This commit is contained in:
fuenmao
2026-10-09 16:46:35 +08:00
parent 4925c4c74d
commit 563836f789
60 changed files with 4729 additions and 2047 deletions
+6
View File
@@ -4,6 +4,12 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"test:devices": "node scripts/test-device-modules.mjs",
"test:device-pages": "node scripts/test-device-pages.mjs",
"test:navigation": "node scripts/test-navigation.mjs",
"test:assessments": "node scripts/test-assessment-pages.mjs",
"test:pages": "node scripts/test-app-pages.mjs",
"test": "npm run test:devices && npm run test:device-pages && npm run test:navigation && npm run test:assessments && npm run test:pages",
"dev": "vite --host 0.0.0.0",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0"
+185
View File
@@ -0,0 +1,185 @@
// 全站页面的假数据逻辑和 SSR 检查,不登录账号、不请求后端、不操作家具。
import assert from 'node:assert/strict'
import { after, test } from 'node:test'
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import { fileURLToPath } from 'node:url'
import { parse, compileScript } from 'vue/compiler-sfc'
import { createSSRApp, h, isRef } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { createMemoryHistory, createRouter } from 'vue-router'
import { createServer } from 'vite'
import vuePlugin from '@vitejs/plugin-vue'
import ts from 'typescript'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src')
const deferred = () => {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const dashboardRecord = (name = '演示用户') => ({ user: { id: 1, displayName: name, phone: '13800000000' },
profile: {}, hasCompletedAssessment: false, tcmResult: { hasCompleted: true, constitutionName: '平和质' } })
const deviceStatus = () => ({ connected: true, sceneDevices: { available: 3 } })
function environment(overrides = {}) {
const disposed = [], toasts = [], navigations = [], tokenWrites = [], mounted = []
const router = { replace: location => navigations.push(location), push: location => navigations.push(location) }
const vue = { defineComponent: options => options, ref: value => ({ value }), reactive: value => value,
computed: getter => ({ get value() { return getter() } }),
onMounted: handler => mounted.push(handler), onUnmounted: handler => disposed.push(handler) }
const healthApi = { dashboard: async () => dashboardRecord(), homeAssistantStatus: async () => deviceStatus(),
getAssessment: async () => ({ hasCompletedAssessment: false, basicInfo: {}, answers: {} }),
login: async () => ({ accessToken: 'test-token', refreshToken: 'test-refresh' }),
register: async () => ({ accessToken: 'test-token', refreshToken: 'test-refresh' }), ...overrides }
let sceneOptions
const mocks = {
vue, 'vue-router': { useRouter: () => router },
vant: { Button: {}, Field: {}, Form: {}, showToast: value => toasts.push(value), showConfirmDialog: async () => {} },
}
const cache = new Map()
function load(relative) {
const file = path.resolve(root, /\.(vue|ts)$/.test(relative) ? relative : relative + '.ts')
const normalized = file.replaceAll('\\', '/')
if (normalized.endsWith('services/health.ts')) return { healthApi }
if (normalized.endsWith('services/auth.ts')) return { saveTokens: (...args) => tokenWrites.push(args), clearTokens: () => tokenWrites.push('clear') }
if (file.endsWith('AppTabBar.vue')) return { default: {} }
if (file.endsWith('useDeviceScenes.ts')) return { useDeviceScenes: options => {
sceneOptions = options; return { sceneRunning: vue.ref(), executeScene() {} }
} }
if (cache.has(file)) return cache.get(file).exports
const raw = fs.readFileSync(file, 'utf8')
const source = file.endsWith('.vue') ? compileScript(parse(raw).descriptor, { id: 'test-page' }).content : raw
const output = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
const module = { exports: {} }; cache.set(file, module)
vm.runInNewContext(output, { module, exports: module.exports,
require: name => mocks[name] ?? load(path.relative(root, path.resolve(path.dirname(file), name))), Date, Intl, Error,
}, { filename: file })
return module.exports
}
return { page: name => load(`views/${name}.vue`).default.setup({}, { expose() {} }), healthApi, toasts, navigations, tokenWrites,
feedback: (...args) => sceneOptions.feedback(...args), dispose: () => disposed.forEach(handler => handler()) }
}
test('dashboard failure is retryable instead of looking like an empty unassessed account', async () => {
const env = environment({ dashboard: async () => { throw new Error('读取失败') } })
const page = env.page('DashboardView'); await page.load()
assert.equal(page.loadError.value, '读取失败'); assert.equal(page.loading.value, false)
assert.equal(page.dashboard.value, undefined)
env.healthApi.dashboard = async () => dashboardRecord(); await page.load()
assert.equal(page.loadError.value, ''); assert.equal(page.dashboard.value.user.displayName, '演示用户')
})
test('dashboard refuses outdated reads and late feedback after leaving', async () => {
const old = deferred(), latest = deferred(); let reads = 0
const env = environment({ dashboard: () => (++reads === 1 ? old : latest).promise })
const page = env.page('DashboardView'), a = page.load(), b = page.load()
latest.resolve(dashboardRecord('新数据')); await b
old.resolve(dashboardRecord('旧数据')); await a
assert.equal(page.dashboard.value.user.displayName, '新数据')
env.dispose(); env.feedback('success', '迟到反馈')
assert.equal(page.actionFeedback.value, undefined)
})
test('profile failed assessment reads do not silently claim an unassessed profile; retry works', async () => {
const env = environment({ getAssessment: async () => { throw new Error('评估读取失败') } })
const page = env.page('ProfileView'); await page.loadData()
assert.equal(page.loadError.value, '评估读取失败'); assert.equal(page.dashboard.value, undefined)
env.healthApi.getAssessment = async () => ({ hasCompletedAssessment: false, basicInfo: {}, answers: {} })
await page.loadData(); assert.equal(page.loadError.value, '')
page.dashboard.value.profile.birthday = 'not-a-date'; assert.equal(page.ageDisplay.value, undefined)
page.dashboard.value.bmi = NaN; assert.equal(page.bmiDisplay.value, null)
})
test('profile displays age from backend timestamps in either profile or assessment', async () => {
const birthday = '2006-01-01 00:00:00'
const env = environment({ dashboard: async () => ({ ...dashboardRecord(), profile: { birthday } }) })
const page = env.page('ProfileView'); await page.loadData()
const expectedAge = new Date().getFullYear() - 2006
assert.equal(page.ageDisplay.value, expectedAge)
page.dashboard.value.profile.birthday = undefined
page.assessment.value.basicInfo.birthday = birthday
assert.equal(page.ageDisplay.value, expectedAge)
page.assessment.value.basicInfo.birthday = undefined
assert.equal(page.ageDisplay.value, undefined)
})
test('profile does not update after leaving during a read', async () => {
const read = deferred(), env = environment({ dashboard: () => read.promise })
const page = env.page('ProfileView'), pending = page.loadData()
env.dispose(); read.resolve(dashboardRecord()); await pending
assert.equal(page.dashboard.value, undefined); assert.equal(env.toasts.length, 0)
})
test('auth blocks duplicate submission and uses the submitted mode even if mode later changes', async () => {
const pending = deferred(); let logins = 0, registers = 0
const env = environment({ login: () => { logins++; return pending.promise }, register: async () => { registers++ } })
const page = env.page('AuthView'); Object.assign(page.form, { phone: '13800000000', password: 'oldpwd' })
const request = page.submit(); page.mode.value = 'register'; await page.submit()
assert.equal(logins, 1); assert.equal(registers, 0)
pending.resolve({ accessToken: 'test-token', refreshToken: 'test-refresh' }); await request
assert.deepEqual(env.navigations, ['/dashboard']); assert.equal(page.loading.value, false)
})
test('registration validates its minimum password and still opens four diagnoses first', async () => {
let writes = 0
const env = environment({ register: async () => { writes++; return { accessToken: 'test-token', refreshToken: 'test-refresh' } } })
const page = env.page('AuthView'); page.mode.value = 'register'
Object.assign(page.form, { phone: '13800000000', password: 'short', displayName: '演示用户' })
await page.submit(); assert.equal(writes, 0)
page.form.password = 'demo-password'; await page.submit()
assert.equal(writes, 1); assert.deepEqual(env.navigations, ['/tcm-assessment'])
})
test('late login response cannot write a token or navigate after leaving the auth page', async () => {
const pending = deferred(), env = environment({ login: () => pending.promise })
const page = env.page('AuthView'); Object.assign(page.form, { phone: '13800000000', password: 'demo-password' })
const request = page.submit(); env.dispose()
pending.resolve({ accessToken: 'test-token', refreshToken: 'test-refresh' }); await request
assert.equal(env.tokenWrites.length, 0); assert.equal(env.navigations.length, 0); assert.equal(env.toasts.length, 0)
})
const server = await createServer({ configFile: false, root: path.dirname(root), plugins: [vuePlugin()], appType: 'custom',
server: { middlewareMode: true, hmr: false }, optimizeDeps: { noDiscovery: true, include: [] } })
after(() => server.close())
async function render(name, values = {}, routePath = '/dashboard') {
const { default: view } = await server.ssrLoadModule(`/src/views/${name}.vue`)
const component = { ...view, setup(props, context) {
const state = view.setup(props, context)
for (const [key, value] of Object.entries(values)) { assert.ok(isRef(state[key]), key); state[key].value = value }
return state
} }
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component }] })
await router.push(routePath); await router.isReady()
const app = createSSRApp({ render: () => h(component) }); app.use(router)
return renderToString(app)
}
test('home renders a prominent scene, collapsed presets, a real profile entry and the required navigation order', async () => {
const record = { ...dashboardRecord(), bmi: 31.1, sceneConfig: { targetHumidity: 55, fanLevel: '1挡', lightBrightnessPercent: 50, lightColorTemperatureKelvin: 2700 } }
const html = await render('DashboardView', { loading: false, dashboard: record, homeAssistant: deviceStatus() })
for (const value of ['我的舒睡空间', '一键开启舒睡', '查看舒睡设备', '查看体质启动预设', '打开我的健康档案']) assert.ok(html.includes(value), value)
assert.match(html, /<details class="startup-preset-details">/)
assert.ok(html.indexOf('首页</span>') < html.indexOf('中医四诊</span>'))
assert.ok(html.indexOf('中医四诊</span>') < html.indexOf('健康评估</span>'))
assert.ok(html.indexOf('健康评估</span>') < html.indexOf('我的</span>'))
})
test('error pages render retry controls without mistaken missing-assessment or device actions', async () => {
for (const [name, path] of [['DashboardView', '/dashboard'], ['ProfileView', '/profile']]) {
const html = await render(name, { loading: false, loadError: '测试连接失败' }, path)
assert.ok(html.includes('测试连接失败')); assert.ok(html.includes('重新加载'))
assert.ok(!html.includes('一键开启舒睡')); assert.ok(!html.includes('未评估'))
}
})
test('auth and both questionnaires render safely; loading records never show an editable form', async () => {
const auth = await render('AuthView', {}, '/auth')
assert.ok(auth.includes('autocomplete="username"')); assert.ok(auth.includes('autocomplete="current-password"'))
for (const [name, path] of [['AssessmentView', '/assessment'], ['TcmAssessmentView', '/tcm-assessment']]) {
const html = await render(name, {}, path)
assert.ok(html.includes('正在读取'))
assert.ok(!html.includes('type="number"')); assert.ok(!html.includes('<textarea'))
}
})
+253
View File
@@ -0,0 +1,253 @@
// Isolated page logic regression checks. No HTTP, accounts, or real devices.
import assert from 'node:assert/strict'
import { test } from 'node:test'
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import { fileURLToPath } from 'node:url'
import { parse, compileScript } from 'vue/compiler-sfc'
import ts from 'typescript'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src')
const deferred = () => {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const healthAnswers = () => ({
sleepDuration: '7-8', fallingAsleepSpeed: 'normal', temperaturePreference: 'normal',
nightAwakening: 'none', mattressPreference: 'medium', exerciseFrequency: 'daily',
muscleFatigueArea: 'none', activeTimeOfDay: 'morning', mealRegularity: 'regular',
flavorPreference: 'light', lateNightEating: 'rarely', dailyWaterIntake: 'moderate'
})
const tcmRecord = () => ({
hasCompleted: true, constitutionCode: 'balanced', constitutionName: '平和质',
answers: { face: 'rosy', body: 'balanced', spirit: 'energetic', voice: 'normal',
coldHeat: 'neutral', sweating: 'normal', mouth: 'normal', stool: 'normal',
sleep: 'good', emotion: 'steady', allergy: 'none', pulse: 'unknown' },
notes: { tongue: '', odor: '', breath: '', pulseNote: '' }, sceneConfig: {}
})
function environment(overrides = {}) {
const mounted = [], disposed = [], toasts = [], navigations = [], scrolls = [], ticks = []
const vue = {
defineComponent: options => options, ref: value => ({ value }), reactive: value => value,
computed: getter => ({ get value() { return getter() } }),
onMounted: handler => mounted.push(handler), onUnmounted: handler => disposed.push(handler),
nextTick: async () => { ticks.push('tick') }
}
const healthApi = {
getAssessment: async () => ({ hasCompletedAssessment: false }),
saveAssessment: async () => ({ hasCompletedAssessment: true }),
getTcmAssessment: async () => ({ hasCompleted: false }),
saveTcmAssessment: async () => tcmRecord(), ...overrides
}
const mocks = {
vue, 'vue-router': { useRouter: () => ({ replace: location => navigations.push(location) }) },
vant: { Button: {}, showToast: message => toasts.push(message),
showSuccessToast: message => toasts.push(message), showFailToast: message => toasts.push(message) }
}
const cache = new Map()
function load(relative) {
const file = path.resolve(root, /\.(ts|vue)$/.test(relative) ? relative : relative + '.ts')
if (file === path.join(root, 'services/health.ts')) return { healthApi }
if (file.endsWith('AppTabBar.vue')) return { default: {} }
if (cache.has(file)) return cache.get(file).exports
const module = { exports: {} }; cache.set(file, module)
const raw = fs.readFileSync(file, 'utf8')
const source = file.endsWith('.vue')
? compileScript(parse(raw).descriptor, { id: 'test-assessment' }).content
: raw
const output = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
vm.runInNewContext(output, {
exports: module.exports, module,
require: name => mocks[name] ?? load(path.relative(root, path.resolve(path.dirname(file), name))),
document: { getElementById: id => ({ scrollIntoView: options => scrolls.push({ id, options, tickCount: ticks.length }) }) },
Date, Error
}, { filename: file })
return module.exports
}
const page = relative => load('views/' + relative + '.vue').default.setup({}, { expose() {} })
return { load, page, healthApi, toasts, navigations, scrolls, ticks, dispose: () => disposed.forEach(handler => handler()) }
}
test('validation rejects non-finite numbers, fractional ages, stale choices, and impossible birthdays', () => {
const validation = environment().load('services/formValidation')
for (const input of [NaN, Infinity, -Infinity, '70', '', null, undefined]) {
assert.equal(validation.finiteNumberInRange(input, 20, 250), false)
}
assert.equal(validation.finiteNumberInRange(31.5, 1, 120, true), false)
assert.equal(validation.finiteNumberInRange(31, 1, 120, true), true)
assert.equal(validation.finiteNumberInRange(70.5, 20, 250), true)
assert.equal(validation.healthAnswersComplete(healthAnswers()), true)
assert.equal(validation.healthAnswersComplete({ ...healthAnswers(), sleepDuration: 'legacy-choice' }), false)
const today = new Date(2026, 9, 9)
assert.equal(validation.ageFromBirthday('2000-10-10T00:00:00', today), 25)
assert.equal(validation.ageFromBirthday('2000-10-09', today), 26)
assert.equal(validation.ageFromBirthday('2000-02-29', today), 26)
assert.equal(validation.ageFromBirthday('2001-02-29', today), undefined)
assert.equal(validation.ageFromBirthday('2027-01-01', today), undefined)
assert.equal(validation.ageFromBirthday('bad-date', today), undefined)
})
test('age calculation accepts backend timestamps without shifting the calendar birthday', () => {
const validation = environment().load('services/formValidation')
const today = new Date(2026, 9, 9)
for (const birthday of ['2006-01-01', '2006-01-01 00:00:00', '2006-01-01T00:00:00',
'2006-01-01T00:00:00.000+08:00', '2006-01-01T23:00:00Z']) {
assert.equal(validation.ageFromBirthday(birthday, today), 20, birthday)
}
assert.equal(validation.ageFromBirthday('2000-10-10 00:00:00', today), 25)
assert.equal(validation.ageFromBirthday('2000-10-09 00:00:00', today), 26)
assert.equal(validation.ageFromBirthday('2001-02-29 00:00:00', today), undefined)
assert.equal(validation.ageFromBirthday('2027-01-01 00:00:00', today), undefined)
assert.equal(validation.ageFromBirthday(null, today), undefined)
assert.equal(validation.ageFromBirthday('2006-01-01 unexpected', today), undefined)
})
test('assessment loads and resaves the backend birthday without losing age or rewriting the saved date', async () => {
const birthday = '2006-01-01 00:00:00', payloads = []
let stored = { hasCompletedAssessment: true,
basicInfo: { gender: '男', heightCm: 165, weightKg: 60, birthday }, answers: healthAnswers() }
const env = environment({ getAssessment: async () => stored,
saveAssessment: async payload => {
payloads.push(payload)
stored = { ...payload, hasCompletedAssessment: true }
return stored
} })
const page = env.page('AssessmentView'); await page.loadExisting()
const expectedAge = new Date().getFullYear() - 2006
assert.equal(page.age.value, expectedAge)
await page.handleSave()
assert.equal(payloads.length, 1)
assert.equal(payloads[0].basicInfo.birthday, birthday)
await page.loadExisting()
assert.equal(page.age.value, expectedAge)
assert.equal(page.basicInfo.birthday, birthday)
})
test('assessment cannot save while records are loading or after a failed read; retry restores saved information', async () => {
const read = deferred(); let writes = 0
const env = environment({ getAssessment: () => read.promise, saveAssessment: async () => { writes++; return { hasCompletedAssessment: true } } })
const page = env.page('AssessmentView'), pending = page.loadExisting()
await page.handleSave(); assert.equal(writes, 0)
read.reject(new Error('读取失败')); await pending
assert.equal(page.loadError.value, '读取失败')
await page.handleSave(); assert.equal(writes, 0)
env.healthApi.getAssessment = async () => ({ hasCompletedAssessment: true,
basicInfo: { gender: '男', heightCm: 175, weightKg: 68, birthday: '1990-05-21' }, answers: healthAnswers() })
await page.loadExisting()
assert.equal(page.loadError.value, ''); assert.equal(page.hasPreviousAssessment.value, true)
assert.equal(page.basicInfo.weightKg, 68); assert.equal(page.answers.sleepDuration, '7-8')
})
test('assessment save preserves a real birthday, blocks repeat submission, and navigates once', async () => {
const saved = deferred(); const payloads = []
const env = environment({ getAssessment: async () => ({ hasCompletedAssessment: true,
basicInfo: { gender: '女', heightCm: 165, weightKg: 60, birthday: '1990-05-21T00:00:00' }, answers: healthAnswers() }),
saveAssessment: payload => { payloads.push(payload); return saved.promise } })
const page = env.page('AssessmentView'); await page.loadExisting()
const save = page.handleSave(); await page.handleSave()
assert.equal(payloads.length, 1); assert.equal(page.submitting.value, true)
assert.equal(payloads[0].basicInfo.birthday, '1990-05-21T00:00:00')
saved.resolve({ hasCompletedAssessment: true }); await save
assert.deepEqual(env.navigations, ['/dashboard']); assert.equal(page.submitting.value, false)
})
test('assessment does not toast or navigate after leaving during a save', async () => {
const saved = deferred()
const env = environment({ saveAssessment: () => saved.promise })
const page = env.page('AssessmentView'); await page.loadExisting()
Object.assign(page.basicInfo, { gender: '男', heightCm: 175, weightKg: 70 }); page.age.value = 30
Object.assign(page.answers, healthAnswers())
const request = page.handleSave(); env.dispose()
saved.resolve({ hasCompletedAssessment: true }); await request
assert.equal(env.navigations.length, 0); assert.equal(env.toasts.length, 0)
})
test('a failed save does not change the original birthday when the user reverts an age edit', async () => {
const payloads = []
const env = environment({ getAssessment: async () => ({ hasCompletedAssessment: true,
basicInfo: { gender: '女', heightCm: 165, weightKg: 60, birthday: '1990-05-21' }, answers: healthAnswers() }),
saveAssessment: async payload => { payloads.push(payload); throw new Error('保存失败') } })
const page = env.page('AssessmentView'); await page.loadExisting()
const originalAge = page.age.value; page.age.value = originalAge + 1
await page.handleSave()
assert.equal(page.basicInfo.birthday, '1990-05-21')
page.age.value = originalAge; await page.handleSave()
assert.equal(payloads[1].basicInfo.birthday, '1990-05-21')
assert.equal(env.navigations.length, 0)
})
test('four diagnoses show changed answers as outdated until regeneration and scroll only after rendering', async () => {
const saved = deferred(); let writes = 0
const env = environment({ getTcmAssessment: async () => tcmRecord(), saveTcmAssessment: () => { writes++; return saved.promise } })
const page = env.page('TcmAssessmentView'); await page.loadExisting()
assert.equal(page.resultOutdated.value, false)
page.answers.face = 'pale'; assert.equal(page.resultOutdated.value, true)
page.answers.face = 'rosy'; assert.equal(page.resultOutdated.value, false)
page.notes.tongue = '备注'; assert.equal(page.resultOutdated.value, true)
const request = page.submit(); await page.submit()
assert.equal(writes, 1); assert.equal(page.saving.value, true)
saved.resolve(tcmRecord()); await request
assert.equal(page.resultOutdated.value, false); assert.equal(page.saving.value, false)
assert.equal(env.scrolls.at(-1).id, 'tcm-result'); assert.equal(env.scrolls.at(-1).tickCount, 1)
})
test('four diagnoses reject unknown stored choices and prevent saves after a failed read', async () => {
let writes = 0
const env = environment({ getTcmAssessment: async () => { throw new Error('网络异常') },
saveTcmAssessment: async () => { writes++; return tcmRecord() } })
const page = env.page('TcmAssessmentView'); await page.loadExisting(); await page.submit()
assert.equal(writes, 0); assert.equal(page.loadError.value, '网络异常')
env.healthApi.getTcmAssessment = async () => tcmRecord(); await page.loadExisting()
page.answers.face = 'legacy-choice'; await page.submit()
assert.equal(writes, 0); assert.equal(page.answeredCount.value, 11)
assert.equal(env.scrolls.at(-1).id, 'tcm-face')
})
test('both pages discard reads completed after leaving instead of treating them as active data', async () => {
for (const [file, api, completed] of [
['AssessmentView', 'getAssessment', { hasCompletedAssessment: true, basicInfo: { weightKg: 70 }, answers: healthAnswers() }],
['TcmAssessmentView', 'getTcmAssessment', tcmRecord()]
]) {
const read = deferred(), env = environment({ [api]: () => read.promise })
const page = env.page(file), request = page.loadExisting(); env.dispose()
read.resolve(completed); await request
assert.equal(page.loading.value, true)
assert.equal(env.toasts.length, 0)
if (file === 'AssessmentView') assert.equal(page.basicInfo.weightKg, undefined)
else assert.equal(page.result.value, undefined)
}
})
test('four diagnoses do not change results, toast, or scroll after leaving during generation', async () => {
const saved = deferred()
const env = environment({ getTcmAssessment: async () => tcmRecord(), saveTcmAssessment: () => saved.promise })
const page = env.page('TcmAssessmentView'); await page.loadExisting()
const previous = page.result.value; page.answers.face = 'pale'
const request = page.submit(); env.dispose()
saved.resolve({ ...tcmRecord(), constitutionName: '新结果' }); await request
assert.equal(page.result.value, previous)
assert.equal(env.toasts.length, 0); assert.equal(env.scrolls.length, 0)
})
test('health choices are native buttons and both forms lock editing while saving', () => {
const healthSource = fs.readFileSync(path.join(root, 'views/AssessmentView.vue'), 'utf8')
const tcmSource = fs.readFileSync(path.join(root, 'views/TcmAssessmentView.vue'), 'utf8')
assert.equal(/<div\s+class="option-(pill|card)"/.test(healthSource), false)
const healthChoiceCount = (healthSource.match(/class="option-(pill|card)"/g) ?? []).length
assert.ok(healthChoiceCount > 30)
assert.equal((healthSource.match(/:aria-pressed=/g) ?? []).length, healthChoiceCount)
assert.match(healthSource, /<fieldset[^>]*:disabled="submitting"[^>]*:inert="submitting \|\| undefined"/)
assert.match(tcmSource, /<fieldset[^>]*:disabled="saving"[^>]*:inert="saving \|\| undefined"/)
})
test('page request guard also ignores superseded results', () => {
const env = environment(), guard = env.load('composables/usePageRequest').usePageRequest()
const first = guard.begin(), second = guard.begin()
assert.equal(guard.isCurrent(first), false); assert.equal(guard.isCurrent(second), true)
env.dispose(); assert.equal(guard.isCurrent(second), false)
})
+503
View File
@@ -0,0 +1,503 @@
// 无真实网络/家具的回归检查;使用现有 TypeScript 编译器和 Node 内置测试运行器。
import assert from 'node:assert/strict'
import { test } from 'node:test'
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src')
const deferred = () => {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve() }
const control = (state, attributes = {}, available = true) => ({ entityId: 'fake', state, attributes, available })
const status = (controls = {}, marker = '') => ({ connected: true, message: marker, devices: [], controlParameters: controls })
const dashboard = () => ({ user: { id: 1 }, tcmResult: { hasCompleted: true } })
const sceneResult = (message, sceneCode = 'sleep') => ({ success: true, status: 'Succeeded', message, sceneCode, actions: [] })
function environment(overrides = {}) {
const intervals = new Map(), timeouts = new Map(), storage = new Map(), disposed = [], mounted = [], toasts = []
let id = 0
const vue = {
ref: value => ({ value }), reactive: value => value,
computed: getter => ({ get value() { return getter() } }),
onUnmounted: handler => disposed.push(handler), onMounted: handler => mounted.push(handler),
}
const healthApi = {
homeAssistantStatus: async () => status(), dashboard: async () => dashboard(),
musicTransitionStatus: async () => ({ running: false, hasVolume: false, volumePercent: 0 }),
lightTransitionStatus: async () => ({ running: false, brightnessPercent: 0 }),
...overrides,
}
const mocks = {
vue, 'vue-router': { useRouter: () => ({ push() {}, replace() {}, back() {} }) },
vant: {
showFailToast: message => toasts.push({ type: 'fail', message }),
showSuccessToast: message => toasts.push({ type: 'success', message }),
showConfirmDialog: overrides.confirmDialog ?? (async () => {}),
showLoadingToast: options => { const toast = { ...options, close() { this.closed = true } }; toasts.push(toast); return toast },
},
}
const cache = new Map()
function load(relative) {
const file = path.resolve(root, relative.endsWith('.ts') ? relative : relative + '.ts')
if (cache.has(file)) return cache.get(file).exports
if (file === path.join(root, 'services/health.ts')) return { healthApi }
const module = { exports: {} }; cache.set(file, module)
const source = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
}).outputText
const context = {
exports: module.exports, module,
require: name => mocks[name] ?? load(path.relative(root, path.resolve(path.dirname(file), name))),
setInterval: callback => { const key = ++id; intervals.set(key, callback); return key },
clearInterval: key => intervals.delete(key),
setTimeout: callback => { const key = ++id; timeouts.set(key, callback); return key },
clearTimeout: key => timeouts.delete(key),
localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) },
window: { history: { length: 1 } }, Intl, Date,
}
vm.runInNewContext(source, context, { filename: file })
return module.exports
}
return { load, healthApi, intervals, timeouts, toasts, vue, dispose: () => disposed.forEach(fn => fn()), mount: () => mounted.forEach(fn => fn()) }
}
test('parameter conversion rejects junk and unavailable data; light zero and hardware limits are respected', () => {
const env = environment(), helpers = env.load('devices/parameterState')
assert.equal(helpers.numberValue('31.1garbage'), undefined)
assert.equal(helpers.numberValue(''), undefined)
assert.equal(helpers.boolValue(control('on', {}, false)), undefined)
assert.equal(helpers.parameterIsAvailable({ quick: control('unknown') }, 'quick'), true)
assert.equal(helpers.parameterIsAvailable({}, 'quick'), false)
const { createDeviceParameterState, syncDeviceParameters } = env.load('devices/parameterRegistry')
const params = createDeviceParameterState()
syncDeviceParameters(params, { brightness: control('off'), color_temp: control('on', { color_temp_kelvin: 6500 }),
mattress_left_heating: control('4'), volume: control('on', { volume_level: .56 }), fragrance_auto_light: control('on') })
assert.equal(params.lightBrightness, 0); assert.equal(params.lightColorTemp, 5000)
assert.equal(params.mattressLeftHeating, 4); assert.equal(params.speakerVolume, 56)
assert.equal(params.fragranceAutoLight, true)
syncDeviceParameters(params, { fragrance_auto_light: control('unavailable', {}, false), volume: control('on', { volume_level: 'NaN' }) })
assert.equal(params.fragranceAutoLight, true); assert.equal(params.speakerVolume, 56)
})
test('parameter availability is scoped to the actual device; missing numeric attributes are not default readings', () => {
const { parameterIsAvailable, parameterHasReading } = environment().load('devices/parameterState')
const lamp = device('lighting', 'Lighting', [entity('light.room', 'light', 'on')])
const controls = { brightness: { ...control('on'), entityId: 'light.room' } }
assert.equal(parameterIsAvailable(controls, 'brightness', lamp), true)
assert.equal(parameterHasReading(controls, 'brightness', lamp), false)
controls.brightness.attributes.brightness = 128
assert.equal(parameterHasReading(controls, 'brightness', lamp), true)
lamp.entities[0].available = false
assert.equal(parameterIsAvailable(controls, 'brightness', lamp), false)
lamp.entities[0].available = true; controls.brightness.entityId = 'light.foreign'
assert.equal(parameterIsAvailable(controls, 'brightness', lamp), false)
controls.brightness.entityId = 'light.room'; lamp.online = false
assert.equal(parameterIsAvailable(controls, 'brightness', lamp), false)
})
test('mattress per-user preferences remain separate from the one physically running side', () => {
const env = environment(), params = env.load('devices/parameterRegistry').createDeviceParameterState()
const user = env.vue.ref(1), hook = env.load('devices/mattress/useMattressPreferences').useMattressPreferences(params, user, () => 'mattress')
hook.syncModes({ mattress_left_mode: control('智能适配'), mattress_right_mode: control('无') })
assert.equal(params.mattressLeftMode, '智能适配'); assert.equal(params.mattressRightMode, '智能适配')
assert.equal(hook.mattressActiveSide.value, 'left')
hook.commitMode('left', '瑜伽助眠'); hook.commitMode('right', '智能腰托')
hook.syncModes({ mattress_left_mode: control('无'), mattress_right_mode: control('智能腰托') })
assert.equal(params.mattressLeftMode, '瑜伽助眠'); assert.equal(hook.mattressActiveSide.value, 'right')
hook.syncModes({ mattress_left_mode: control('智能适配'), mattress_right_mode: control('智能腰托') })
assert.equal(hook.mattressActiveSide.value, 'right')
user.value = 2; hook.syncModes({})
assert.equal(params.mattressLeftMode, '智能适配')
})
test('older transition response cannot replace newer volume; disposal cannot create a late timer', async () => {
const first = deferred(), second = deferred(); let reads = 0
const env = environment({ musicTransitionStatus: () => (++reads === 1 ? first : second).promise })
const params = env.load('devices/parameterRegistry').createDeviceParameterState()
const hook = env.load('composables/useDeviceTransitions').useDeviceTransitions(params, async () => {})
const a = hook.syncMusicTransitionStatus(), b = hook.syncMusicTransitionStatus()
second.resolve({ running: true, hasVolume: true, volumePercent: 46 }); await b
first.resolve({ running: true, hasVolume: true, volumePercent: 50 }); await a
assert.equal(params.speakerVolume, 46); assert.equal(env.intervals.size, 1)
env.dispose(); assert.equal(env.intervals.size, 0)
const late = deferred(); env.healthApi.musicTransitionStatus = () => late.promise
const request = hook.syncMusicTransitionStatus()
late.resolve({ running: true, hasVolume: true, volumePercent: 20 }); await request
assert.equal(params.speakerVolume, 46); assert.equal(env.intervals.size, 0)
})
test('out-of-order status reads preserve newer state and reported music overrides old HA volume', async () => {
const first = deferred(), second = deferred(); let reads = 0
const env = environment({ homeAssistantStatus: () => (++reads === 1 ? first : second).promise,
musicTransitionStatus: async () => ({ running: true, hasVolume: true, volumePercent: 46 }) })
const hook = env.load('composables/useDeviceControl').useDeviceControl()
const a = hook.loadStatus(), b = hook.loadStatus()
second.resolve(status({ volume: control('on', { volume_level: .7 }) }, 'new')); await b
first.resolve(status({}, 'old')); await a
assert.equal(hook.haStatus.value.message, 'new'); assert.equal(hook.localParams.speakerVolume, 46)
assert.equal(hook.loading.value, false)
env.dispose(); assert.equal(env.intervals.size, 0)
})
test('all-off can interrupt start; a late start result cannot overwrite off feedback or busy state', async () => {
const start = deferred(), off = deferred()
const env = environment({ executeSleepScene: () => start.promise, executeAllOffScene: () => off.promise })
const hook = env.load('composables/useDeviceControl').useDeviceControl()
await hook.loadStatus()
const a = hook.executeScene('sleep'), b = hook.executeScene('all-off')
assert.equal(hook.sceneRunning.value, 'all-off')
assert.equal(env.toasts.find(toast => toast.message === '正在启动舒睡场景…').forbidClick, false)
off.resolve(sceneResult('已关闭', 'all-off')); await b
start.resolve(sceneResult('已开启')); await a
assert.equal(hook.actionFeedback.value.message, '已关闭'); assert.equal(hook.sceneRunning.value, undefined)
env.dispose(); assert.equal(env.timeouts.size, 0)
})
test('failed parameter command restores last known reading even if refresh itself fails', async () => {
const env = environment({ homeAssistantStatus: async () => status({ fragrance_auto_light: control('off') }),
setParameter: async () => ({ success: false, message: '失败' }) })
const hook = env.load('composables/useDeviceControl').useDeviceControl()
await hook.loadStatus(); hook.localParams.fragranceAutoLight = true
env.healthApi.homeAssistantStatus = async () => { throw new Error('离线') }
assert.equal(await hook.applyParam('fragrance', 'fragrance_auto_light', true), false)
assert.equal(hook.localParams.fragranceAutoLight, false)
assert.equal(hook.actionFeedback.value.message, '失败')
env.dispose(); assert.equal(env.timeouts.size, 0)
})
test('disposing a page while a scene is pending prevents late refresh and success toast', async () => {
const pending = deferred(); let reads = 0
const env = environment({ homeAssistantStatus: async () => { reads++; return status() }, executeSleepScene: () => pending.promise })
const hook = env.load('composables/useDeviceControl').useDeviceControl()
await hook.loadStatus(); const scene = hook.executeScene('sleep')
env.dispose(); pending.resolve(sceneResult('迟到结果')); await scene; await flush()
assert.equal(reads, 1); assert.equal(env.intervals.size, 0); assert.equal(env.timeouts.size, 0)
assert.equal(env.toasts.some(toast => toast.message === '迟到结果'), false)
})
test('unavailable or unknown primary switches do not send commands; pending commands are not duplicated', async () => {
let calls = 0
const pending = deferred()
const env = environment({ switchDevice: () => { calls++; return pending.promise } })
const hook = env.load('composables/useDeviceControl').useDeviceControl()
await hook.loadStatus()
const item = device('fragrance', 'Fragrance', [entity('switch.fragrance', 'switch', 'unknown')],
{ primaryControlEntityId: 'switch.fragrance' })
await hook.toggleDevice(item); assert.equal(calls, 0)
item.entities[0].state = 'off'; item.entities[0].available = false
await hook.toggleDevice(item); assert.equal(calls, 0)
item.entities[0].available = true
const request = hook.toggleDevice(item)
await hook.toggleDevice(item); assert.equal(calls, 1)
pending.resolve({ success: true, deviceId: item.deviceId, entityId: 'switch.fragrance', state: 'on', message: '开启完成' })
await request; assert.equal(hook.switchingDeviceId.value, null)
env.dispose()
})
test('navigation closes a pending parameter toast and prevents a late result from refreshing or reporting success', async () => {
const pending = deferred(); let reads = 0, writes = 0
const env = environment({ homeAssistantStatus: async () => { reads++; return status() },
setParameter: () => { writes++; return pending.promise } })
const hook = env.load('composables/useDeviceControl').useDeviceControl()
await hook.loadStatus()
const request = hook.applyParam('speaker', 'volume', 20)
assert.equal(await hook.applyParam('speaker', 'volume', 30), false)
assert.equal(writes, 1)
const toast = env.toasts.find(item => item.message === '正在设置音箱音量…')
assert.equal(toast.closed, undefined)
env.dispose(); assert.equal(toast.closed, true)
pending.resolve({ success: true, message: '迟到的参数结果' })
assert.equal(await request, false)
assert.equal(reads, 1)
assert.equal(env.toasts.some(item => item.message === '迟到的参数结果'), false)
assert.equal(env.timeouts.size, 0)
})
test('mattress confirmation cannot act after leaving the page or switching to another mattress', async () => {
const confirmation = deferred(), env = environment({ confirmDialog: () => confirmation.promise })
const selected = env.vue.ref(device('mattress', 'Sleep', [], { deviceId: 'first' }))
let writes = 0
const hook = env.load('devices/mattress/useMattressControls').useMattressControls(selected,
async () => { writes++; return true }, async () => {}, () => {})
const request = hook.setMattressSelect('mattress_massage_right_part', { target: { value: '全身' } })
selected.value = undefined; confirmation.resolve(); await request
assert.equal(writes, 0)
selected.value = device('mattress', 'Sleep', [], { deviceId: 'first' })
env.dispose()
await hook.setMattressSelect('mattress_massage_right_part', { target: { value: '全身' } })
assert.equal(writes, 0)
})
const device = (moduleKey, category, entities = [], extra = {}) => ({
deviceId: 'device', name: '测试设备', manufacturer: '', model: '', moduleKey, category,
online: true, entities, ...extra,
})
const entity = (entityId, domain, state, attributes = {}, available = true) => ({ entityId, domain, state, attributes, available })
const outletDevice = () => device('outlet', 'Outlet', [entity('switch.service', 'switch', 'on'), entity('switch.power', 'switch', 'off')],
{ primaryControlEntityId: 'switch.power' })
test('outlet summary and power capability use only the bound main switch, including a valid off state', () => {
const env = environment(), { deviceSummary } = env.load('devices/deviceSummary')
const { outletCapabilities } = env.load('devices/outlet/capabilities'), item = outletDevice()
assert.equal(outletCapabilities(item).available, true); assert.equal(outletCapabilities(item).powerOn, false)
assert.equal(deviceSummary(item).statusText, '已断电'); assert.equal(deviceSummary(item).canToggle, true)
assert.equal(deviceSummary(item).icon, 'outlet'); assert.equal(deviceSummary(item).categoryLabel, '智能插座')
item.entities[1].state = 'on'
assert.equal(deviceSummary(item).statusText, '已通电'); assert.equal(outletCapabilities(item).powerOn, true)
})
test('outlet unknown/offline/missing main entity never borrows a working auxiliary switch', () => {
const env = environment(), { outletCapabilities } = env.load('devices/outlet/capabilities')
const { deviceSummary } = env.load('devices/deviceSummary'), hook = env.load('composables/useDeviceControl').useDeviceControl()
for (const state of ['unknown', 'unavailable', '', 'idle', 'standby']) {
const item = outletDevice(); item.entities[1].state = state
assert.equal(outletCapabilities(item).available, false); assert.equal(outletCapabilities(item).powerOn, null)
assert.equal(deviceSummary(item).canToggle, false); assert.equal(hook.isDeviceControlAvailable(item), false)
}
const item = outletDevice(); item.entities[1].available = false
assert.equal(outletCapabilities(item).available, false)
item.entities[1].available = true; item.primaryControlEntityId = 'switch.foreign'
assert.equal(outletCapabilities(item).available, false)
item.primaryControlEntityId = 'switch.power'; item.moduleKey = ''
assert.equal(deviceSummary(item).canToggle, false); assert.equal(hook.isDeviceControlAvailable(item), false)
item.moduleKey = 'outlet'; item.online = false
assert.equal(outletCapabilities(item).powerOn, null); assert.equal(hook.isDeviceControlAvailable(item), false)
env.dispose()
})
test('outlet energy is standard read-only own telemetry, not an encoded power/voltage engineering field', () => {
const { outletCapabilities } = environment().load('devices/outlet/capabilities'), item = outletDevice()
item.entities.push(entity('sensor.encoded', 'sensor', '8472528'))
assert.equal(outletCapabilities(item).energy, undefined)
item.entities.push({ ...entity('sensor.energy', 'sensor', '0', { device_class: 'energy' }), unit: 'kWh' })
assert.equal(outletCapabilities(item).energy, 0)
for (const value of ['unknown', '-1', 'NaN', '20junk']) {
item.entities[3].state = value; assert.equal(outletCapabilities(item).energy, undefined)
}
item.entities[3].state = '1.25'; item.entities[3].available = false
assert.equal(outletCapabilities(item).energy, undefined)
item.entities[3].available = true; item.entities[3].unit = 'V'
assert.equal(outletCapabilities(item).energy, undefined)
})
test('manual outlet toggle sends exact main power entity and refuses unavailable controls', async () => {
const writes = [], item = outletDevice()
const env = environment({ switchDevice: async payload => {
writes.push(payload); const state = payload.turnOn ? 'on' : 'off'; item.entities[1].state = state
return { success: true, deviceId: payload.deviceId, entityId: payload.entityId, state, message: '已确认' }
} })
const hook = env.load('composables/useDeviceControl').useDeviceControl(); await hook.loadStatus()
await hook.toggleDevice(item); await hook.toggleDevice(item)
assert.equal(writes.length, 2); assert.equal(writes[0].turnOn, true); assert.equal(writes[1].turnOn, false)
for (const write of writes) assert.equal(write.entityId, 'switch.power')
item.entities[1].state = 'unknown'; await hook.toggleDevice(item)
assert.equal(writes.length, 2); env.dispose()
})
test('overview distinguishes a known off state from unavailable data and never adds unconnected climate controls', () => {
const { deviceSummary } = environment().load('devices/deviceSummary')
const bulb = device('lighting', 'Lighting', [entity('light.room', 'light', 'on', { brightness: 128 })], { primaryControlEntityId: 'light.room' })
assert.equal(deviceSummary(bulb).metricText, '亮度 50%')
assert.equal(deviceSummary(bulb).canToggle, true)
bulb.entities[0].state = 'off'
assert.equal(deviceSummary(bulb).powerOn, false)
bulb.entities[0].state = 'unknown'
assert.equal(deviceSummary(bulb).powerOn, null)
assert.equal(deviceSummary(bulb).canToggle, false)
bulb.online = false
assert.equal(deviceSummary(bulb).statusText, '设备离线')
const climate = device('', 'Climate', [entity('climate.room', 'climate', 'cool')], { primaryControlEntityId: 'climate.room' })
assert.equal(deviceSummary(climate).canToggle, false)
})
test('overview separates fragrance from its nightlight and only uses mattress readings belonging to that device', () => {
const { deviceSummary } = environment().load('devices/deviceSummary')
const fragrance = device('fragrance', 'Fragrance', [entity('switch.fragrance', 'switch', 'off'), entity('light.night', 'light', 'on')],
{ primaryControlEntityId: 'switch.fragrance' })
assert.equal(deviceSummary(fragrance).powerOn, false)
assert.equal(deviceSummary(fragrance).statusText, '已关闭')
const mattress = device('mattress', 'Sleep', [entity('select.massage', 'select', '全身')])
assert.equal(deviceSummary(mattress).canToggle, false)
assert.equal(deviceSummary(mattress, { controls: { mattress_massage_right_part: { ...control('全身'), entityId: 'foreign' } } }).statusText, '已连接')
assert.equal(deviceSummary(mattress, { controls: { mattress_massage_right_part: { ...control('全身'), entityId: 'select.massage' } } }).statusText, '右侧按摩中')
assert.equal(deviceSummary(mattress, { controls: { mattress_massage_right_part: { ...control('unknown'), entityId: 'select.massage' } } }).statusText, '已连接')
})
test('overview uses latest music runtime volume even if HA still reports an old value or an offline speaker', () => {
const { deviceSummary } = environment().load('devices/deviceSummary')
const audio = device('audio', 'Media', [entity('media_player.room', 'media_player', 'idle', { volume_level: .7 })],
{ primaryControlEntityId: 'media_player.room' })
const music = { running: true, hasVolume: true, volumePercent: 46 }
assert.equal(deviceSummary(audio, { music }).metricText, '音量 46%')
audio.online = false; audio.entities[0].available = false
const summary = deviceSummary(audio, { music })
assert.equal(summary.statusText, '助眠播放中')
assert.equal(summary.powerOn, true)
assert.equal(summary.canToggle, true)
assert.equal(summary.audioControl, true)
})
test('module mapping never borrows same-category furniture; detail navigation preserves raw device IDs for router encoding', () => {
const { deviceModuleKey, deviceDetailLocation } = environment().load('devices/deviceKinds')
assert.equal(deviceModuleKey(device('', 'Lighting')), '')
assert.equal(deviceModuleKey(device(undefined, 'Lighting')), 'lighting')
assert.equal(deviceModuleKey(device('new-device', 'Lighting')), 'new-device')
const location = deviceDetailLocation('房间/灯 #1?')
assert.equal(location.name, 'device-detail')
assert.equal(location.params.deviceId, '房间/灯 #1?')
})
const climateAttributes = () => ({ hvac_modes: ['cool', 'heat', 'fan_only', 'off'], min_temp: 5, max_temp: 35,
temperature_unit: '°C',
target_temp_step: .5, fan_modes: ['自动', '低风', '中风', '高风'], current_temperature: 24.1,
temperature: 23, fan_mode: '低风', supported_features: 393 })
const climateDevice = (attributes = climateAttributes(), state = 'off', available = true) => device('climate', 'Climate',
[entity('climate.room', 'climate', state, attributes, available)], { primaryControlEntityId: 'climate.room' })
const curtainDevice = (attributes = { supported_features: 15 }, state = 'unavailable', available = false) => device('curtain', 'Curtain',
[entity('cover.room', 'cover', state, attributes, available)], { primaryControlEntityId: 'cover.room' })
test('climate reads real mode, fan and temperature capabilities instead of hardcoded hardware defaults', () => {
const { climateCapabilities } = environment().load('devices/climate/capabilities')
const item = climateDevice(), capability = climateCapabilities(item)
assert.equal(capability.available, true)
assert.deepEqual(Array.from(capability.modes), ['cool', 'heat', 'fan_only', 'off'])
assert.deepEqual(Array.from(capability.fans), ['自动', '低风', '中风', '高风'])
assert.equal(capability.range.min, 5); assert.equal(capability.range.max, 35); assert.equal(capability.range.step, .5)
assert.equal(capability.targetTemperature, 23); assert.equal(capability.currentTemperature, 24.1)
assert.equal(capability.fanMode, '低风'); assert.equal(capability.canSetTemperature, true)
assert.equal(capability.canSetFan, true); assert.equal(capability.supports(32), false)
const otherRange = climateCapabilities(climateDevice({ ...climateAttributes(), min_temp: 18, max_temp: 28, target_temp_step: 1 }))
assert.equal(otherRange.range.min, 18); assert.equal(otherRange.range.max, 28); assert.equal(otherRange.range.step, 1)
})
test('climate cannot fabricate missing target temperature, range, fan or mode capability', () => {
const { climateCapabilities } = environment().load('devices/climate/capabilities')
for (const bad of [undefined, null, 'NaN', '23garbage', 40]) {
const capability = climateCapabilities(climateDevice({ ...climateAttributes(), temperature: bad }))
assert.equal(capability.targetTemperature, undefined); assert.equal(capability.canSetTemperature, false)
}
for (const attribute of ['min_temp', 'max_temp', 'target_temp_step']) {
const attributes = climateAttributes(); delete attributes[attribute]
assert.equal(climateCapabilities(climateDevice(attributes)).range, undefined)
}
assert.equal(climateCapabilities(climateDevice({ ...climateAttributes(), target_temp_step: 0 })).range, undefined)
assert.equal(climateCapabilities(climateDevice({ ...climateAttributes(), supported_features: 1 })).canSetFan, false)
assert.equal(climateCapabilities(climateDevice({ ...climateAttributes(), fan_mode: 'invalid' })).fanMode, undefined)
assert.equal(climateCapabilities(climateDevice({ ...climateAttributes(), hvac_modes: ['off', null, '', 'invalid'] }, 'cool')).available, false)
const fahrenheit = climateCapabilities(climateDevice({ ...climateAttributes(), temperature_unit: '°F' }))
assert.equal(fahrenheit.temperatureUnit, '℉')
assert.equal(fahrenheit.canSetTemperature, false)
const unknownUnit = climateCapabilities(climateDevice({ ...climateAttributes(), temperature_unit: 'kelvin' }))
assert.equal(unknownUnit.targetTemperature, undefined); assert.equal(unknownUnit.currentTemperature, undefined)
assert.equal(unknownUnit.canSetTemperature, false)
const missingUnit = climateCapabilities(climateDevice({ ...climateAttributes(), temperature_unit: undefined }))
assert.equal(missingUnit.temperatureUnit, undefined); assert.equal(missingUnit.canSetTemperature, false)
assert.equal(missingUnit.currentTemperature, undefined); assert.equal(missingUnit.targetTemperature, undefined)
})
test('climate main control must be owned, available and bound; turn-on and turn-off use directional flags', () => {
const { climateCapabilities } = environment().load('devices/climate/capabilities')
const item = climateDevice()
assert.equal(climateCapabilities(item).canToggle, true)
item.entities[0].state = 'cool'; assert.equal(climateCapabilities(item).canToggle, true)
item.entities[0].attributes.supported_features = 257; assert.equal(climateCapabilities(item).canToggle, false)
item.entities[0].state = 'off'; assert.equal(climateCapabilities(item).canToggle, true)
item.entities[0].attributes.supported_features = 129; assert.equal(climateCapabilities(item).canToggle, false)
item.entities[0].attributes.supported_features = 393
item.entities[0].available = false; assert.equal(climateCapabilities(item).available, false)
item.entities.push(entity('sensor.online', 'sensor', '1')); assert.equal(climateCapabilities(item).canToggle, false)
item.entities[0].available = true; item.moduleKey = ''; assert.equal(climateCapabilities(item).available, false)
item.moduleKey = 'climate'; item.primaryControlEntityId = 'climate.foreign'
assert.equal(climateCapabilities(item).entity, undefined); assert.equal(climateCapabilities(item).canToggle, false)
})
test('curtain has dedicated feature-gated actions and never invents position for an unavailable main entity', () => {
const { curtainCapabilities } = environment().load('devices/curtain/capabilities')
const item = curtainDevice(), unavailable = curtainCapabilities(item)
assert.equal(unavailable.supports(4), true); assert.equal(unavailable.position, undefined)
for (const action of ['open', 'stop', 'close']) assert.equal(unavailable.canAction(action), false)
item.entities[0].available = true; item.entities[0].state = 'open'
const ready = curtainCapabilities(item)
for (const action of ['open', 'stop', 'close']) assert.equal(ready.canAction(action), true)
assert.equal(ready.canSetPosition, false)
item.entities[0].attributes.current_position = 0
assert.equal(curtainCapabilities(item).position, 0); assert.equal(curtainCapabilities(item).canSetPosition, true)
item.entities[0].attributes.supported_features = 1
const limited = curtainCapabilities(item)
assert.equal(limited.canAction('open'), true); assert.equal(limited.canAction('close'), false)
assert.equal(limited.canAction('stop'), false); assert.equal(limited.canSetPosition, false)
})
test('curtain rejects malformed position, unknown state, foreign entity and same-category unbound device', () => {
const { curtainCapabilities } = environment().load('devices/curtain/capabilities')
for (const current_position of [undefined, null, '', 'unknown', '50garbage', -1, 101])
assert.equal(curtainCapabilities(curtainDevice({ supported_features: 15, current_position }, 'open', true)).position, undefined)
const item = curtainDevice({ supported_features: 15, current_position: 50 }, 'unknown', true)
assert.equal(curtainCapabilities(item).available, false)
item.entities[0].state = 'open'; item.moduleKey = ''; assert.equal(curtainCapabilities(item).available, false)
item.moduleKey = 'curtain'; item.primaryControlEntityId = 'cover.foreign'
assert.equal(curtainCapabilities(item).entity, undefined)
})
test('new parameter synchronization preserves real readings and missing attributes are not readable', () => {
const env = environment(), { createDeviceParameterState, syncDeviceParameters } = env.load('devices/parameterRegistry')
const { parameterHasReading, parameterIsAvailable } = env.load('devices/parameterState')
const state = createDeviceParameterState()
assert.equal(state.climateTemperature, undefined); assert.equal(state.curtainPosition, undefined)
const controls = { climate_temperature: { ...control('off', climateAttributes()), entityId: 'climate.room' },
climate_mode: { ...control('cool'), entityId: 'climate.room' },
climate_fan_mode: { ...control('cool', { fan_mode: '低风' }), entityId: 'climate.room' },
curtain_position: { ...control('closed', { current_position: 0 }), entityId: 'cover.room' } }
syncDeviceParameters(state, controls)
assert.equal(state.climateTemperature, 23); assert.equal(state.climateMode, 'cool')
assert.equal(state.climateFanMode, '低风'); assert.equal(state.curtainPosition, 0)
assert.equal(parameterHasReading(controls, 'climate_temperature', climateDevice()), true)
assert.equal(parameterHasReading(controls, 'curtain_position', curtainDevice({}, 'closed', true)), true)
delete controls.climate_temperature.attributes.temperature
assert.equal(parameterHasReading(controls, 'climate_temperature', climateDevice()), false)
controls.climate_fan_mode.attributes = {}
assert.equal(parameterHasReading(controls, 'climate_fan_mode', climateDevice()), false)
controls.curtain_position.entityId = 'cover.foreign'
assert.equal(parameterIsAvailable(controls, 'curtain_position', curtainDevice({}, 'open', true)), false)
syncDeviceParameters(state, { climate_temperature: control('off', { temperature: 'garbage' }), curtain_position: control('closed', { current_position: -1 }) })
assert.equal(state.climateTemperature, 23); assert.equal(state.curtainPosition, 0)
})
test('new overview summaries use the main entity and curtain never exposes a generic power toggle', () => {
const { deviceSummary } = environment().load('devices/deviceSummary')
const climate = climateDevice()
assert.equal(deviceSummary(climate).statusText, '关闭'); assert.equal(deviceSummary(climate).metricText, '室温 24.1℃ · 目标 23℃')
assert.equal(deviceSummary(climate).canToggle, true)
climate.entities[0].available = false
assert.equal(deviceSummary(climate).statusText, '空调主控不可用'); assert.equal(deviceSummary(climate).canToggle, false)
const curtain = curtainDevice({ supported_features: 15, current_position: 55 }, 'opening', true)
assert.equal(deviceSummary(curtain).statusText, '正在打开'); assert.equal(deviceSummary(curtain).metricText, '开度 55%')
assert.equal(deviceSummary(curtain).canToggle, false)
delete curtain.entities[0].attributes.current_position
assert.equal(deviceSummary(curtain).metricText, '打开 · 停止 · 关闭')
curtain.entities[0].available = false
assert.equal(deviceSummary(curtain).statusText, '窗帘主控不可用')
})
test('climate generic switch accepts only bound directional capability and sends exact trusted entity', async () => {
const writes = [], env = environment({ switchDevice: async payload => { writes.push(payload); return { success: true, deviceId: payload.deviceId, entityId: payload.entityId, state: 'cool', message: '已发送' } } })
const hook = env.load('composables/useDeviceControl').useDeviceControl(); await hook.loadStatus()
const item = climateDevice()
assert.equal(hook.isDeviceControlAvailable(item), true)
await hook.toggleDevice(item)
assert.equal(writes.length, 1); assert.equal(writes[0].entityId, 'climate.room'); assert.equal(writes[0].turnOn, true)
item.entities[0].attributes.supported_features = 129
await hook.toggleDevice(item); assert.equal(writes.length, 1)
item.entities[0].attributes.supported_features = 393; item.moduleKey = ''
await hook.toggleDevice(item); assert.equal(writes.length, 1)
await hook.toggleDevice(curtainDevice({ supported_features: 15, current_position: 0 }, 'closed', true))
assert.equal(writes.length, 1)
env.dispose()
})
+308
View File
@@ -0,0 +1,308 @@
// 渲染真实 Vue 页面,注入假读数;不启动 HTTP 服务、不请求后端、不操作真实家具。
import assert from 'node:assert/strict'
import { after, test } from 'node:test'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { createServer } from 'vite'
import vue from '@vitejs/plugin-vue'
import { createSSRApp, h, ref } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { createRouter, createMemoryHistory, RouterView } from 'vue-router'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const server = await createServer({ configFile: false, root, plugins: [vue()], appType: 'custom',
server: { middlewareMode: true, hmr: false }, optimizeDeps: { noDiscovery: true, include: [] } })
after(() => server.close())
const { default: Overview } = await server.ssrLoadModule('/src/views/DeviceControlView.vue')
const { default: Detail } = await server.ssrLoadModule('/src/views/DeviceDetailView.vue')
const { deviceControlKey } = await server.ssrLoadModule('/src/devices/controlContext.ts')
const { createDeviceParameterState, syncDeviceParameters } = await server.ssrLoadModule('/src/devices/parameterRegistry.ts')
const mattressParams = await server.ssrLoadModule('/src/devices/mattress/parameters.ts')
const { deviceDetailLocation } = await server.ssrLoadModule('/src/devices/deviceKinds.ts')
const { parameterIsAvailable, parameterHasReading } = await server.ssrLoadModule('/src/devices/parameterState.ts')
const { primaryDeviceEntity, reportedPower } = await server.ssrLoadModule('/src/devices/deviceSummary.ts')
const { climateCapabilities } = await server.ssrLoadModule('/src/devices/climate/capabilities.ts')
const reading = (entityId, domain, state, attributes = {}) => ({ entityId, domain, name: entityId, state, attributes, available: true })
const device = (deviceId, name, moduleKey, category, entities = [], extra = {}) => ({
deviceId, name, moduleKey, category, entities, online: true, manufacturer: '测试品牌', model: '测试型号', areaName: '睡眠空间', ...extra,
})
const devices = [
device('lamp', '演示卧室灯', 'lighting', 'Lighting', [reading('light.room', 'light', 'on', { brightness: 128 })], { primaryControlEntityId: 'light.room' }),
device('mattress', '演示智能床垫', 'mattress', 'Sleep'),
device('fragrance', '演示香氛机', 'fragrance', 'Fragrance', [reading('switch.fragrance', 'switch', 'off')], { primaryControlEntityId: 'switch.fragrance' }),
device('speaker', '演示音箱', 'audio', 'Media', [reading('media_player.room', 'media_player', 'idle', { volume_level: .7 })], { primaryControlEntityId: 'media_player.room' }),
]
const unexpectedCommand = () => { throw new Error('页面检查不允许发出家具命令') }
async function render(location = '/device-control', overrides = {}) {
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/device-control', component: { render: () => h(RouterView) }, children: [
{ path: '', name: 'device-overview', component: Overview },
{ path: ':deviceId', name: 'device-detail', component: Detail },
] },
] })
const context = {
router, loading: ref(false), operationBusy: ref(false), tcmResult: ref({ hasCompleted: true, constitutionName: '平和质' }),
haStatus: ref({ connected: true, message: '', devices, summary: { totalDevices: 4, onlineDevices: 4 },
environment: { temperature: 25.6, humidity: 58.8 }, controlParameters: {} }),
musicTransition: ref({ running: true, hasVolume: true, volumePercent: 46 }), lightTransition: ref(),
sceneRunning: ref(), switchingDeviceId: ref(), settingParam: ref(), actionFeedback: ref(), currentOperationText: ref(''), lastUpdatedAt: ref(''),
localParams: createDeviceParameterState(), mattressComfort: ref(), mattressActiveSide: ref('left'),
...mattressParams,
isDeviceTurnedOn: value => Boolean(reportedPower(primaryDeviceEntity(value))),
isDeviceControlAvailable: value => primaryDeviceEntity(value)?.domain === 'climate' ? climateCapabilities(value).canToggle
: Boolean(value?.online && reportedPower(primaryDeviceEntity(value)) !== null &&
['switch', 'humidifier', 'light', 'media_player'].includes(primaryDeviceEntity(value)?.domain)),
isParameterAvailable: (parameter, value) => parameterIsAvailable(context.haStatus.value?.controlParameters, parameter, value),
hasParameterReading: (parameter, value) => parameterHasReading(context.haStatus.value?.controlParameters, parameter, value),
toggleDevice: unexpectedCommand, controlSpeaker: unexpectedCommand, applyParam: unexpectedCommand,
executeScene: unexpectedCommand, loadStatus: unexpectedCommand, controlLightTransition: unexpectedCommand,
setMattressMode: unexpectedCommand, setMattressSelect: unexpectedCommand, ...overrides,
}
await router.push(location); await router.isReady()
const app = createSSRApp({ render: () => h(RouterView) })
app.use(router); app.provide(deviceControlKey, context)
return { html: await renderToString(app), router }
}
const climateAttributes = () => ({ hvac_modes: ['cool', 'heat', 'fan_only', 'off'], min_temp: 5, max_temp: 35, target_temp_step: .5,
temperature_unit: '°C',
fan_modes: ['自动', '低风', '中风', '高风'], current_temperature: 24.1, temperature: 23, fan_mode: '低风', supported_features: 393 })
const newDeviceSnapshot = item => {
const entity = item.entities.find(value => value.entityId === item.primaryControlEntityId)
const keys = item.moduleKey === 'climate' ? ['climate_mode', 'climate_temperature', 'climate_fan_mode'] : ['curtain_action', 'curtain_position']
const controlParameters = Object.fromEntries(keys.map(key => [key, { entityId: entity.entityId, state: entity.state, available: entity.available, attributes: entity.attributes }]))
return { connected: true, devices: [item], summary: { totalDevices: 1, onlineDevices: item.online ? 1 : 0 }, controlParameters }
}
const renderNewDevice = async (item, change = {}) => {
const snapshot = { ...newDeviceSnapshot(item), ...change }, localParams = createDeviceParameterState()
syncDeviceParameters(localParams, snapshot.controlParameters)
return render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot), localParams })
}
test('climate detail exposes only real supported modes, range and fan options', async () => {
const item = device('climate-demo', '演示空调', 'climate', 'Climate', [reading('climate.room', 'climate', 'off', climateAttributes())], { primaryControlEntityId: 'climate.room' })
const { html } = await renderNewDevice(item)
for (const text of ['室温调节', '当前室温', '24.1℃', '23℃', '空调运行模式', '制冷', '制热', '送风', '空调目标温度', '每档 0.5℃', '5℃', '35℃', '低风', '高风'])
assert.ok(html.includes(text), text)
for (const text of ['摆风', '校准', '恢复出厂', '演示智能床垫']) assert.ok(!html.includes(text), text)
assert.ok(html.includes('van-slider'))
const modeButtons = html.match(/<button[^>]*aria-pressed[^>]*>[\s\S]*?<\/button>/g)
assert.equal(modeButtons.length, 8)
assert.equal(modeButtons.filter(value => value.includes('disabled')).length, 0)
})
test('climate missing target or range never renders a default temperature slider', async () => {
const attributes = climateAttributes(); delete attributes.temperature
const item = device('climate-missing', '温度缺失空调', 'climate', 'Climate', [reading('climate.room', 'climate', 'cool', attributes)], { primaryControlEntityId: 'climate.room' })
let html = (await renderNewDevice(item)).html
assert.ok(html.includes('不显示默认温度')); assert.ok(!html.includes('van-slider')); assert.ok(!html.includes('0℃'))
attributes.temperature = 23; delete attributes.min_temp
html = (await renderNewDevice(item)).html
assert.ok(!html.includes('van-slider')); assert.ok(!html.includes('23℃'))
attributes.min_temp = 5; attributes.temperature_unit = '°F'
html = (await renderNewDevice(item)).html
assert.ok(html.includes('23℉')); assert.ok(html.includes('24.1℉')); assert.ok(!html.includes('23℃'))
assert.ok(html.includes('van-slider--disabled')); assert.ok(html.includes('此版仅支持摄氏温度调节'))
delete attributes.temperature_unit
html = (await renderNewDevice(item)).html
assert.ok(!html.includes('van-slider')); assert.ok(!html.includes('23℃')); assert.ok(!html.includes('24.1℃'))
})
test('climate offline main entity does not borrow auxiliary availability or another device parameter', async () => {
const item = device('climate-offline', '主控离线空调', 'climate', 'Climate', [
{ ...reading('climate.room', 'climate', 'unavailable', climateAttributes()), available: false }, reading('sensor.alive', 'sensor', '1'),
], { primaryControlEntityId: 'climate.room' })
const { html } = await renderNewDevice(item)
assert.ok(html.includes('空调当前不可用')); assert.ok(!html.includes('24.1℃')); assert.ok(!html.includes('23℃'))
assert.ok(!html.includes('van-slider'))
const buttons = html.match(/<button[^>]*aria-pressed[^>]*>[\s\S]*?<\/button>/g)
assert.ok(buttons.every(value => value.includes('disabled')))
const owned = device('climate-owned', '未拥有参数空调', 'climate', 'Climate', [reading('climate.room', 'climate', 'off', climateAttributes())], { primaryControlEntityId: 'climate.room' })
const snapshot = newDeviceSnapshot(owned)
for (const control of Object.values(snapshot.controlParameters)) control.entityId = 'climate.foreign'
const foreignHtml = (await renderNewDevice(owned, snapshot)).html
assert.ok(!foreignHtml.includes('van-slider')); assert.ok(!foreignHtml.includes('23℃'))
assert.ok(foreignHtml.match(/<button[^>]*aria-pressed[^>]*>[\s\S]*?<\/button>/g).every(value => value.includes('disabled')))
})
test('curtain unavailable detail keeps three disabled actions without inventing a position or generic power switch', async () => {
const item = device('curtain-offline', '演示窗帘', 'curtain', 'Curtain', [
{ ...reading('cover.room', 'cover', 'unavailable', { supported_features: 15 }), available: false },
], { primaryControlEntityId: 'cover.room', online: false })
const { html } = await renderNewDevice(item)
for (const text of ['窗帘开合', '打开', '停止', '关闭', '窗帘开度', '开度暂未回读', '窗帘当前不可用']) assert.ok(html.includes(text), text)
assert.ok(!html.includes('van-slider')); assert.ok(!html.includes('0% 完全关闭'))
assert.ok(!html.includes('mini-toggle')); assert.ok(!/<span class="status-dot[^>]*>已关闭<\/span>/.test(html))
const group = html.match(/<div class="btn-group device-mode-buttons curtain-actions">[\s\S]*?<\/div>/)[0]
assert.equal((group.match(/<button/g) ?? []).length, 3)
assert.equal((group.match(/disabled/g) ?? []).length, 3)
})
test('curtain online missing position permits feature-supported actions but no fake slider; zero is a valid reading', async () => {
const item = device('curtain-ready', '可用窗帘', 'curtain', 'Curtain', [reading('cover.room', 'cover', 'open', { supported_features: 15 })], { primaryControlEntityId: 'cover.room' })
let html = (await renderNewDevice(item)).html
assert.ok(html.includes('已打开')); assert.ok(!html.includes('van-slider'))
let group = html.match(/<div class="btn-group device-mode-buttons curtain-actions">[\s\S]*?<\/div>/)[0]
assert.ok(!group.includes('disabled'))
item.entities[0].attributes.current_position = 0; item.entities[0].state = 'closed'
html = (await renderNewDevice(item)).html
assert.ok(html.includes('已关闭')); assert.ok(html.includes('0%')); assert.ok(html.includes('van-slider'))
item.entities[0].attributes.supported_features = 1
html = (await renderNewDevice(item)).html
assert.ok(!html.includes('窗帘开度')); assert.ok(!html.includes('van-slider'))
group = html.match(/<div class="btn-group device-mode-buttons curtain-actions">[\s\S]*?<\/div>/)[0]
assert.equal((group.match(/disabled/g) ?? []).length, 2)
})
test('unbound devices in new categories remain generic and cannot borrow bound controls', async () => {
for (const category of ['Climate', 'Curtain']) {
const domain = category === 'Climate' ? 'climate' : 'cover'
const item = device(`unbound-${domain}`, `未绑定${category}`, '', category, [reading(`${domain}.room`, domain, 'off', climateAttributes())], { primaryControlEntityId: `${domain}.room` })
const { html } = await renderNewDevice(item)
assert.ok(html.includes('设备能力'))
for (const text of ['空调运行模式', '空调目标温度', '窗帘开合', '窗帘开度']) assert.ok(!html.includes(text), text)
}
})
test('overview has compact tiles and room tabs without detailed sliders or mattress controls', async () => {
const { html } = await render()
assert.equal((html.match(/<article class="device-overview-tile/g) ?? []).length, 4)
for (const value of ['一键启动全屋舒睡模式', '一键关闭设备', '全屋', '睡眠空间', '音量 46%', '演示智能床垫']) assert.ok(html.includes(value), value)
for (const value of ['左侧加热档位', '按摩力度', '灯光色温调节', 'van-slider']) assert.ok(!html.includes(value), value)
// 快捷开关与进入详情按钮是同级元素,不会发生嵌套按钮或点击开关误进详情。
for (const tile of html.match(/<article class="device-overview-tile[\s\S]*?<\/article>/g) ?? []) {
const mainButton = tile.match(/<button class="device-tile-open"[\s\S]*?<\/button>/)?.[0]
assert.ok(mainButton)
assert.ok(!mainButton.includes('device-tile-power'))
}
})
const outlet = () => device('outlet', '红灯插座', 'outlet', 'Outlet', [
reading('switch.service', 'switch', 'on'), reading('switch.power', 'switch', 'off'),
{ ...reading('number.protection', 'number', '8472528'), name: '充电保护、过载保护' },
{ ...reading('sensor.energy', 'sensor', '0', { device_class: 'energy' }), unit: 'kWh' },
], { primaryControlEntityId: 'switch.power' })
const outletSnapshot = item => ({ connected: true, devices: [item],
summary: { totalDevices: 1, onlineDevices: item.online ? 1 : 0 }, controlParameters: {} })
test('outlet detail renders its own on/off control and real energy without service or packed engineering controls', async () => {
const item = outlet(), snapshot = outletSnapshot(item)
let html = (await render('/device-control/outlet', { haStatus: ref(snapshot) })).html
for (const text of ['智能插座', '插座可用', '开启电源', '插座电源状态', '已断电', '累计用电', '0 kWh', '暂不参与一键舒睡']) assert.ok(html.includes(text), text)
for (const text of ['8472528', '充电保护、过载保护', 'switch.service', '查看全部能力', '设备能力']) assert.ok(!html.includes(text), text)
assert.ok(!html.match(/<button[^>]*disabled[^>]*>[\s\S]*?开启电源/))
item.entities[1].state = 'on'
html = (await render('/device-control/outlet', { haStatus: ref(snapshot) })).html
assert.ok(html.includes('关闭电源')); assert.ok(html.includes('已通电'))
})
test('outlet detail does not report power off when main state is missing despite online service switch', async () => {
const item = outlet(); item.entities[1].state = 'unknown'
const { html } = await render('/device-control/outlet', { haStatus: ref(outletSnapshot(item)) })
assert.ok(html.includes('插座主开关当前不可用')); assert.ok(html.includes('暂不可用'))
assert.ok(html.includes('<strong class="p-val">--</strong>')); assert.ok(!html.includes('已断电'))
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)[0]
assert.ok(card.match(/<button[^>]*disabled/)); assert.ok(!card.includes('开启电源'))
})
test('outlet overview has a dedicated compact tile and trusted quick power switch', async () => {
const { html } = await render('/device-control', { haStatus: ref(outletSnapshot(outlet())) })
for (const text of ['智能插座', '红灯插座', '已断电', '累计用电 0kWh', 'device-tile-power']) assert.ok(html.includes(text), text)
for (const text of ['8472528', '充电保护、过载保护', '插座电源状态']) assert.ok(!html.includes(text), text)
})
test('mattress detail retains full controls and never renders other devices or scene-wide actions', async () => {
const { html } = await render('/device-control/mattress')
assert.equal((html.match(/<section class="device-card"/g) ?? []).length, 1)
for (const value of ['演示智能床垫', '全部设备', '左侧加热档位', '右侧加热档位', '左侧承托模式', '右侧承托模式', '左肩软硬度', '右腿软硬度', '左侧按摩部位', '右侧按摩部位', '按摩力度']) assert.ok(html.includes(value), value)
for (const value of ['演示卧室灯', '演示香氛机', '一键启动全屋舒睡模式', 'device-overview-tile']) assert.ok(!html.includes(value), value)
})
test('fragrance detail retains separate nightlight and unavailable quick-release feedback', async () => {
const { html } = await render('/device-control/fragrance')
for (const value of ['单次出香时长', '自动出香间隔', '感应夜灯', '已关闭', '暂不可用']) assert.ok(html.includes(value), value)
assert.ok(!html.includes('演示智能床垫'))
})
test('each registered device detail renders its own parameters without requesting the network', async () => {
const cases = [
[devices[0], ['灯光亮度', '灯光色温调节']],
[devices[3], ['音箱音量', '音箱睡眠模式']],
[device('dehumidifier', '演示除湿机', 'dehumidifier', 'EnvironmentControl', [reading('humidifier.room', 'humidifier', 'off')],
{ primaryControlEntityId: 'humidifier.room' }), ['恒湿目标湿度', '除湿运行模式', '除湿机风速档位', 'UV 深度紫外消杀']],
[device('environment', '演示环境检测仪', 'environment', 'EnvironmentSensor'), ['温度', '湿度', 'PM2.5', 'CO2 浓度', '甲醛 HCHO', 'TVOC']],
[device('scale', '演示体脂秤', '', 'BodyComposition'), ['健康身体成分', '健康档案']],
[device('generic', '未接入模块的灯', '', 'Lighting', [reading('sensor.example', 'sensor', '12')]), ['设备能力', 'sensor.example', '12']],
]
for (const [item, labels] of cases) {
const snapshot = { connected: true, devices: [item], summary: { totalDevices: 1, onlineDevices: 1 },
environment: { temperature: 25.6, humidity: 58.8 }, controlParameters: {} }
const { html } = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot) })
assert.equal((html.match(/<section class="device-card"/g) ?? []).length, 1, item.name)
for (const label of labels) assert.ok(html.includes(label), `${item.name}: ${label}`)
if (item.deviceId === 'generic') assert.ok(!html.includes('灯光色温调节'))
}
})
test('missing, offline and disconnected devices display honest explanatory states', async () => {
assert.ok((await render('/device-control/removed')).html.includes('未找到这个设备'))
const offline = { ...devices[1], online: false }
const snapshot = { connected: true, devices: [offline], summary: { totalDevices: 1, onlineDevices: 0 } }
const { html } = await render('/device-control/mattress', { haStatus: ref(snapshot) })
assert.ok(html.includes('设备回读显示离线'))
assert.ok(html.includes('右侧按摩部位'))
const disconnected = await render('/device-control/mattress', { haStatus: ref({ ...snapshot, connected: false, message: '连接失败' }) })
assert.ok(disconnected.html.includes('暂时无法读取设备'))
assert.ok(!disconnected.html.includes('未找到这个设备'))
const loading = await render('/device-control/mattress', { haStatus: ref(), loading: ref(true) })
assert.ok(loading.html.includes('正在读取设备详情'))
})
test('detail route safely round-trips unusual device IDs; unfinished constitution keeps scene start disabled', async () => {
const { router, html } = await render('/device-control', { tcmResult: ref({ hasCompleted: false }) })
const deviceId = '房间/灯 #1?'
const resolved = router.resolve(deviceDetailLocation(deviceId))
assert.equal(router.resolve(resolved.fullPath).params.deviceId, deviceId)
assert.match(html, /<button class="scene-btn sleep-btn" disabled/)
assert.ok(html.includes('去完成中医四诊'))
})
test('offline parameter details disable controls and do not present default values as current readings', async () => {
for (const item of [devices[0], devices[1], devices[2]]) {
const snapshot = { connected: true, devices: [{ ...item, online: false }], controlParameters: {} }
const { html } = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot) })
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)?.[0]
assert.ok(card)
for (const button of card.match(/<button\b[^>]*>/g) ?? []) assert.ok(button.includes('disabled'), button)
for (const select of card.match(/<select\b[^>]*>/g) ?? []) assert.ok(select.includes('disabled'), select)
for (const slider of card.match(/class="van-slider(?: |\")[^"\n]*"/g) ?? []) assert.ok(slider.includes('disabled'), slider)
assert.ok(!card.includes('<strong class="p-val">2700K</strong>'))
assert.ok(!card.includes('<strong class="p-val">20 分钟</strong>'))
assert.ok(!card.includes('<strong class="p-val">0 档</strong>'))
}
})
test('environment detail uses only that device metrics and hides offline or malformed readings', async () => {
const item = device('environment', '自己的环境检测仪', 'environment', 'EnvironmentSensor', [],
{ metrics: { temperature: '26.2', humidity: 'bad', co2: '900' } })
const snapshot = { connected: true, devices: [item], environment: { temperature: 99, humidity: 99 }, controlParameters: {} }
const { html } = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot) })
assert.ok(html.includes('26.2')); assert.ok(html.includes('900')); assert.ok(!html.includes('<strong>99'))
item.online = false
const offline = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot) })
assert.ok(!offline.html.includes('26.2')); assert.ok(!offline.html.includes('900'))
})
test('independent audio gateway still supports HA-offline playback while HA-only sleep mode is disabled', async () => {
const item = { ...devices[3], online: false, entities: devices[3].entities.map(value => ({ ...value, available: false })) }
const snapshot = { connected: true, devices: [item], controlParameters: {} }
const { html } = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot),
musicTransition: ref({ running: true, hasVolume: true, volumePercent: 0 }) })
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)?.[0]
assert.ok(card.includes('0% 静音'))
const playback = card.match(/<button\b[^>]*>[\s\S]*?播放测试音乐[\s\S]*?<\/button>/)?.[0]
assert.ok(playback); assert.ok(!playback.match(/^<button\b[^>]*disabled/))
assert.match(card, /<button class="mini-toggle" disabled/)
})
+140
View File
@@ -0,0 +1,140 @@
// Isolated navigation/authentication checks: no server or real device requests.
import assert from 'node:assert/strict'
import { test } from 'node:test'
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src')
function environment(pathname = '/dashboard') {
const storage = new Map(), redirects = [], cache = new Map()
const handlers = {}
const http = {
interceptors: {
request: { use: callback => { handlers.request = callback } },
response: { use: (success, failure) => { handlers.success = success; handlers.failure = failure } },
},
}
const window = { location: { pathname, replace: url => redirects.push(url) } }
const mocks = {
axios: { default: { create: () => http } },
'vue-router': {
createWebHistory: () => ({}),
createRouter: options => ({
options,
beforeEach: callback => { handlers.guard = callback },
afterEach: callback => { handlers.after = callback },
}),
},
}
function load(relative) {
const file = path.resolve(root, relative)
if (cache.has(file)) return cache.get(file).exports
const module = { exports: {} }; cache.set(file, module)
const source = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
}).outputText
vm.runInNewContext(source, {
exports: module.exports, module, window,
localStorage: {
getItem: key => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, value),
removeItem: key => storage.delete(key),
},
require: name => mocks[name] ?? (name.endsWith('.vue') ? { default: {} }
: load(path.relative(root, path.resolve(path.dirname(file), `${name}.ts`)))),
}, { filename: file })
return module.exports
}
const auth = load('services/auth.ts')
load('services/http.ts')
return { auth, handlers, redirects, storage, load }
}
function request(env) {
return env.handlers.request({ headers: {} })
}
test('a current HTTP 401 clears both tokens and redirects only once', async () => {
const env = environment()
env.auth.saveTokens('session-one', 'refresh-one')
const config = request(env)
const fail = () => env.handlers.failure({ config, response: { status: 401 }, message: 'Unauthorized' })
await assert.rejects(fail(), /Unauthorized/)
await assert.rejects(fail(), /Unauthorized/)
assert.equal(env.auth.hasToken(), false)
assert.equal(env.storage.size, 0)
assert.deepEqual(env.redirects, ['/auth'])
})
test('an HTTP 200 with an unauthorized envelope also expires the current session', async () => {
const env = environment()
env.auth.saveTokens('session-one', 'refresh-one')
const config = request(env)
await assert.rejects(env.handlers.success({ config, data: { code: 401, type: 'error', result: null } }), /登录已过期/)
assert.equal(env.auth.hasToken(), false)
assert.deepEqual(env.redirects, ['/auth'])
})
test('a late HTTP/envelope 401 from the previous login cannot remove a newer session', async () => {
const env = environment()
env.auth.saveTokens('old-session', 'old-refresh')
const oldConfig = request(env)
env.auth.saveTokens('new-session', 'new-refresh')
await assert.rejects(env.handlers.failure({ config: oldConfig, response: { status: 401 }, message: 'Expired' }), /Expired/)
await assert.rejects(env.handlers.success({ config: oldConfig, data: { code: 401, message: 'Expired' } }), /Expired/)
assert.equal(env.auth.getToken(), 'new-session')
assert.equal(env.storage.size, 2)
assert.deepEqual(env.redirects, [])
})
test('an anonymous request that returns 401 cannot log out a later authenticated session', async () => {
const env = environment('/auth')
const anonymousConfig = request(env)
assert.equal(anonymousConfig.headers.Authorization, undefined)
env.auth.saveTokens('new-session', 'new-refresh')
await assert.rejects(env.handlers.failure({ config: anonymousConfig, response: { status: 401 }, message: 'Invalid password' }))
assert.equal(env.auth.getToken(), 'new-session')
assert.deepEqual(env.redirects, [])
})
test('failed credentials on the login page do not reload that page', async () => {
const env = environment('/auth')
await assert.rejects(env.handlers.failure({ config: request(env), response: { status: 401 }, message: 'Invalid password' }))
assert.deepEqual(env.redirects, [])
})
test('normal API results still unwrap and business failures are still rejected', async () => {
const env = environment()
env.auth.saveTokens('session-one', 'refresh-one')
assert.equal(request(env).headers.Authorization, 'Bearer session-one')
const result = { success: false, message: 'device unavailable' }
assert.equal(env.handlers.success({ data: { code: 200, type: 'success', result } }), result)
await assert.rejects(env.handlers.success({ data: { type: 'error', result: null, message: 'failed' } }), /failed/)
assert.equal(env.auth.hasToken(), true)
})
test('route guards protect pages while allowing login, with a fallback for unknown URLs', () => {
const env = environment(), router = env.load('router.ts').default
assert.equal(env.handlers.guard({ meta: {} }), '/auth')
assert.equal(env.handlers.guard({ meta: { guest: true } }), undefined)
env.auth.saveTokens('session-one', 'refresh-one')
assert.equal(env.handlers.guard({ meta: {} }), undefined)
assert.equal(env.handlers.guard({ meta: { guest: true } }), '/dashboard')
assert.equal(router.options.routes.at(-1).path, '/:pathMatch(.*)*')
assert.equal(router.options.routes.at(-1).redirect, '/dashboard')
const devices = router.options.routes.find(route => route.path === '/device-control')
assert.equal(devices.children.find(route => route.name === 'device-detail').path, ':deviceId')
})
test('new pages open at the top and browser back preserves its saved scroll position', () => {
const env = environment(), router = env.load('router.ts').default
const saved = { left: 0, top: 512 }
assert.equal(router.options.scrollBehavior({}, {}, saved), saved)
const fresh = router.options.scrollBehavior({}, {}, null)
assert.equal(fresh.left, 0)
assert.equal(fresh.top, 0)
})
+16 -9
View File
@@ -27,10 +27,11 @@ function navigate(target: string) {
</script>
<template>
<nav class="app-tab-bar">
<div
<nav class="app-tab-bar" aria-label="主导航">
<button type="button"
class="tab-item"
:class="{ active: currentTab === 'home' }"
:aria-current="currentTab === 'home' ? 'page' : undefined"
@click="navigate('home')"
>
<div class="tab-icon">
@@ -40,11 +41,12 @@ function navigate(target: string) {
</svg>
</div>
<span class="tab-label">首页</span>
</div>
</button>
<div
<button type="button"
class="tab-item"
:class="{ active: currentTab === 'tcm' }"
:aria-current="currentTab === 'tcm' ? 'page' : undefined"
@click="navigate('tcm')"
>
<div class="tab-icon">
@@ -56,11 +58,12 @@ function navigate(target: string) {
</svg>
</div>
<span class="tab-label">中医四诊</span>
</div>
</button>
<div
<button type="button"
class="tab-item"
:class="{ active: currentTab === 'assessment' }"
:aria-current="currentTab === 'assessment' ? 'page' : undefined"
@click="navigate('assessment')"
>
<div class="tab-icon">
@@ -73,11 +76,12 @@ function navigate(target: string) {
</svg>
</div>
<span class="tab-label">健康评估</span>
</div>
</button>
<div
<button type="button"
class="tab-item"
:class="{ active: currentTab === 'profile' }"
:aria-current="currentTab === 'profile' ? 'page' : undefined"
@click="navigate('profile')"
>
<div class="tab-icon">
@@ -87,7 +91,7 @@ function navigate(target: string) {
</svg>
</div>
<span class="tab-label">我的</span>
</div>
</button>
</nav>
</template>
@@ -116,6 +120,9 @@ function navigate(target: string) {
}
.tab-item {
border: 0;
background: transparent;
font: inherit;
display: flex;
flex-direction: column;
align-items: center;
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed, toRefs } from 'vue'
import { Button as VanButton, Slider as VanSlider } from 'vant'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
import { useDeviceCardState } from '../../devices/useDeviceCardState'
import { primaryDeviceEntity } from '../../devices/deviceSummary'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { device: speakerDevice } = toRefs(props)
const { localParams, operationBusy, switchingDeviceId, applyParam, controlSpeaker, musicTransition, loading } = useDeviceControlContext()
const state = useDeviceCardState(() => props.device)
// MiAirX 独立于 HA 实体在线状态,但仍需已确认属于该音箱的主控实体。
const gatewayAvailable = computed(() => primaryDeviceEntity(props.device)?.domain === 'media_player')
</script>
<template>
<section class="device-card" v-if="speakerDevice">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">睡眠音频</span>
<h3>{{ speakerDevice.name }}</h3>
<span class="status-dot" :class="{ online: speakerDevice.online }">
{{ speakerDevice.online ? 'HA 实体在线' : 'HA 实体离线' }}
</span>
</div>
</div>
<div class="speaker-actions">
<VanButton
size="small"
round
type="primary"
color="#0c5045"
:loading="switchingDeviceId === speakerDevice.deviceId"
:disabled="operationBusy || !gatewayAvailable"
@click="controlSpeaker(speakerDevice, true)"
>播放测试音乐</VanButton>
<VanButton
size="small"
round
:disabled="operationBusy || !gatewayAvailable"
@click="controlSpeaker(speakerDevice, false)"
>停止播放</VanButton>
</div>
<small class="p-tip">手动按钮播放测试音频;一键开启按体质选曲循环播放,音量自动渐弱至停止</small>
<div class="params-body">
<!-- 音量滑块 -->
<div class="param-row">
<div class="param-header">
<span class="p-title">音箱音量(MiAirX 已下发)</span>
<strong class="p-val">{{ musicTransition?.hasVolume ? `${localParams.speakerVolume}%` : state.display('volume', localParams.speakerVolume, '%') }}</strong>
</div>
<VanSlider
v-model="localParams.speakerVolume"
:min="0"
:max="100"
:disabled="operationBusy || !gatewayAvailable"
active-color="#0c5045"
@change="applyParam(speakerDevice.deviceId, 'volume', localParams.speakerVolume)"
/>
<div class="slider-scale">
<span>0% 静音</span>
<span>50% 适中聆听</span>
<span>100% 满格音量</span>
</div>
<small class="p-tip">{{ musicTransition?.running ? '自动渐弱中,每 5 秒更新一次显示' : '手动调整音量会停止本次自动渐弱' }}</small>
</div>
<div class="param-row inline-row">
<div>
<div class="p-title">音箱睡眠模式</div>
<small class="p-tip">睡眠模式由 Home Assistant 单独控制</small>
</div>
<button
class="mini-toggle"
:class="{ active: state.readable('speaker_sleep_mode') && localParams.speakerSleepMode }"
:disabled="operationBusy || !state.available('speaker_sleep_mode')"
@click="localParams.speakerSleepMode = !localParams.speakerSleepMode; applyParam(speakerDevice.deviceId, 'speaker_sleep_mode', localParams.speakerSleepMode)"
>
{{ state.display('speaker_sleep_mode', localParams.speakerSleepMode ? '已开启' : '已关闭') }}
</button>
</div>
</div>
</section>
</template>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Button as VanButton, Slider as VanSlider } from 'vant'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
import { useDeviceCardState } from '../../devices/useDeviceCardState'
import { climateCapabilities, climateModeLabels } from '../../devices/climate/capabilities'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { localParams, operationBusy, switchingDeviceId, isDeviceTurnedOn, toggleDevice, applyParam } = useDeviceControlContext()
const state = useDeviceCardState(() => props.device)
const capability = computed(() => climateCapabilities(props.device))
const temperatureValue = computed({
get: () => localParams.climateTemperature ?? capability.value.targetTemperature ?? 0,
set: (value: number) => { localParams.climateTemperature = value },
})
const temperatureReadable = computed(() => capability.value.available && state.readable('climate_temperature') && capability.value.targetTemperature !== undefined)
function setMode(mode: string) {
if (operationBusy.value || !capability.value.available || !state.available('climate_mode') || !capability.value.modes.includes(mode)) return
void applyParam(props.device.deviceId, 'climate_mode', mode)
}
function setTemperature() {
const range = capability.value.range, value = temperatureValue.value
if (operationBusy.value || !capability.value.canSetTemperature || !state.available('climate_temperature') || !range || !Number.isFinite(value) || value < range.min || value > range.max) return
const steps = (value - range.min) / range.step
if (Math.abs(steps - Math.round(steps)) > 0.000001) return
void applyParam(props.device.deviceId, 'climate_temperature', value)
}
function setFan(mode: string) {
if (operationBusy.value || !capability.value.canSetFan || !state.available('climate_fan_mode') || !capability.value.fans.includes(mode)) return
void applyParam(props.device.deviceId, 'climate_fan_mode', mode)
}
</script>
<template>
<section class="device-card">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">室温调节</span>
<h3>{{ device.name }}</h3>
<span class="status-dot" :class="{ online: capability.available }">{{ capability.available ? '空调可用' : '空调当前不可用' }}</span>
</div>
<VanButton size="small" round :type="isDeviceTurnedOn(device) ? 'primary' : 'default'"
:loading="switchingDeviceId === device.deviceId" :disabled="operationBusy || !capability.canToggle"
@click="toggleDevice(device)">
{{ capability.available ? isDeviceTurnedOn(device) ? '已开启' : '已关闭' : '暂不可用' }}
</VanButton>
</div>
<div class="params-body">
<div class="sensor-grid climate-readings">
<div class="sensor-box"><span class="s-label">当前室温</span><strong>{{ capability.available && capability.currentTemperature !== undefined ? `${capability.currentTemperature}${capability.temperatureUnit}` : '--' }}</strong></div>
<div class="sensor-box"><span class="s-label">目标温度</span><strong>{{ temperatureReadable ? `${capability.targetTemperature}${capability.temperatureUnit}` : '--' }}</strong></div>
</div>
<div class="param-row">
<div class="param-header"><span class="p-title">空调运行模式</span><strong class="p-val">{{ capability.available && state.readable('climate_mode') ? climateModeLabels[capability.entity!.state] : '--' }}</strong></div>
<div v-if="capability.modes.length" class="btn-group device-mode-buttons">
<button v-for="mode in capability.modes" :key="mode" type="button"
:class="{ active: capability.available && state.readable('climate_mode') && capability.entity?.state === mode }"
:aria-pressed="capability.available && state.readable('climate_mode') && capability.entity?.state === mode"
:disabled="operationBusy || !capability.available || !state.available('climate_mode')" @click="setMode(mode)">{{ climateModeLabels[mode] }}</button>
</div>
<p v-else class="scale-note">运行模式暂未回读,请刷新后再试。</p>
</div>
<div v-if="capability.supports(1)" class="param-row">
<div class="param-header"><span class="p-title">空调目标温度</span><strong class="p-val">{{ temperatureReadable ? `${capability.targetTemperature}${capability.temperatureUnit}` : '--' }}</strong></div>
<template v-if="capability.range && temperatureReadable">
<VanSlider v-model="temperatureValue" :min="capability.range.min" :max="capability.range.max" :step="capability.range.step"
:disabled="operationBusy || !capability.canSetTemperature || !state.available('climate_temperature')" active-color="#0c5045" @change="setTemperature" />
<div class="slider-scale"><span>{{ capability.range.min }}{{ capability.temperatureUnit }}</span><span>每档 {{ capability.range.step }}{{ capability.temperatureUnit }}</span><span>{{ capability.range.max }}{{ capability.temperatureUnit }}</span></div>
<p v-if="capability.temperatureUnit === '℉'" class="scale-note">当前回读为华氏温度,此版仅支持摄氏温度调节。</p>
</template>
<p v-else class="scale-note">目标温度、温度单位或调节范围暂未回读,不显示默认温度。</p>
</div>
<div v-if="capability.supports(8) && capability.fans.length" class="param-row">
<div class="param-header"><span class="p-title">空调风速</span><strong class="p-val">{{ capability.available && state.readable('climate_fan_mode') ? capability.fanMode || '--' : '--' }}</strong></div>
<div class="btn-group device-mode-buttons">
<button v-for="fan in capability.fans" :key="fan" type="button"
:class="{ active: capability.available && state.readable('climate_fan_mode') && capability.fanMode === fan }"
:aria-pressed="capability.available && state.readable('climate_fan_mode') && capability.fanMode === fan"
:disabled="operationBusy || !capability.canSetFan || !state.available('climate_fan_mode')" @click="setFan(fan)">{{ fan }}</button>
</div>
</div>
<p class="scale-note">仅显示设备实际支持的模式与范围;空调一键场景按室内温度建议执行。</p>
</div>
</section>
</template>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Slider as VanSlider } from 'vant'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
import { useDeviceCardState } from '../../devices/useDeviceCardState'
import { curtainCapabilities, curtainStateLabels, type CurtainAction } from '../../devices/curtain/capabilities'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { localParams, operationBusy, applyParam } = useDeviceControlContext()
const state = useDeviceCardState(() => props.device)
const capability = computed(() => curtainCapabilities(props.device))
const actions: { value: CurtainAction; label: string }[] = [{ value: 'open', label: '打开' }, { value: 'stop', label: '停止' }, { value: 'close', label: '关闭' }]
const positionReadable = computed(() => capability.value.available && state.readable('curtain_position') && capability.value.position !== undefined)
const positionValue = computed({ get: () => localParams.curtainPosition ?? capability.value.position ?? 0,
set: (value: number) => { localParams.curtainPosition = value } })
function setAction(action: CurtainAction) {
if (operationBusy.value || !capability.value.canAction(action) || !state.available('curtain_action')) return
void applyParam(props.device.deviceId, 'curtain_action', action)
}
function setPosition() {
const value = positionValue.value
if (operationBusy.value || !capability.value.canSetPosition || !state.available('curtain_position') || !Number.isInteger(value) || value < 0 || value > 100) return
void applyParam(props.device.deviceId, 'curtain_position', value)
}
</script>
<template>
<section class="device-card">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">窗帘控制</span><h3>{{ device.name }}</h3>
<span class="status-dot" :class="{ online: capability.available }">{{ capability.available ? curtainStateLabels[capability.entity!.state] : '窗帘当前不可用' }}</span>
</div>
</div>
<div class="params-body">
<div class="param-row">
<span class="p-title">窗帘开合</span>
<div class="btn-group device-mode-buttons curtain-actions">
<button v-for="action in actions" :key="action.value" type="button"
:disabled="operationBusy || !capability.canAction(action.value) || !state.available('curtain_action')"
@click="setAction(action.value)">{{ action.label }}</button>
</div>
</div>
<div v-if="capability.supports(4)" class="param-row">
<div class="param-header"><span class="p-title">窗帘开度</span><strong class="p-val">{{ positionReadable ? `${capability.position}%` : '--' }}</strong></div>
<template v-if="positionReadable">
<VanSlider v-model="positionValue" :min="0" :max="100" :step="1"
:disabled="operationBusy || !capability.canSetPosition || !state.available('curtain_position')" active-color="#0c5045" @change="setPosition" />
<div class="slider-scale"><span>0% 完全关闭</span><span>100% 完全打开</span></div>
</template>
<p v-else class="scale-note">开度暂未回读,不显示默认位置。设备恢复可用后可使用打开、停止、关闭。</p>
</div>
<p v-if="!capability.available" class="scale-note">Home Assistant 未能回读窗帘主控状态。请先确认米家中可操作,再检查集成连接;这里不会把未知状态当作已关闭。</p>
<p class="scale-note">此版先提供单独控制,暂不随一键场景自动开合。不包含反向、校准或清除行程操作。</p>
</div>
</section>
</template>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { toRefs } from 'vue'
import { Button as VanButton, Slider as VanSlider } from 'vant'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
import { useDeviceCardState } from '../../devices/useDeviceCardState'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { device: dehumidifierDevice } = toRefs(props)
const { localParams, operationBusy, switchingDeviceId, isDeviceTurnedOn, toggleDevice, applyParam, loading } = useDeviceControlContext()
const state = useDeviceCardState(() => props.device)
</script>
<template>
<section class="device-card" v-if="dehumidifierDevice">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">恒湿净化</span>
<h3>{{ dehumidifierDevice.name }}</h3>
<span class="status-dot" :class="{ online: dehumidifierDevice.online }">
{{ dehumidifierDevice.online ? '设备在线' : '设备离线' }}
</span>
</div>
<VanButton
size="small"
round
:type="isDeviceTurnedOn(dehumidifierDevice) ? 'primary' : 'default'"
:color="isDeviceTurnedOn(dehumidifierDevice) ? '#0c5045' : ''"
:loading="switchingDeviceId === dehumidifierDevice.deviceId"
:disabled="operationBusy || !state.powerAvailable()"
@click="toggleDevice(dehumidifierDevice)"
>
{{ state.powerLabel('已开启', '已关闭') }}
</VanButton>
</div>
<div class="params-body">
<!-- 目标湿度滑块 -->
<div class="param-row">
<div class="param-header">
<span class="p-title">恒湿目标湿度</span>
<strong class="p-val">{{ state.display('humidity', localParams.dehumidifierHumidity, '%') }}</strong>
</div>
<VanSlider
v-model="localParams.dehumidifierHumidity"
:min="40"
:max="70"
:step="1"
:disabled="operationBusy || !state.available('humidity')"
active-color="#0c5045"
@change="applyParam(dehumidifierDevice.deviceId, 'humidity', localParams.dehumidifierHumidity)"
/>
<div class="slider-scale">
<span>40% 干爽</span>
<span>55% 舒适</span>
<span>70% 润泽</span>
</div>
</div>
<!-- 运行模式切换 -->
<div class="param-row">
<span class="p-title">除湿运行模式</span>
<div class="btn-group">
<button
v-for="mode in ['睡眠', '智能']"
:key="mode"
:class="{ active: state.readable('dehumidifier_mode') && localParams.dehumidifierMode === mode }"
:disabled="operationBusy || !state.available('dehumidifier_mode')"
@click="localParams.dehumidifierMode = mode; applyParam(dehumidifierDevice.deviceId, 'dehumidifier_mode', mode)"
>{{ mode }}</button>
</div>
</div>
<!-- 风速切换 -->
<div class="param-row">
<span class="p-title">除湿机风速档位</span>
<div class="btn-group">
<button
v-for="fan in ['1挡', '2挡', '3挡']"
:key="fan"
:class="{ active: state.readable('fan_level') && localParams.dehumidifierFan === fan }"
:disabled="operationBusy || !state.available('fan_level')"
@click="localParams.dehumidifierFan = fan; applyParam(dehumidifierDevice.deviceId, 'fan_level', fan)"
>
{{ fan }}
</button>
</div>
</div>
<!-- UV 杀菌开关 -->
<div class="param-row inline-row">
<div>
<div class="p-title">UV 深度紫外消杀</div>
<small class="p-tip">夜间杀菌净化,除湿同时清新空气</small>
</div>
<button
class="mini-toggle"
:class="{ active: state.readable('uv') && localParams.dehumidifierUv }"
:disabled="operationBusy || !state.available('uv')"
@click="localParams.dehumidifierUv = !localParams.dehumidifierUv; applyParam(dehumidifierDevice.deviceId, 'uv', localParams.dehumidifierUv)"
>
{{ state.display('uv', localParams.dehumidifierUv ? '已开启' : '已关闭') }}
</button>
</div>
</div>
</section>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { DeviceIconKind } from '../../devices/deviceSummary'
defineProps<{ kind: DeviceIconKind }>()
</script>
<template>
<svg class="device-glyph" viewBox="0 0 80 80" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<g v-if="kind === 'dehumidifier'">
<rect x="22" y="9" width="36" height="60" rx="9" fill="#edf3f0" />
<path d="M28 22h24M28 28h24M28 34h24" opacity=".45" />
<path d="M40 43c-3 4-6 7-6 10a6 6 0 0 0 12 0c0-3-3-6-6-10Z" fill="#bfd7cd" />
<path d="M29 70v3m22-3v3" />
</g>
<g v-else-if="kind === 'lighting'">
<circle cx="40" cy="35" r="20" fill="#f9edcf" stroke="#a98446" />
<circle cx="40" cy="35" r="10" stroke="#a98446" /><path d="M33 56h14M35 61h10M37 66h6" stroke="#a98446" />
<path d="M40 5v5M12 35h5M63 35h5M20 14l4 4m32-4-4 4" stroke="#d1b476" />
</g>
<g v-else-if="kind === 'audio'">
<rect x="22" y="11" width="36" height="58" rx="12" fill="#263e3a" />
<path d="M27 20h26" stroke="#7aafc2" stroke-width="3" />
<circle cx="40" cy="38" r="8" stroke="#819b92" /><circle cx="40" cy="57" r="5" stroke="#819b92" />
</g>
<g v-else-if="kind === 'fragrance'">
<path d="M29 25h22l6 39c0 7-34 7-34 0l6-39Z" fill="#f6f3eb" /><ellipse cx="40" cy="25" rx="11" ry="4" fill="#e3d9c3" />
<path d="M37 18c-6-5 6-8 0-13m8 13c-6-5 6-8 0-13" stroke="#91b5a7" /><path d="M29 59h22" stroke="#bdcfc6" />
</g>
<g v-else-if="kind === 'mattress'">
<path d="m11 44 27-23 32 20-28 25-31-22Z" fill="#d5e2dc" /><path d="M11 44v9l31 21 28-25v-8M42 66v8" />
<path d="m23 35 31 20m-22-27 31 20" stroke="#a3bdb0" />
</g>
<g v-else-if="kind === 'environment' || kind === 'sleep-monitor'">
<rect x="12" y="20" width="56" height="41" rx="9" fill="#e4ede8" /><rect x="18" y="26" width="44" height="28" rx="4" fill="#173e35" />
<path d="m22 43 7-3 5 7 7-13 6 6h10" stroke="#a8d4bf" /><path d="M27 66h26" />
</g>
<g v-else-if="kind === 'scale'">
<rect x="14" y="14" width="52" height="52" rx="13" fill="#edf2ee" /><rect x="30" y="23" width="20" height="10" rx="3" fill="#b3cbbf" />
<circle cx="25" cy="46" r="5" stroke="#b3cbbf" /><circle cx="55" cy="46" r="5" stroke="#b3cbbf" />
</g>
<g v-else-if="kind === 'climate'">
<rect x="8" y="20" width="64" height="30" rx="7" fill="#edf2ee" /><path d="M14 40h52m-52 5h52M27 55v9m13-9v14m13-14v9" /><circle cx="60" cy="28" r="2" fill="#87b29e" stroke="none" />
</g>
<g v-else-if="kind === 'curtain'">
<path d="M10 15h60M15 16v50l20-6V16m30 0v50l-20-6V16" fill="#d9e6df" /><path d="M23 18v44m9-44v41m17-41v41m8-41v44" stroke="#a5c0b1" />
</g>
<g v-else-if="kind === 'outlet'">
<rect x="17" y="12" width="46" height="56" rx="12" fill="#e6eee9" />
<circle cx="40" cy="36" r="14" /><path d="M33 31v7m14-7v7m-7 3v5" />
<circle cx="40" cy="59" r="2" fill="#87b29e" stroke="none" />
</g>
<g v-else>
<rect x="17" y="15" width="46" height="50" rx="11" fill="#e6eee9" /><circle cx="40" cy="40" r="11" /><path d="M40 34v12m-6-6h12" />
</g>
</svg>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { useDeviceControlContext } from '../../devices/controlContext'
const { actionFeedback, operationBusy, currentOperationText, lastUpdatedAt } = useDeviceControlContext()
</script>
<template>
<div class="operation-status" :class="actionFeedback?.type" role="status" aria-live="polite">
<span class="operation-message">
<i v-if="operationBusy" class="operation-spinner" aria-hidden="true"></i>
<i v-else-if="actionFeedback?.type === 'success'" class="operation-result" aria-hidden="true">✓</i>
<i v-else-if="actionFeedback?.type === 'fail'" class="operation-result" aria-hidden="true">!</i>
{{ currentOperationText || actionFeedback?.message || '设备状态会自动更新' }}
</span>
<small v-if="lastUpdatedAt">{{ lastUpdatedAt }}</small>
</div>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import type { DeviceSummary } from '../../devices/deviceSummary'
import DeviceGlyph from './DeviceGlyph.vue'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice; summary: DeviceSummary; busy: boolean; switching: boolean }>()
defineEmits<{ open: []; power: [] }>()
const powerLabel = computed(() => props.switching ? '操作中' : props.summary.powerOn ? props.summary.audioControl ? '停止播放' : '关闭设备'
: props.summary.audioControl ? '开始播放' : '开启设备')
</script>
<template>
<article class="device-overview-tile" :class="{ offline: !device.online, powered: summary.powerOn === true }">
<button class="device-tile-open" type="button" :aria-label="`查看${device.name}详情`" @click="$emit('open')">
<span class="device-tile-category">{{ summary.categoryLabel }}</span>
<DeviceGlyph :kind="summary.icon" />
<strong class="device-tile-name">{{ device.name }}</strong>
<span class="device-tile-status"><i :class="{ online: device.online }"></i>{{ summary.statusText }}</span>
<span class="device-tile-metric">{{ summary.metricText }}</span>
<span class="device-tile-footer">{{ device.areaName || '设备详情' }}<span aria-hidden="true">›</span></span>
</button>
<button v-if="summary.canToggle" class="device-tile-power" type="button" :class="{ on: summary.powerOn }"
:disabled="busy || switching" :aria-label="`${device.name}:${powerLabel}`" :title="powerLabel"
:aria-pressed="summary.powerOn === true" @click.stop="$emit('power')">
<span v-if="switching" class="operation-spinner" aria-hidden="true"></span>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M12 3v9M6.5 5.7a8 8 0 1 0 11 0" />
</svg>
</button>
</article>
</template>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { toRefs } from 'vue'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
import { numberValue } from '../../devices/parameterState'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { device: sensorDevice } = toRefs(props)
const { operationBusy, loadStatus, loading } = useDeviceControlContext()
const metric = (key: string) => props.device.online ? numberValue(props.device.metrics?.[key]) ?? '--' : '--'
</script>
<template>
<section class="device-card" v-if="sensorDevice">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">环境质量监控</span>
<h3>{{ sensorDevice.name }}</h3>
<span class="status-dot" :class="{ online: sensorDevice.online }">
{{ sensorDevice.online ? '实时采集正常' : '设备离线' }}
</span>
</div>
<button class="text-refresh" :disabled="operationBusy" @click="loadStatus(true)">
{{ loading ? '刷新中…' : '手动刷新指标' }}
</button>
</div>
<div class="sensor-grid">
<div class="sensor-box">
<span class="s-label">温度</span>
<strong>{{ metric('temperature') }} <small>°C</small></strong>
</div>
<div class="sensor-box">
<span class="s-label">湿度</span>
<strong>{{ metric('humidity') }} <small>%</small></strong>
</div>
<div class="sensor-box">
<span class="s-label">PM2.5</span>
<strong>{{ metric('pm25') }} <small>μg/m³</small></strong>
</div>
<div class="sensor-box">
<span class="s-label">CO2 浓度</span>
<strong>{{ metric('co2') }} <small>ppm</small></strong>
</div>
<div class="sensor-box">
<span class="s-label">甲醛 HCHO</span>
<strong>{{ metric('hcho') }} <small>mg/m³</small></strong>
</div>
<div class="sensor-box">
<span class="s-label">TVOC</span>
<strong>{{ metric('tvoc') }} <small>mg/m³</small></strong>
</div>
</div>
</section>
</template>
+140
View File
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { toRefs } from 'vue'
import { Button as VanButton, Slider as VanSlider } from 'vant'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
import { useDeviceCardState } from '../../devices/useDeviceCardState'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { device: fragranceDevice } = toRefs(props)
const { localParams, operationBusy, switchingDeviceId, isDeviceTurnedOn, toggleDevice, applyParam, settingParam } = useDeviceControlContext()
const state = useDeviceCardState(() => props.device)
</script>
<template>
<section class="device-card" v-if="fragranceDevice">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">香氛助眠</span>
<h3>{{ fragranceDevice.name }}</h3>
<span class="status-dot" :class="{ online: fragranceDevice.online }">
{{ fragranceDevice.online ? '设备在线' : '设备离线' }}
</span>
</div>
<VanButton
size="small"
round
:type="isDeviceTurnedOn(fragranceDevice) ? 'primary' : 'default'"
:color="isDeviceTurnedOn(fragranceDevice) ? '#0c5045' : ''"
:loading="switchingDeviceId === fragranceDevice.deviceId"
:disabled="operationBusy || !state.powerAvailable()"
@click="toggleDevice(fragranceDevice)"
>
{{ state.powerLabel('释放中', '已关闭') }}
</VanButton>
</div>
<small class="p-tip">上方“释放中/已关闭”只表示香薰主开关;感应夜灯在下方单独显示和控制。</small>
<div class="params-body">
<div class="param-row">
<div class="param-header">
<span class="p-title">单次出香时长</span>
<strong class="p-val">{{ state.display('fragrance_duration', localParams.fragranceDuration, ' 秒') }}</strong>
</div>
<VanSlider
v-model="localParams.fragranceDuration"
:min="2"
:max="6"
:step="1"
:disabled="operationBusy || !state.available('fragrance_duration')"
@change="applyParam(fragranceDevice.deviceId, 'fragrance_duration', localParams.fragranceDuration)"
/>
<div class="range-labels"><span>2 秒</span><span>4 秒</span><span>6 秒</span></div>
</div>
<div class="param-row">
<div class="param-header">
<span class="p-title">自动出香间隔</span>
<strong class="p-val">{{ state.display('fragrance_interval', localParams.fragranceInterval, ' 分钟') }}</strong>
</div>
<VanSlider
v-model="localParams.fragranceInterval"
:min="10"
:max="20"
:step="5"
:disabled="operationBusy || !state.available('fragrance_interval')"
@change="applyParam(fragranceDevice.deviceId, 'fragrance_interval', localParams.fragranceInterval)"
/>
<div class="range-labels"><span>10 分钟</span><span>15 分钟</span><span>20 分钟</span></div>
</div>
<div class="param-row inline-row">
<div>
<div class="p-title">香薰氛围灯</div>
<small class="p-tip">与香薰释放独立控制</small>
</div>
<button
class="mini-toggle"
:class="{ active: state.readable('fragrance_light') && localParams.fragranceLight }"
:disabled="operationBusy || !state.available('fragrance_light')"
@click="localParams.fragranceLight = !localParams.fragranceLight; applyParam(fragranceDevice.deviceId, 'fragrance_light', localParams.fragranceLight)"
>{{ state.display('fragrance_light', localParams.fragranceLight ? '已开启' : '已关闭') }}</button>
</div>
<div class="param-row inline-row">
<div>
<div class="p-title">人来增香</div>
<small class="p-tip">检测到有人活动时自动增香</small>
</div>
<button
class="mini-toggle"
:class="{ active: state.readable('fragrance_auto_release') && localParams.fragranceAutoRelease }"
:disabled="operationBusy || !state.available('fragrance_auto_release')"
@click="localParams.fragranceAutoRelease = !localParams.fragranceAutoRelease; applyParam(fragranceDevice.deviceId, 'fragrance_auto_release', localParams.fragranceAutoRelease)"
>{{ state.display('fragrance_auto_release', localParams.fragranceAutoRelease ? '已开启' : '已关闭') }}</button>
</div>
<div class="param-row inline-row">
<div>
<div class="p-title">感应夜灯</div>
<small class="p-tip">有人移动时自动开启柔光</small>
</div>
<button
class="mini-toggle"
:class="{ active: state.readable('fragrance_auto_light') && localParams.fragranceAutoLight }"
:disabled="operationBusy || !state.available('fragrance_auto_light')"
@click="localParams.fragranceAutoLight = !localParams.fragranceAutoLight; applyParam(fragranceDevice.deviceId, 'fragrance_auto_light', localParams.fragranceAutoLight)"
>{{ state.display('fragrance_auto_light', localParams.fragranceAutoLight ? '已开启' : '已关闭') }}</button>
</div>
<div class="param-row inline-row">
<div>
<div class="p-title">无人时自动关灯</div>
<small class="p-tip">关闭时氛围灯保持常亮</small>
</div>
<button
class="mini-toggle"
:class="{ active: state.readable('fragrance_light_auto_off') && localParams.fragranceLightAutoOff }"
:disabled="operationBusy || !state.available('fragrance_light_auto_off')"
@click="localParams.fragranceLightAutoOff = !localParams.fragranceLightAutoOff; applyParam(fragranceDevice.deviceId, 'fragrance_light_auto_off', localParams.fragranceLightAutoOff)"
>{{ state.display('fragrance_light_auto_off', localParams.fragranceLightAutoOff ? '已开启' : '已关闭') }}</button>
</div>
<div class="param-row inline-row">
<div>
<div class="p-title">立即出香</div>
<small class="p-tip">{{ state.available('fragrance_quick_release') ? '手动触发一次当前时长的香氛释放' : '快速出香能力当前不可用' }}</small>
</div>
<VanButton
size="small"
round
plain
color="#0c5045"
:loading="settingParam === `${fragranceDevice.deviceId}_fragrance_quick_release`"
:disabled="operationBusy || !state.available('fragrance_quick_release')"
@click="applyParam(fragranceDevice.deviceId, 'fragrance_quick_release', true)"
>{{ state.available('fragrance_quick_release') ? '快速出香' : '暂不可用' }}</VanButton>
</div>
</div>
</section>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { operationBusy, toggleDevice, isDeviceTurnedOn, isDeviceControlAvailable, switchingDeviceId } = useDeviceControlContext()
const expanded = ref(false)
const entities = computed(() => expanded.value ? props.device.entities : props.device.entities.slice(0, 6))
</script>
<template>
<section class="device-card">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">设备能力</span>
<h3>{{ device.name }}</h3>
<span class="status-dot" :class="{ online: device.online }">{{ device.online ? '设备在线' : '设备离线' }}</span>
</div>
<button v-if="device.primaryControlEntityId" class="mini-toggle"
:class="{ active: isDeviceTurnedOn(device) }"
:disabled="operationBusy || !isDeviceControlAvailable(device) || switchingDeviceId === device.deviceId" @click="toggleDevice(device)">
{{ !isDeviceControlAvailable(device) ? '暂不可用' : isDeviceTurnedOn(device) ? '关闭' : '开启' }}
</button>
</div>
<div class="params-body">
<div v-for="entity in entities" :key="entity.entityId" class="param-row">
<span class="p-title">{{ entity.name }}</span>
<span class="p-val">{{ entity.available && !['unknown', 'unavailable'].includes(entity.state) ? entity.state : '不可用' }} {{ entity.unit || '' }}</span>
</div>
<button v-if="device.entities.length > 6" class="text-refresh" @click="expanded = !expanded">
{{ expanded ? '收起能力' : '查看全部能力' }}
</button>
<p class="scale-note">已发现此设备;专属参数和自动场景需由对应模块接入,不会自动执行未知能力。</p>
</div>
</section>
</template>
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { toRefs } from 'vue'
import { Button as VanButton, Slider as VanSlider } from 'vant'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
import { useDeviceCardState } from '../../devices/useDeviceCardState'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { device: lightDevice } = toRefs(props)
const { localParams, operationBusy, switchingDeviceId, isDeviceTurnedOn, toggleDevice, applyParam, controlLightTransition, lightTransition, loading } = useDeviceControlContext()
const state = useDeviceCardState(() => props.device)
</script>
<template>
<section class="device-card" v-if="lightDevice">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">卧室灯光</span>
<h3>{{ lightDevice.name }}</h3>
<span class="status-dot" :class="{ online: lightDevice.online }">
{{ lightDevice.online ? '设备在线' : '设备离线' }}
</span>
</div>
<VanButton
size="small"
round
:type="isDeviceTurnedOn(lightDevice) ? 'primary' : 'default'"
:color="isDeviceTurnedOn(lightDevice) ? '#0c5045' : ''"
:loading="switchingDeviceId === lightDevice.deviceId"
:disabled="operationBusy || !state.powerAvailable()"
@click="toggleDevice(lightDevice)"
>
{{ state.powerLabel('已点亮', '已熄灭') }}
</VanButton>
</div>
<div class="params-body">
<div class="light-mode-panel">
<div>
<strong v-if="lightTransition?.running">
{{ lightTransition.mode === 'wake' ? '晨起唤醒中' : '助眠渐暗中' }} · {{ lightTransition.brightnessPercent }}%
</strong>
<strong v-else>渐变灯光</strong>
<small>{{ lightTransition?.message || '开灯从50%逐渐变暗;晨起从当前亮度逐渐变亮至70%' }}</small>
</div>
<div class="light-mode-actions">
<button type="button" :disabled="operationBusy || !state.available('brightness')" @click="controlLightTransition('sleep')">🌙 开始助眠渐暗</button>
<button type="button" :disabled="operationBusy || !state.available('brightness')" @click="controlLightTransition('wake')">🌅 晨起唤醒</button>
<button v-if="lightTransition?.running" type="button" :disabled="operationBusy" @click="controlLightTransition('stop')">停止渐变</button>
</div>
</div>
<!-- 亮度滑块 -->
<div class="param-row">
<div class="param-header">
<span class="p-title">灯光亮度</span>
<strong class="p-val">{{ lightTransition?.running ? `${localParams.lightBrightness}%` : state.display('brightness', localParams.lightBrightness, '%') }}</strong>
</div>
<VanSlider
v-model="localParams.lightBrightness"
:min="0"
:max="100"
:disabled="operationBusy || !state.available('brightness')"
active-color="#d6a657"
@change="applyParam(lightDevice.deviceId, 'brightness', localParams.lightBrightness)"
/>
<div class="slider-scale">
<span>0% 关闭灯光</span>
<span>50% 舒缓</span>
<span>100% 满亮</span>
</div>
</div>
<!-- 色温滑块 -->
<div class="param-row">
<div class="param-header">
<span class="p-title">灯光色温调节</span>
<strong class="p-val">{{ state.display('color_temp', localParams.lightColorTemp, 'K') }}</strong>
</div>
<VanSlider
v-model="localParams.lightColorTemp"
:min="2200"
:max="5000"
:step="50"
:disabled="operationBusy || !state.available('color_temp')"
active-color="#c87f2b"
@change="applyParam(lightDevice.deviceId, 'color_temp', localParams.lightColorTemp)"
/>
<div class="slider-scale">
<span>2200K 超暖橙光 (利于褪黑素)</span>
<span>5000K 清爽白光</span>
</div>
</div>
</div>
</section>
</template>
+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { toRefs } from 'vue'
import { Slider as VanSlider } from 'vant'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
import { useDeviceCardState } from '../../devices/useDeviceCardState'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { device: mattressDevice } = toRefs(props)
const { localParams, operationBusy, applyParam, mattressComfort, mattressActiveSide, mattressModeOptions, setMattressMode, setMattressSelect, mattressFirmnessOptions, mattressFirmnessZones, massagePartOptions } = useDeviceControlContext()
const state = useDeviceCardState(() => props.device)
</script>
<template>
<section class="device-card" v-if="mattressDevice">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">智能承托与温控</span>
<h3>{{ mattressDevice.name }}</h3>
<span class="status-dot" :class="{ online: mattressDevice.online }">
{{ mattressDevice.online ? '设备在线' : '设备离线' }}
</span>
</div>
</div>
<div class="params-body">
<div class="mattress-recommendation">
<strong>室内温度与 BMI 床垫建议</strong>
<p>{{ mattressComfort?.heatingReason || '室温建议读取中' }}</p>
<p>{{ mattressComfort?.firmnessReason || 'BMI 建议读取中' }}</p>
<small v-if="mattressComfort?.indoorTemperatureAvailable">
当前室温 {{ mattressComfort.indoorTemperatureCelsius }}℃
</small>
<small>下次一键启动使用当前建议;下面显示设备读数,可手动覆盖。</small>
</div>
<!-- 左侧加热档位 -->
<div class="param-row">
<div class="param-header">
<span class="p-title">左侧加热档位</span>
<strong class="p-val">{{ state.display('mattress_left_heating', localParams.mattressLeftHeating, ' 档') }}</strong>
</div>
<VanSlider
v-model="localParams.mattressLeftHeating"
:min="0"
:max="4"
:step="1"
:disabled="operationBusy || !state.available('mattress_left_heating')"
active-color="#d66927"
@change="applyParam(mattressDevice.deviceId, 'mattress_left_heating', localParams.mattressLeftHeating)"
/>
<div class="slider-scale">
<span>0 关闭</span>
<span>2 舒适</span>
<span>4 强加热</span>
</div>
</div>
<!-- 右侧加热档位 -->
<div class="param-row">
<div class="param-header">
<span class="p-title">右侧加热档位</span>
<strong class="p-val">{{ state.display('mattress_right_heating', localParams.mattressRightHeating, ' 档') }}</strong>
</div>
<VanSlider
v-model="localParams.mattressRightHeating"
:min="0"
:max="4"
:step="1"
:disabled="operationBusy || !state.available('mattress_right_heating')"
active-color="#d66927"
@change="applyParam(mattressDevice.deviceId, 'mattress_right_heating', localParams.mattressRightHeating)"
/>
<div class="slider-scale">
<span>0 关闭</span>
<span>2 舒适</span>
<span>4 强加热</span>
</div>
</div>
<div class="param-row">
<p class="mattress-mode-note">
左右可分别预选模式,但共用气泵,同一时间仅最近启动的一侧实际运行;另一侧保留预选,处于待机状态。
</p>
</div>
<div class="param-row">
<span class="p-title">左侧承托模式 · {{ !state.readable('mattress_left_mode') ? '状态未确认(保留预选)' : mattressActiveSide === 'left' ? '运行中' : '预选待机' }}</span>
<div class="btn-group mattress-mode-buttons">
<button
v-for="mode in mattressModeOptions"
:key="`left-${mode}`"
:class="{ active: localParams.mattressLeftMode === mode, running: state.readable('mattress_left_mode') && mattressActiveSide === 'left' && localParams.mattressLeftMode === mode }"
:disabled="operationBusy || !state.available('mattress_left_mode')"
@click="setMattressMode('left', mode)"
>{{ mode }}</button>
</div>
</div>
<div class="param-row">
<span class="p-title">右侧承托模式 · {{ !state.readable('mattress_right_mode') ? '状态未确认(保留预选)' : mattressActiveSide === 'right' ? '运行中' : '预选待机' }}</span>
<div class="btn-group mattress-mode-buttons">
<button
v-for="mode in mattressModeOptions"
:key="`right-${mode}`"
:class="{ active: localParams.mattressRightMode === mode, running: state.readable('mattress_right_mode') && mattressActiveSide === 'right' && localParams.mattressRightMode === mode }"
:disabled="operationBusy || !state.available('mattress_right_mode')"
@click="setMattressMode('right', mode)"
>{{ mode }}</button>
</div>
</div>
<details class="mattress-advanced">
<summary>分区软硬度与按摩调节</summary>
<p class="mattress-mode-note">软硬度按左、右各 6 个部位独立设置;BMI 只给出演示起点,并非医学建议。选择按摩部位可能让床垫立即动作。</p>
<div v-for="zone in mattressFirmnessZones" :key="zone.key" class="mattress-select-row">
<label :for="`firmness-${zone.key}`">{{ zone.label }}软硬度</label>
<select :id="`firmness-${zone.key}`"
:value="state.readable(`mattress_firmness_${zone.key}`) ? localParams.mattressFirmness[zone.key] || '' : ''"
:disabled="operationBusy || !state.available(`mattress_firmness_${zone.key}`)"
@change="setMattressSelect(`mattress_firmness_${zone.key}`, $event)">
<option value="" disabled>状态未确认</option>
<option v-for="level in mattressFirmnessOptions" :key="level" :value="level">{{ level }}</option>
</select>
</div>
<div class="mattress-select-row">
<label for="massage-left-part">左侧按摩部位</label>
<select id="massage-left-part" :value="state.readable('mattress_massage_left_part') ? localParams.mattressMassageLeftPart : ''"
:disabled="operationBusy || !state.available('mattress_massage_left_part')"
@change="setMattressSelect('mattress_massage_left_part', $event)">
<option value="" disabled>状态未确认</option>
<option v-for="part in massagePartOptions" :key="part" :value="part">{{ part }}</option>
</select>
</div>
<div class="mattress-select-row">
<label for="massage-right-part">右侧按摩部位</label>
<select id="massage-right-part" :value="state.readable('mattress_massage_right_part') ? localParams.mattressMassageRightPart : ''"
:disabled="operationBusy || !state.available('mattress_massage_right_part')"
@change="setMattressSelect('mattress_massage_right_part', $event)">
<option value="" disabled>状态未确认</option>
<option v-for="part in massagePartOptions" :key="part" :value="part">{{ part }}</option>
</select>
</div>
<div class="mattress-select-row">
<label for="massage-level">按摩力度</label>
<select id="massage-level" :value="state.readable('mattress_massage_level') ? localParams.mattressMassageLevel : ''"
:disabled="operationBusy || !state.available('mattress_massage_level')"
@change="setMattressSelect('mattress_massage_level', $event)">
<option value="" disabled>状态未确认</option>
<option value="轻柔">轻柔</option>
<option value="强劲">强劲</option>
</select>
</div>
</details>
</div>
</section>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Button as VanButton } from 'vant'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
import { outletCapabilities } from '../../devices/outlet/capabilities'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { operationBusy, switchingDeviceId, toggleDevice } = useDeviceControlContext()
const capability = computed(() => outletCapabilities(props.device))
</script>
<template>
<section class="device-card">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">智能插座</span>
<h3>{{ device.name }}</h3>
<span class="status-dot" :class="{ online: capability.available }">{{ capability.available ? '插座可用' : '插座主开关当前不可用' }}</span>
</div>
<VanButton size="small" round :type="capability.powerOn ? 'primary' : 'default'"
:loading="switchingDeviceId === device.deviceId" :disabled="operationBusy || !capability.available"
@click="toggleDevice(device)">
{{ capability.available ? capability.powerOn ? '关闭电源' : '开启电源' : '暂不可用' }}
</VanButton>
</div>
<div class="params-body">
<div class="param-row inline-row">
<span class="p-title">插座电源状态</span>
<strong class="p-val">{{ capability.powerOn === null ? '--' : capability.powerOn ? '已通电' : '已断电' }}</strong>
</div>
<div v-if="capability.energyEntity" class="param-row inline-row">
<span class="p-title">累计用电</span>
<strong class="p-val">{{ capability.energy === undefined ? '--' : `${capability.energy} ${capability.energyEntity.unit}` }}</strong>
</div>
<p class="scale-note">仅单独控制插座电源,暂不参与一键舒睡或一键关闭。内部服务及保护设置不在此调整。</p>
</div>
</section>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { toRefs } from 'vue'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { device: scaleDevice } = toRefs(props)
</script>
<template>
<section class="device-card" v-if="scaleDevice">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">健康身体成分</span>
<h3>{{ scaleDevice.name }}</h3>
<span class="status-dot" :class="{ online: scaleDevice.online }">
{{ scaleDevice.online ? '已绑定就绪' : '设备离线' }}
</span>
</div>
</div>
<p class="scale-note">
体脂秤数据是否自动同步取决于设备接入能力;当前 Demo 按健康档案中保存的身高、体重计算 BMI。
</p>
</section>
</template>
+318
View File
@@ -0,0 +1,318 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showFailToast, showLoadingToast, showSuccessToast } from 'vant'
import { healthApi, type HomeAssistantDiscoveredDevice, type HomeAssistantStatus,
type MattressComfort, type TcmAssessmentResult } from '../services/health'
import { createDeviceParameterState, parameterLabels, syncDeviceParameters } from '../devices/parameterRegistry'
import { mattressFirmnessOptions, mattressModeOptions, mattressFirmnessZones, massagePartOptions } from '../devices/mattress/parameters'
import { useMattressPreferences } from '../devices/mattress/useMattressPreferences'
import { useMattressControls } from '../devices/mattress/useMattressControls'
import { useDeviceTransitions } from './useDeviceTransitions'
import { useDeviceScenes } from './useDeviceScenes'
import { parameterHasReading, parameterIsAvailable } from '../devices/parameterState'
import { primaryDeviceEntity, reportedPower } from '../devices/deviceSummary'
import { climateCapabilities } from '../devices/climate/capabilities'
import { outletCapabilities } from '../devices/outlet/capabilities'
/** 页面公共交互;家具参数同步/左右预选/渐变轮询各有独立模块。 */
export function useDeviceControl() {
const router = useRouter()
const loading = ref(true)
const haStatus = ref<HomeAssistantStatus>()
const tcmResult = ref<TcmAssessmentResult>()
const mattressComfort = ref<MattressComfort>()
const settingParam = ref<string | null>(null)
const switchingDeviceId = ref<string | null>(null)
const lastUpdatedAt = ref('')
const actionFeedback = ref<{ type: 'success' | 'fail'; message: string }>()
const lightActionBusy = ref(false)
const currentUserId = ref<number>()
const localParams = createDeviceParameterState()
const mattressDevice = computed(() => haStatus.value?.devices.find(device => device.moduleKey === 'mattress')
?? haStatus.value?.devices.find(device => device.moduleKey === undefined && device.category === 'Sleep'))
const { mattressActiveSide, syncModes, commitMode } =
useMattressPreferences(localParams, currentUserId, () => mattressDevice.value?.deviceId)
const { lightTransition, musicTransition, syncLightTransitionStatus, syncMusicTransitionStatus } =
useDeviceTransitions(localParams, async () => {
await loadStatus(false, true)
if (lightTransition.value?.message) setFeedback(lightTransition.value.success ? 'success' : 'fail', lightTransition.value.message)
})
let active = true
let loadSequence = 0
let statusInFlight = false
let refreshInterval: ReturnType<typeof setInterval> | undefined
const operationToasts = new Set<ReturnType<typeof showLoadingToast>>()
function operationToast(message: string) {
const toast = showLoadingToast({ message, forbidClick: true, duration: 0 })
operationToasts.add(toast)
return { close() { toast.close(); operationToasts.delete(toast) } }
}
const { sceneRunning, executeScene, scheduleSceneStatusRefresh } = useDeviceScenes({
hasConstitution: () => Boolean(tcmResult.value?.hasCompleted),
needsConstitution: () => { void router.push('/tcm-assessment') },
refresh: () => loadStatus(), refreshLater: () => loadStatus(false, true), feedback: setFeedback,
})
const { setMattressMode, setMattressSelect } = useMattressControls(mattressDevice, applyParam, loadStatus, commitMode)
function setFeedback(type: 'success' | 'fail', message: string) {
if (active) actionFeedback.value = { type, message }
}
function isParameterAvailable(parameter: string, device?: HomeAssistantDiscoveredDevice) {
return parameterIsAvailable(haStatus.value?.controlParameters, parameter, device)
}
function hasParameterReading(parameter: string, device?: HomeAssistantDiscoveredDevice) {
return parameterHasReading(haStatus.value?.controlParameters, parameter, device)
}
function isDeviceControlAvailable(device?: HomeAssistantDiscoveredDevice) {
if (!device?.online) return false
if (device.moduleKey === 'outlet' || device.category === 'Outlet') return outletCapabilities(device).available
const target = primaryDeviceEntity(device)
if (target?.domain === 'climate') return climateCapabilities(device).canToggle
return Boolean(target && ['switch', 'humidifier', 'light', 'media_player'].includes(target.domain) && reportedPower(target) !== null)
}
const operationBusy = computed(() =>
loading.value || lightActionBusy.value || Boolean(sceneRunning.value) || Boolean(settingParam.value) || Boolean(switchingDeviceId.value)
)
const currentOperationText = computed(() => {
if (lightActionBusy.value) return '正在切换灯光渐变模式…'
if (sceneRunning.value === 'sleep') return '正在启动全屋舒睡模式…'
if (sceneRunning.value === 'all-off') return '正在关闭场景设备…'
if (switchingDeviceId.value) return '正在同步设备开关状态…'
if (settingParam.value) {
const parameterType = Object.keys(parameterLabels)
.find(key => settingParam.value?.endsWith(`_${key}`))
return `正在设置${parameterLabels[parameterType ?? ''] ?? '设备参数'}…`
}
if (loading.value) return '正在刷新设备状态…'
return ''
})
async function loadStatus(notify = false, background = false) {
if (!active || (background && (operationBusy.value || statusInFlight))) return
const sequence = ++loadSequence
statusInFlight = true
if (!background) loading.value = true
try {
const [status, dashboard] = await Promise.all([
healthApi.homeAssistantStatus(), background ? Promise.resolve(undefined) : healthApi.dashboard().catch(() => undefined)
])
if (!active || sequence !== loadSequence) return
haStatus.value = status
if (dashboard) {
currentUserId.value = dashboard.user.id
tcmResult.value = dashboard.tcmResult
mattressComfort.value = dashboard.mattressComfort
}
const controls = status.controlParameters ?? {}
syncDeviceParameters(localParams, controls)
syncModes(controls)
await Promise.all([syncLightTransitionStatus().catch(() => undefined), syncMusicTransitionStatus().catch(() => undefined)])
if (!active || sequence !== loadSequence) return
lastUpdatedAt.value = new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).format(new Date())
if (notify) { setFeedback('success', '设备状态已刷新'); showSuccessToast('设备状态已刷新') }
} catch (error) {
if (!active || sequence !== loadSequence) return
if (!background) {
const message = error instanceof Error ? error.message : '获取设备状态失败'
setFeedback('fail', message); showFailToast(message)
}
} finally {
if (sequence === loadSequence) {
statusInFlight = false
if (!background) loading.value = false
}
}
}
async function controlLightTransition(action: 'sleep' | 'wake' | 'stop') {
if (!active || operationBusy.value) return
lightActionBusy.value = true
const loadingToast = operationToast(action === 'sleep' ? '正在启动助眠渐暗…' : action === 'wake' ? '正在启动晨起唤醒…' : '正在停止灯光渐变…')
try {
const result = action === 'sleep'
? await healthApi.startSleepLight()
: action === 'wake'
? await healthApi.startWakeLight()
: await healthApi.stopLightTransition()
if (!result.success) throw new Error(result.message)
if (!active) return
lightTransition.value = result
await loadStatus()
if (!active) return
setFeedback('success', result.message)
showSuccessToast(result.message)
} catch (err) {
if (!active) return
const message = err instanceof Error ? err.message : '灯光操作失败'
setFeedback('fail', message)
showFailToast(message)
} finally {
loadingToast.close()
lightActionBusy.value = false
}
}
// 判断设备是否开启
function isDeviceTurnedOn(device?: HomeAssistantDiscoveredDevice): boolean {
return Boolean(device && reportedPower(primaryDeviceEntity(device)))
}
function updateEntityState(deviceId: string, entityId: string, state: string, isMuted?: boolean | null) {
const device = haStatus.value?.devices.find(item => item.deviceId === deviceId)
const entity = device?.entities.find(item => item.entityId === entityId)
if (!entity || !state || state === 'unknown' || state === 'unavailable') return
entity.state = state
if (typeof isMuted === 'boolean') {
entity.attributes = { ...(entity.attributes ?? {}), is_volume_muted: isMuted }
}
}
// 单独开关设备
async function toggleDevice(device?: HomeAssistantDiscoveredDevice) {
if (!active || operationBusy.value) return
if (!device || !device.primaryControlEntityId) {
const message = '该设备暂无可控主开关'
setFeedback('fail', message)
showFailToast(message)
return
}
if (!isDeviceControlAvailable(device)) {
const message = '设备主开关当前不可用,请刷新状态后再试'
setFeedback('fail', message); showFailToast(message); return
}
const nextOn = !isDeviceTurnedOn(device)
switchingDeviceId.value = device.deviceId
const loadingToast = operationToast(nextOn ? '正在开启设备…' : '正在关闭设备…')
try {
const res = await healthApi.switchDevice({
deviceId: device.deviceId,
turnOn: nextOn,
entityId: device.primaryControlEntityId
})
const message = res.message || (nextOn ? '设备已开启' : '设备已关闭')
if (!res.success) throw new Error(message)
await loadStatus()
if (!active) return
// 只采用真实回读的非空状态,不使用目标值伪造完成。
updateEntityState(res.deviceId, res.entityId, res.state, res.isMuted)
setFeedback('success', message)
showSuccessToast(message)
} catch (err) {
if (!active) return
const message = err instanceof Error ? err.message : '控制失败'
await loadStatus()
if (!active) return
setFeedback('fail', message)
showFailToast(message)
} finally {
loadingToast.close()
switchingDeviceId.value = null
}
}
async function controlSpeaker(device: HomeAssistantDiscoveredDevice, play: boolean) {
if (!active || operationBusy.value) return
const target = primaryDeviceEntity(device)
if (target?.domain !== 'media_player') {
const message = '音箱缺少已确认的控制实体'
setFeedback('fail', message)
showFailToast(message)
return
}
switchingDeviceId.value = device.deviceId
const loadingToast = operationToast(play ? '正在发送播放指令…' : '正在发送停止指令…')
try {
const res = await healthApi.switchDevice({
deviceId: device.deviceId,
entityId: target.entityId,
turnOn: play
})
if (!res.success) throw new Error(res.message || '音箱控制失败')
await loadStatus()
await syncMusicTransitionStatus().catch(() => undefined)
if (!active) return
setFeedback('success', res.message)
showSuccessToast(res.message)
} catch (err) {
if (!active) return
const message = err instanceof Error ? err.message : '音箱控制失败'
setFeedback('fail', message)
showFailToast(message)
} finally {
loadingToast.close()
switchingDeviceId.value = null
}
}
// 调节参数 API
async function applyParam(deviceId: string, paramType: string, val: unknown, entityId?: string): Promise<boolean> {
if (!active || operationBusy.value) return false
const previousControls = haStatus.value?.controlParameters ?? {}
const key = `${deviceId}_${paramType}`
settingParam.value = key
const label = parameterLabels[paramType]
|| (paramType.startsWith('mattress_firmness_') ? '床垫分区软硬度' : '设备参数')
const loadingToast = operationToast(`正在设置${label}…`)
try {
const res = await healthApi.setParameter({
deviceId,
entityId,
parameterType: paramType,
value: val
})
if (!active) return false
const message = res.message || `${label}设置成功`
if (!res.success) throw new Error(message)
if (paramType === 'volume') await syncMusicTransitionStatus().catch(() => undefined)
else await loadStatus()
if (!active) return false
setFeedback('success', message)
showSuccessToast(message)
return true
} catch (err) {
if (!active) return false
const message = err instanceof Error ? err.message : `${label}设置失败`
syncDeviceParameters(localParams, previousControls)
syncModes(previousControls)
await loadStatus()
if (!active) return false
setFeedback('fail', message)
showFailToast(message)
return false
} finally {
loadingToast.close()
settingParam.value = null
scheduleSceneStatusRefresh()
}
}
function handleBack() {
if (window.history.length > 1) {
router.back()
} else {
router.replace('/dashboard')
}
}
onMounted(() => {
void loadStatus()
refreshInterval = setInterval(() => { void loadStatus(false, true) }, 10000)
})
onUnmounted(() => {
active = false; loadSequence++
if (refreshInterval) clearInterval(refreshInterval)
for (const toast of operationToasts) toast.close()
operationToasts.clear()
})
return { router, loading, haStatus, tcmResult, mattressComfort, sceneRunning, settingParam, switchingDeviceId,
lastUpdatedAt, actionFeedback, lightTransition, musicTransition, lightActionBusy, localParams, operationBusy,
currentOperationText, mattressActiveSide, mattressFirmnessOptions, mattressModeOptions, mattressFirmnessZones,
massagePartOptions, loadStatus, controlLightTransition, isDeviceTurnedOn, isDeviceControlAvailable, isParameterAvailable, hasParameterReading, toggleDevice, controlSpeaker,
applyParam, setMattressMode, setMattressSelect, executeScene, handleBack }
}
+70
View File
@@ -0,0 +1,70 @@
import { onUnmounted, ref } from 'vue'
import { showFailToast, showLoadingToast, showSuccessToast } from 'vant'
import { healthApi } from '../services/health'
type SceneOptions = {
hasConstitution: () => boolean
needsConstitution: () => void
refresh: () => Promise<void>
refreshLater?: () => Promise<void>
feedback?: (type: 'success' | 'fail', message: string) => void
}
/** 首页和设备页共用:取消旧请求结果、全关接管、延迟回读和退出清理。 */
export function useDeviceScenes(options: SceneOptions) {
const sceneRunning = ref<'sleep' | 'all-off'>()
let active = true, sequence = 0
let toast: ReturnType<typeof showLoadingToast> | undefined
const timers: ReturnType<typeof setTimeout>[] = []
function clearRefreshTimers() {
for (const timer of timers) clearTimeout(timer)
timers.length = 0
}
function scheduleSceneStatusRefresh() {
clearRefreshTimers()
if (!active) return
for (const delay of [2000, 5000, 10000]) timers.push(setTimeout(() => {
if (active && !sceneRunning.value) void (options.refreshLater ?? options.refresh)().catch(() => undefined)
}, delay))
}
function feedback(type: 'success' | 'fail', message: string) {
options.feedback?.(type, message)
if (type === 'fail') showFailToast(message)
else showSuccessToast(message)
}
async function executeScene(scene: 'sleep' | 'all-off') {
if (!active || sceneRunning.value === 'all-off' || (scene === 'sleep' && sceneRunning.value)) return
if (scene === 'sleep' && !options.hasConstitution()) {
showFailToast('请先完成中医四诊,生成体质倾向'); options.needsConstitution(); return
}
const request = ++sequence
const current = () => active && request === sequence
clearRefreshTimers(); toast?.close()
sceneRunning.value = scene
const loadingToast = showLoadingToast({
message: scene === 'sleep' ? '正在启动舒睡场景…' : '正在关闭场景设备…',
// 其他按钮自行禁用,但不能用全屏点击锁挡住一键关闭。
forbidClick: false, duration: 0,
})
toast = loadingToast
try {
const result = scene === 'sleep' ? await healthApi.executeSleepScene() : await healthApi.executeAllOffScene()
if (!current()) return
await options.refresh().catch(() => undefined)
if (!current()) return
feedback(result.success && result.status !== 'Partial' ? 'success' : 'fail', result.message || '场景执行失败')
} catch (error) {
if (!current()) return
await options.refresh().catch(() => undefined)
if (!current()) return
feedback('fail', error instanceof Error ? error.message : '场景执行失败')
} finally {
if (request === sequence) {
loadingToast.close(); toast = undefined; sceneRunning.value = undefined
scheduleSceneStatusRefresh()
}
}
}
onUnmounted(() => { active = false; sequence++; toast?.close(); clearRefreshTimers() })
return { sceneRunning, executeScene, scheduleSceneStatusRefresh }
}
+57
View File
@@ -0,0 +1,57 @@
import { onUnmounted, ref } from 'vue'
import { healthApi, type HomeAssistantLightTransition, type TcmMusicTransition } from '../services/health'
import type { DeviceParameterState } from '../devices/parameterRegistry'
import { clamp } from '../devices/parameterState'
export function useDeviceTransitions(parameters: DeviceParameterState, onLightEnded: () => Promise<void>) {
const lightTransition = ref<HomeAssistantLightTransition>()
const musicTransition = ref<TcmMusicTransition>()
let active = true
let lightTimer: ReturnType<typeof setInterval> | undefined
let musicTimer: ReturnType<typeof setInterval> | undefined
let lightPolling = false
let musicPolling = false
let musicSequence = 0
let lightSequence = 0
async function syncMusicTransitionStatus() {
const sequence = ++musicSequence
const status = await healthApi.musicTransitionStatus()
if (!active || sequence !== musicSequence) return
musicTransition.value = status
if (status.hasVolume) parameters.speakerVolume = clamp(status.volumePercent, 0, 100)
if (status.running && !musicTimer) musicTimer = setInterval(() => { void pollMusic() }, 5000)
else if (!status.running && musicTimer) { clearInterval(musicTimer); musicTimer = undefined }
}
async function pollMusic() {
if (!active || musicPolling) return
musicPolling = true
try { await syncMusicTransitionStatus() } catch { /* 下个周期重试。 */ }
finally { musicPolling = false }
}
async function syncLightTransitionStatus() {
const sequence = ++lightSequence
const status = await healthApi.lightTransitionStatus()
if (!active || sequence !== lightSequence) return
lightTransition.value = status
if (status.running) {
parameters.lightBrightness = clamp(status.brightnessPercent, 0, 100)
if (!lightTimer) lightTimer = setInterval(() => { void pollLight() }, 5000)
} else if (lightTimer) { clearInterval(lightTimer); lightTimer = undefined }
}
async function pollLight() {
if (!active || lightPolling) return
lightPolling = true
try {
const wasRunning = lightTransition.value?.running
await syncLightTransitionStatus()
if (active && wasRunning && !lightTransition.value?.running) await onLightEnded()
} catch { /* 状态读取失败不伪造成功,下个周期重试。 */ }
finally { lightPolling = false }
}
onUnmounted(() => {
active = false
if (lightTimer) clearInterval(lightTimer)
if (musicTimer) clearInterval(musicTimer)
})
return { lightTransition, musicTransition, syncLightTransitionStatus, syncMusicTransitionStatus }
}
+18
View File
@@ -0,0 +1,18 @@
import { onUnmounted } from 'vue'
// Pages may leave while a request is pending. Only the latest live request may
// update their state, show a toast, or navigate after it settles.
export function usePageRequest() {
let sequence = 0
let active = true
onUnmounted(() => {
active = false
sequence++
})
return {
begin: () => ++sequence,
isCurrent: (requestId: number) => active && requestId === sequence
}
}
+9
View File
@@ -0,0 +1,9 @@
import { boolValue, clamp, numberValue, type ParameterSnapshot } from '../parameterState'
export const defaults = { speakerVolume: 50, speakerSleepMode: false }
export const labels = { volume: '音箱音量', speaker_sleep_mode: '音箱睡眠模式' }
export function sync(state: typeof defaults, controls: ParameterSnapshot) {
const volume = numberValue(controls.volume?.attributes?.volume_level)
if (controls.volume?.available && volume !== undefined) state.speakerVolume = clamp(Math.round(volume * 100), 0, 100)
const sleep = boolValue(controls.speaker_sleep_mode)
if (sleep !== undefined) state.speakerSleepMode = sleep
}
+26
View File
@@ -0,0 +1,26 @@
import type { Component } from 'vue'
import type { HomeAssistantDiscoveredDevice } from '../services/health'
import DehumidifierCard from '../components/devices/DehumidifierCard.vue'
import LightingCard from '../components/devices/LightingCard.vue'
import AudioCard from '../components/devices/AudioCard.vue'
import FragranceCard from '../components/devices/FragranceCard.vue'
import MattressCard from '../components/devices/MattressCard.vue'
import EnvironmentCard from '../components/devices/EnvironmentCard.vue'
import ScaleCard from '../components/devices/ScaleCard.vue'
import GenericDeviceCard from '../components/devices/GenericDeviceCard.vue'
import ClimateCard from '../components/devices/ClimateCard.vue'
import CurtainCard from '../components/devices/CurtainCard.vue'
import OutletCard from '../components/devices/OutletCard.vue'
import { deviceModuleKey } from './deviceKinds'
/** 新增专属卡片只注册这里;主页面不增加家具分支。 */
const cards: Record<string, Component> = {
dehumidifier: DehumidifierCard, lighting: LightingCard, audio: AudioCard,
fragrance: FragranceCard, mattress: MattressCard, environment: EnvironmentCard,
climate: ClimateCard, curtain: CurtainCard, outlet: OutletCard
}
export function deviceCardFor(device: HomeAssistantDiscoveredDevice): Component {
if (device.category === 'BodyComposition') return ScaleCard
// 新版没有模块绑定的设备只展示通用能力,不能误用同类设备的已配置参数。
return cards[deviceModuleKey(device)] ?? GenericDeviceCard
}
+33
View File
@@ -0,0 +1,33 @@
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { numberValue } from '../parameterState'
export const climateModeLabels: Record<string, string> = {
off: '关闭', cool: '制冷', heat: '制热', fan_only: '送风', dry: '除湿', auto: '自动', heat_cool: '冷暖自动',
}
export function climateCapabilities(device: HomeAssistantDiscoveredDevice) {
const entity = device.entities.find(item => item.entityId === device.primaryControlEntityId && item.domain === 'climate')
const attributes = entity?.attributes ?? {}
const reportedUnit = attributes.temperature_unit ?? attributes.unit_of_measurement
const temperatureUnit = ['°C', '℃'].includes(String(reportedUnit)) ? '℃'
: ['°F', '℉'].includes(String(reportedUnit)) ? '℉' : undefined
const modes = Array.isArray(attributes.hvac_modes)
? attributes.hvac_modes.filter((value): value is string => typeof value === 'string' && Object.prototype.hasOwnProperty.call(climateModeLabels, value)) : []
const fans = Array.isArray(attributes.fan_modes)
? attributes.fan_modes.filter((value): value is string => typeof value === 'string' && Boolean(value.trim())) : []
const features = numberValue(attributes.supported_features)
const supports = (flag: number) => features !== undefined && Number.isInteger(features) && features >= 0 && (features & flag) === flag
const available = device.moduleKey === 'climate' && device.online && entity?.available === true && modes.includes(entity.state)
const min = numberValue(attributes.min_temp), max = numberValue(attributes.max_temp), step = numberValue(attributes.target_temp_step)
const range = min !== undefined && max !== undefined && max > min && step !== undefined && step > 0 && step <= max - min
? { min, max, step } : undefined
const temperature = numberValue(attributes.temperature)
const targetTemperature = temperatureUnit && temperature !== undefined && range && temperature >= range.min && temperature <= range.max ? temperature : undefined
const currentTemperature = temperatureUnit ? numberValue(attributes.current_temperature) : undefined
const fanMode = typeof attributes.fan_mode === 'string' && fans.includes(attributes.fan_mode) ? attributes.fan_mode : undefined
const canToggle = Boolean(available && (entity?.state === 'off'
? supports(256) && modes.some(mode => mode !== 'off') : supports(128) && modes.includes('off')))
return { entity, modes: [...new Set(modes)], fans: [...new Set(fans)], supports, available, range,
targetTemperature, currentTemperature, temperatureUnit, fanMode, canToggle,
canSetTemperature: Boolean(available && temperatureUnit === '℃' && supports(1) && range && targetTemperature !== undefined),
canSetFan: Boolean(available && supports(8) && fans.length > 0) }
}
+14
View File
@@ -0,0 +1,14 @@
import { numberValue, type ParameterSnapshot } from '../parameterState'
export const defaults = { climateTemperature: undefined as number | undefined, climateMode: '', climateFanMode: '' }
export const labels = { climate_mode: '空调运行模式', climate_temperature: '空调目标温度', climate_fan_mode: '空调风速' }
export function sync(state: typeof defaults, controls: ParameterSnapshot) {
const temperature = controls.climate_temperature
if (temperature?.available) {
const value = numberValue(temperature.attributes?.temperature)
if (value !== undefined) state.climateTemperature = value
}
if (controls.climate_mode?.available && !['unknown', 'unavailable', ''].includes(controls.climate_mode.state))
state.climateMode = controls.climate_mode.state
const fan = controls.climate_fan_mode
if (fan?.available && typeof fan.attributes?.fan_mode === 'string') state.climateFanMode = fan.attributes.fan_mode
}
+9
View File
@@ -0,0 +1,9 @@
import { inject, type InjectionKey } from 'vue'
import type { useDeviceControl } from '../composables/useDeviceControl'
export type DeviceControlContext = ReturnType<typeof useDeviceControl>
export const deviceControlKey: InjectionKey<DeviceControlContext> = Symbol('device-control')
export function useDeviceControlContext(): DeviceControlContext {
const context = inject(deviceControlKey)
if (!context) throw new Error('设备控制组件缺少上下文')
return context
}
+16
View File
@@ -0,0 +1,16 @@
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { numberValue } from '../parameterState'
export type CurtainAction = 'open' | 'stop' | 'close'
export const curtainStateLabels: Record<string, string> = { open: '已打开', closed: '已关闭', opening: '正在打开', closing: '正在关闭' }
export function curtainCapabilities(device: HomeAssistantDiscoveredDevice) {
const entity = device.entities.find(item => item.entityId === device.primaryControlEntityId && item.domain === 'cover')
const features = numberValue(entity?.attributes?.supported_features)
const supports = (flag: number) => features !== undefined && Number.isInteger(features) && features >= 0 && (features & flag) === flag
const available = device.moduleKey === 'curtain' && device.online && entity?.available === true && Object.prototype.hasOwnProperty.call(curtainStateLabels, entity.state)
const value = numberValue(entity?.attributes?.current_position)
const position = value !== undefined && value >= 0 && value <= 100 ? value : undefined
const canAction = (action: CurtainAction) => Boolean(available && supports({ open: 1, close: 2, stop: 8 }[action]))
return { entity, supports, available, position, canAction,
canSetPosition: Boolean(available && supports(4) && position !== undefined) }
}
+10
View File
@@ -0,0 +1,10 @@
import { numberValue, type ParameterSnapshot } from '../parameterState'
export const defaults = { curtainPosition: undefined as number | undefined }
export const labels = { curtain_action: '窗帘开合', curtain_position: '窗帘开度' }
export function sync(state: typeof defaults, controls: ParameterSnapshot) {
const position = controls.curtain_position
if (position?.available) {
const value = numberValue(position.attributes?.current_position)
if (value !== undefined && value >= 0 && value <= 100) state.curtainPosition = value
}
}
+15
View File
@@ -0,0 +1,15 @@
import { boolValue, clamp, numberValue, type ParameterSnapshot } from '../parameterState'
export const defaults = { dehumidifierHumidity: 55, dehumidifierMode: '睡眠', dehumidifierFan: '1挡', dehumidifierUv: false }
export const labels = { humidity: '目标湿度', dehumidifier_mode: '除湿模式', fan_level: '风速档位', uv: 'UV 杀菌' }
export function sync(state: typeof defaults, controls: ParameterSnapshot) {
const humidifier = controls.humidity
if (humidifier?.available) {
const humidity = numberValue(humidifier.attributes?.humidity)
if (humidity !== undefined) state.dehumidifierHumidity = clamp(humidity, 40, 70)
if (typeof humidifier.attributes?.mode === 'string') state.dehumidifierMode = humidifier.attributes.mode
}
if (controls.fan_level?.available && ['1挡', '2挡', '3挡'].includes(controls.fan_level.state.replace('档', '挡')))
state.dehumidifierFan = controls.fan_level.state.replace('档', '挡')
const uv = boolValue(controls.uv)
if (uv !== undefined) state.dehumidifierUv = uv
}
+15
View File
@@ -0,0 +1,15 @@
import type { HomeAssistantDiscoveredDevice } from '../services/health'
const legacyCategories: Record<string, string> = {
EnvironmentControl: 'dehumidifier', Lighting: 'lighting', Media: 'audio',
Fragrance: 'fragrance', Sleep: 'mattress', EnvironmentSensor: 'environment',
}
/** 新接口未绑定模块时不借用另一台同类设备的参数;仅兼容旧接口没有 moduleKey 的情况。 */
export function deviceModuleKey(device: HomeAssistantDiscoveredDevice): string {
return device.moduleKey === undefined ? legacyCategories[device.category] ?? '' : device.moduleKey
}
export function deviceDetailLocation(deviceId: string) {
return { name: 'device-detail', params: { deviceId } }
}
+128
View File
@@ -0,0 +1,128 @@
import type { HomeAssistantDiscoveredDevice, HomeAssistantEntity, TcmMusicTransition, HomeAssistantLightTransition } from '../services/health'
import type { ParameterSnapshot } from './parameterState'
import { numberValue } from './parameterState'
import { deviceModuleKey } from './deviceKinds'
import { climateCapabilities, climateModeLabels } from './climate/capabilities'
import { curtainCapabilities, curtainStateLabels } from './curtain/capabilities'
import { outletCapabilities } from './outlet/capabilities'
export type DeviceIconKind = 'dehumidifier' | 'lighting' | 'audio' | 'fragrance' | 'mattress' | 'environment' | 'scale' | 'climate' | 'curtain' | 'outlet' | 'sleep-monitor' | 'other'
type SummaryInput = {
controls?: ParameterSnapshot
music?: TcmMusicTransition
light?: HomeAssistantLightTransition
}
export type DeviceSummary = {
icon: DeviceIconKind
categoryLabel: string
statusText: string
metricText: string
powerOn: boolean | null
canToggle: boolean
audioControl: boolean
}
type Presenter = (device: HomeAssistantDiscoveredDevice, input: SummaryInput, powerOn: boolean | null) => Pick<DeviceSummary, 'statusText' | 'metricText'>
const categoryIcons: Record<string, DeviceIconKind> = {
EnvironmentControl: 'dehumidifier', Lighting: 'lighting', Media: 'audio', Fragrance: 'fragrance',
Sleep: 'mattress', EnvironmentSensor: 'environment', BodyComposition: 'scale', Climate: 'climate',
Curtain: 'curtain', Outlet: 'outlet', SleepMonitoring: 'sleep-monitor',
}
const labels: Record<DeviceIconKind, string> = {
dehumidifier: '空气调节', lighting: '卧室灯光', audio: '助眠音箱', fragrance: '香氛助眠',
mattress: '智能床垫', environment: '环境监测', scale: '体脂秤', climate: '空调',
curtain: '窗帘', outlet: '智能插座', 'sleep-monitor': '睡眠监测', other: '智能设备',
}
export function primaryDeviceEntity(device: HomeAssistantDiscoveredDevice): HomeAssistantEntity | undefined {
return device.entities.find(entity => entity.entityId === device.primaryControlEntityId)
}
export function reportedPower(entity?: HomeAssistantEntity): boolean | null {
if (!entity?.available) return null
if (entity.state === 'off' || entity.state === 'idle' || entity.state === 'standby' || entity.state === 'paused') return false
if (entity.state === 'on' || entity.state === 'playing') return true
if (entity.domain === 'climate' && ['heat', 'cool', 'auto', 'heat_cool', 'dry', 'fan_only'].includes(entity.state)) return true
return null
}
function metric(device: HomeAssistantDiscoveredDevice, key: string): number | undefined {
return numberValue(device.metrics?.[key])
}
function formatted(value: number | undefined, unit: string): string {
return value === undefined ? '' : `${Math.round(value * 10) / 10}${unit}`
}
function ownState(device: HomeAssistantDiscoveredDevice, input: SummaryInput, parameter: string): string | undefined {
const state = input.controls?.[parameter]
return state?.available && !['unknown', 'unavailable', ''].includes(state.state) &&
device.entities.some(entity => entity.entityId === state.entityId) ? state.state : undefined
}
function powerText(power: boolean | null, running = '运行中'): string {
return power === null ? '状态未确认' : power ? running : '已关闭'
}
const presenters: Record<string, Presenter> = {
outlet(device) {
const capability = outletCapabilities(device)
return { statusText: capability.available ? capability.powerOn ? '已通电' : '已断电' : '插座主开关不可用',
metricText: capability.energy === undefined ? '单独控制电源' : `累计用电 ${capability.energy}${capability.energyEntity!.unit}` }
},
lighting(device, input, power) {
const brightness = numberValue(primaryDeviceEntity(device)?.attributes?.brightness)
return {
statusText: input.light?.running ? input.light.mode === 'wake' ? '晨起唤醒中' : '助眠渐暗中' : powerText(power, '已点亮'),
metricText: input.light?.running ? `亮度 ${input.light.brightnessPercent}%`
: power === true && brightness !== undefined ? `亮度 ${Math.round(brightness / 255 * 100)}%` : '亮度 · 色温 · 渐变',
}
},
dehumidifier(device, _input, power) {
const humidity = numberValue(primaryDeviceEntity(device)?.attributes?.current_humidity) ?? metric(device, 'humidity')
return { statusText: powerText(power, '除湿中'), metricText: humidity === undefined ? '恒湿 · 风速 · UV' : `当前湿度 ${formatted(humidity, '%')}` }
},
audio(device, input, power) {
const volume = input.music?.hasVolume ? input.music.volumePercent : numberValue(primaryDeviceEntity(device)?.attributes?.volume_level)
const percent = input.music?.hasVolume ? volume : volume === undefined ? undefined : Math.round(volume * 100)
return { statusText: input.music?.running ? '助眠播放中' : powerText(power, '播放中'), metricText: percent === undefined ? '播放 · 音量' : `音量 ${formatted(percent, '%')}` }
},
fragrance(_device, _input, power) {
return { statusText: powerText(power, '香薰已开启'), metricText: '香薰与夜灯独立控制' }
},
mattress(device, input) {
const right = ownState(device, input, 'mattress_massage_right_part')
const left = ownState(device, input, 'mattress_massage_left_part')
return { statusText: right && right !== '无' ? '右侧按摩中' : left && left !== '无' ? '左侧按摩中' : '已连接',
metricText: '按摩 · 加热 · 承托' }
},
environment(device) {
const values = [formatted(metric(device, 'temperature'), '℃'), formatted(metric(device, 'humidity'), '%')].filter(Boolean)
return { statusText: '正在监测', metricText: values.join(' · ') || '查看环境指标' }
},
climate(device) {
const capability = climateCapabilities(device)
if (!capability.available) return { statusText: '空调主控不可用', metricText: '查看详情与连接状态' }
const values = [capability.currentTemperature !== undefined ? `室温 ${formatted(capability.currentTemperature, capability.temperatureUnit!)}` : '',
capability.targetTemperature !== undefined ? `目标 ${formatted(capability.targetTemperature, capability.temperatureUnit!)}` : ''].filter(Boolean)
return { statusText: climateModeLabels[capability.entity!.state], metricText: values.join(' · ') || '模式 · 温度 · 风速' }
},
curtain(device) {
const capability = curtainCapabilities(device)
return { statusText: capability.available ? curtainStateLabels[capability.entity!.state] : '窗帘主控不可用',
metricText: capability.available && capability.position !== undefined ? `开度 ${formatted(capability.position, '%')}` : '打开 · 停止 · 关闭' }
},
}
/** 总览只呈现少量真实读数,完整可控参数交给专属详情卡片。 */
export function deviceSummary(device: HomeAssistantDiscoveredDevice, input: SummaryInput = {}): DeviceSummary {
const key = deviceModuleKey(device)
const icon = categoryIcons[device.category] ?? 'other'
const target = primaryDeviceEntity(device)
const audioControl = key === 'audio'
const outletControl = key === 'outlet' || device.category === 'Outlet'
const audioRunning = audioControl && input.music?.running === true
const powerOn = outletControl ? outletCapabilities(device).powerOn : audioRunning ? true : reportedPower(target)
const canToggle = outletControl ? outletCapabilities(device).available
: target?.domain === 'climate' ? key === 'climate' && climateCapabilities(device).canToggle
: Boolean(target && (device.online || audioRunning) &&
['switch', 'humidifier', 'light', 'media_player'].includes(target.domain) && powerOn !== null)
let presentation = { statusText: powerOn === null ? '已连接' : powerText(powerOn), metricText: '点击查看设备详情' }
if (device.online || audioRunning) presentation = presenters[key]?.(device, input, powerOn) ?? presentation
if (!device.online && !audioRunning) presentation = { statusText: '设备离线', metricText: '查看详情与连接状态' }
return { icon, categoryLabel: labels[icon], ...presentation, powerOn, canToggle, audioControl }
}
+17
View File
@@ -0,0 +1,17 @@
import { boolValue, clamp, numberValue, type ParameterSnapshot } from '../parameterState'
export const defaults = { fragranceDuration: 2, fragranceInterval: 20, fragranceLight: false,
fragranceAutoRelease: false, fragranceAutoLight: false, fragranceLightAutoOff: false }
export const labels = { fragrance_duration: '出香时长', fragrance_interval: '出香间隔', fragrance_light: '香薰氛围灯',
fragrance_quick_release: '快速出香', fragrance_auto_release: '人来增香', fragrance_auto_light: '感应夜灯',
fragrance_light_auto_off: '氛围灯自动关闭' }
export function sync(state: typeof defaults, controls: ParameterSnapshot) {
const duration = numberValue(controls.fragrance_duration?.state)
if (controls.fragrance_duration?.available && duration !== undefined) state.fragranceDuration = clamp(duration, 2, 6)
const interval = numberValue(controls.fragrance_interval?.state)
if (controls.fragrance_interval?.available && interval !== undefined) state.fragranceInterval = clamp(interval, 10, 20)
for (const [parameter, property] of [['fragrance_light', 'fragranceLight'], ['fragrance_auto_release', 'fragranceAutoRelease'],
['fragrance_auto_light', 'fragranceAutoLight'], ['fragrance_light_auto_off', 'fragranceLightAutoOff']] as const) {
const value = boolValue(controls[parameter])
if (value !== undefined) state[property] = value
}
}
+13
View File
@@ -0,0 +1,13 @@
import { clamp, numberValue, type ParameterSnapshot } from '../parameterState'
export const defaults = { lightBrightness: 50, lightColorTemp: 2700 }
export const labels = { brightness: '灯光亮度', color_temp: '灯光色温' }
export function sync(state: typeof defaults, controls: ParameterSnapshot) {
const brightness = controls.brightness
if (brightness?.available) {
const value = numberValue(brightness.attributes?.brightness)
if (brightness.state === 'off') state.lightBrightness = 0
else if (value !== undefined) state.lightBrightness = clamp(Math.round(value / 255 * 100), 0, 100)
}
const kelvin = numberValue(controls.color_temp?.attributes?.color_temp_kelvin)
if (controls.color_temp?.available && kelvin !== undefined) state.lightColorTemp = clamp(kelvin, 2200, 5000)
}
+36
View File
@@ -0,0 +1,36 @@
import { clamp, numberValue, type ParameterSnapshot } from '../parameterState'
export const mattressFirmnessOptions = ['最软', '很软', '稍软', '稍硬', '很硬', '最硬']
export const mattressModeOptions = ['无', '瑜伽助眠', '智能适配', '智能腰托']
export const massagePartOptions = ['无', '背部', '腰部', '牵引', '腿部', '全身']
export const mattressFirmnessZones = [
{ key: 'left_shoulder', label: '左肩' }, { key: 'right_shoulder', label: '右肩' },
{ key: 'left_back', label: '左背' }, { key: 'right_back', label: '右背' },
{ key: 'left_waist', label: '左腰' }, { key: 'right_waist', label: '右腰' },
{ key: 'left_lumbar', label: '左腰椎' }, { key: 'right_lumbar', label: '右腰椎' },
{ key: 'left_hip', label: '左臀' }, { key: 'right_hip', label: '右臀' },
{ key: 'left_leg', label: '左腿' }, { key: 'right_leg', label: '右腿' }
]
export const defaults = { mattressLeftHeating: 0, mattressRightHeating: 0, mattressLeftMode: '智能适配',
mattressRightMode: '智能适配', mattressFirmness: {} as Record<string, string>, mattressMassageLeftPart: '无',
mattressMassageRightPart: '无', mattressMassageLevel: '轻柔' }
export const labels = { mattress_left_heating: '床垫左侧加热', mattress_right_heating: '床垫右侧加热',
mattress_left_mode: '床垫左侧承托模式', mattress_right_mode: '床垫右侧承托模式',
mattress_massage_left_part: '左侧按摩部位', mattress_massage_right_part: '右侧按摩部位', mattress_massage_level: '按摩力度' }
export function sync(state: typeof defaults, controls: ParameterSnapshot) {
for (const [parameter, property] of [['mattress_left_heating', 'mattressLeftHeating'],
['mattress_right_heating', 'mattressRightHeating']] as const) {
const value = numberValue(controls[parameter]?.state)
if (controls[parameter]?.available && value !== undefined) state[property] = clamp(value, 0, 4)
}
for (const zone of mattressFirmnessZones) {
const control = controls['mattress_firmness_' + zone.key]
if (control?.available && mattressFirmnessOptions.includes(control.state)) state.mattressFirmness[zone.key] = control.state
}
for (const [parameter, property] of [['mattress_massage_left_part', 'mattressMassageLeftPart'],
['mattress_massage_right_part', 'mattressMassageRightPart']] as const) {
const control = controls[parameter]
if (control?.available && massagePartOptions.includes(control.state)) state[property] = control.state
}
const level = controls.mattress_massage_level
if (level?.available && ['轻柔', '强劲'].includes(level.state)) state.mattressMassageLevel = level.state
}
@@ -0,0 +1,27 @@
import { showConfirmDialog } from 'vant'
import { onUnmounted, type ComputedRef } from 'vue'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
export function useMattressControls(device: ComputedRef<HomeAssistantDiscoveredDevice | undefined>,
applyParam: (deviceId: string, parameter: string, value: unknown) => Promise<boolean>,
loadStatus: () => Promise<void>, commitMode: (side: 'left' | 'right', mode: string) => void) {
let active = true
onUnmounted(() => { active = false })
async function setMattressMode(side: 'left' | 'right', mode: string) {
if (!active || !device.value) return
if (await applyParam(device.value.deviceId, 'mattress_' + side + '_mode', mode) && active) commitMode(side, mode)
}
async function setMattressSelect(parameterType: string, event: Event) {
if (!active || !device.value) return
const deviceId = device.value.deviceId
const value = (event.target as HTMLSelectElement).value
if (parameterType.startsWith('mattress_massage_') && value !== '无') {
try { await showConfirmDialog({ title: '确认启动床垫按摩',
message: '按摩或牵引可能让床垫立即动作,请确认设备周围安全后继续。' }) }
catch { if (active) await loadStatus(); return }
}
// 确认框等待期间可能离开页面或设备被移除,不能向新的床垫误发命令。
if (active && device.value?.deviceId === deviceId) await applyParam(deviceId, parameterType, value)
}
return { setMattressMode, setMattressSelect }
}
@@ -0,0 +1,47 @@
import { ref, type Ref } from 'vue'
import type { DeviceParameterState } from '../parameterRegistry'
import type { ParameterSnapshot } from '../parameterState'
import { mattressModeOptions } from './parameters'
export function useMattressPreferences(localParams: DeviceParameterState,
currentUserId: Ref<number | undefined>, getDeviceId: () => string | undefined) {
const mattressActiveSide = ref<'left' | 'right' | null>(null)
function storageKey() {
const deviceId = getDeviceId()
return currentUserId.value && deviceId ? 'evergreen-mattress-modes-' + currentUserId.value + '-' + deviceId : undefined
}
function readPreference(): { left: string; right: string } | undefined {
const key = storageKey()
if (!key) return undefined
try {
const saved = JSON.parse(localStorage.getItem(key) || 'null')
return mattressModeOptions.includes(saved?.left) && mattressModeOptions.includes(saved?.right) ? saved : undefined
} catch { return undefined }
}
function savePreference() {
const key = storageKey()
if (!key) return
try { localStorage.setItem(key, JSON.stringify({ left: localParams.mattressLeftMode, right: localParams.mattressRightMode })) }
catch { /* 禁止存储时仅保留本次页面预选。 */ }
}
function syncModes(controls: ParameterSnapshot) {
const left = controls.mattress_left_mode?.available ? controls.mattress_left_mode.state : undefined
const right = controls.mattress_right_mode?.available ? controls.mattress_right_mode.state : undefined
const leftRunning = Boolean(left && mattressModeOptions.includes(left) && left !== '无')
const rightRunning = Boolean(right && mattressModeOptions.includes(right) && right !== '无')
// 双侧云缓存尚未同步时保留已确认的一侧,不把两侧预选当作两侧物理运行。
mattressActiveSide.value = leftRunning && rightRunning ? mattressActiveSide.value
: rightRunning ? 'right' : leftRunning ? 'left' : null
const preference = readPreference()
localParams.mattressLeftMode = mattressActiveSide.value === 'left' ? left! : preference?.left ?? '智能适配'
localParams.mattressRightMode = mattressActiveSide.value === 'right' ? right! : preference?.right ?? '智能适配'
}
function commitMode(side: 'left' | 'right', mode: string) {
if (side === 'left') localParams.mattressLeftMode = mode
else localParams.mattressRightMode = mode
if (mode !== '无') mattressActiveSide.value = side
else if (mattressActiveSide.value === side) mattressActiveSide.value = null
savePreference()
}
return { mattressActiveSide, syncModes, commitMode }
}
+15
View File
@@ -0,0 +1,15 @@
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { numberValue } from '../parameterState'
/** 主开关由后端明确绑定,不从同设备的多个 switch 中猜测;电计量只读标准能耗传感器。 */
export function outletCapabilities(device: HomeAssistantDiscoveredDevice) {
const entity = device.entities.find(item => item.entityId === device.primaryControlEntityId && item.domain === 'switch')
const available = device.moduleKey === 'outlet' && device.online && entity?.available === true &&
(entity.state === 'on' || entity.state === 'off')
const powerOn = available ? entity!.state === 'on' : null
const energyEntity = device.entities.find(item => item.domain === 'sensor' && item.attributes?.device_class === 'energy' &&
(item.unit === 'kWh' || item.unit === 'Wh'))
const value = device.online && energyEntity?.available ? numberValue(energyEntity.state) : undefined
const energy = value !== undefined && value >= 0 ? value : undefined
return { available: Boolean(available), powerOn, energyEntity, energy }
}
+24
View File
@@ -0,0 +1,24 @@
import { reactive } from 'vue'
import * as dehumidifier from './dehumidifier/parameters'
import * as lighting from './lighting/parameters'
import * as audio from './audio/parameters'
import * as fragrance from './fragrance/parameters'
import * as mattress from './mattress/parameters'
import * as climate from './climate/parameters'
import * as curtain from './curtain/parameters'
import type { ParameterSnapshot } from './parameterState'
export const parameterLabels: Record<string, string> = {
...dehumidifier.labels, ...lighting.labels, ...audio.labels, ...fragrance.labels, ...mattress.labels,
...climate.labels, ...curtain.labels
}
export function createDeviceParameterState() {
return reactive({ ...dehumidifier.defaults, ...lighting.defaults, ...audio.defaults,
...fragrance.defaults, ...mattress.defaults, ...climate.defaults, ...curtain.defaults, mattressFirmness: {} as Record<string, string> })
}
export type DeviceParameterState = ReturnType<typeof createDeviceParameterState>
export function syncDeviceParameters(state: DeviceParameterState, controls: ParameterSnapshot) {
dehumidifier.sync(state, controls); lighting.sync(state, controls); audio.sync(state, controls)
fragrance.sync(state, controls); mattress.sync(state, controls)
climate.sync(state, controls); curtain.sync(state, controls)
}
+33
View File
@@ -0,0 +1,33 @@
import type { HomeAssistantDiscoveredDevice, HomeAssistantParameterState } from '../services/health'
export type ParameterSnapshot = Record<string, HomeAssistantParameterState>
export function parameterIsAvailable(controls: ParameterSnapshot | undefined, parameter: string, device?: HomeAssistantDiscoveredDevice): boolean {
const reading = controls?.[parameter]
if (reading?.available !== true) return false
return !device || (device.online && device.entities.some(entity => entity.entityId === reading.entityId && entity.available))
}
export function parameterHasReading(controls: ParameterSnapshot | undefined, parameter: string, device?: HomeAssistantDiscoveredDevice): boolean {
if (!parameterIsAvailable(controls, parameter, device)) return false
const reading = controls![parameter]
if (['unknown', 'unavailable', ''].includes(reading.state)) return false
const numericAttribute: Record<string, string> = {
brightness: 'brightness', color_temp: 'color_temp_kelvin', humidity: 'humidity', volume: 'volume_level',
climate_temperature: 'temperature', curtain_position: 'current_position',
}
if (parameter === 'brightness' && reading.state === 'off') return true
if (numericAttribute[parameter]) return numberValue(reading.attributes?.[numericAttribute[parameter]]) !== undefined
if (parameter === 'dehumidifier_mode') return typeof reading.attributes?.mode === 'string' && Boolean(reading.attributes.mode)
if (parameter === 'climate_fan_mode') return typeof reading.attributes?.fan_mode === 'string' && Boolean(reading.attributes.fan_mode)
return true
}
export const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
export function numberValue(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value !== 'string' || !value.trim()) return undefined
const number = Number(value)
return Number.isFinite(number) ? number : undefined
}
export function boolValue(state: HomeAssistantParameterState | undefined): boolean | undefined {
if (!state?.available) return undefined
return state.state === 'on' ? true : state.state === 'off' ? false : undefined
}
+13
View File
@@ -0,0 +1,13 @@
import type { HomeAssistantDiscoveredDevice } from '../services/health'
import { useDeviceControlContext } from './controlContext'
/** 专属详情必须检查当前设备的参数实体,不能用在线设备数或默认值冒充读数。 */
export function useDeviceCardState(device: () => HomeAssistantDiscoveredDevice) {
const context = useDeviceControlContext()
const available = (parameter: string) => context.isParameterAvailable(parameter, device())
const readable = (parameter: string) => context.hasParameterReading(parameter, device())
const display = (parameter: string, value: unknown, suffix = '') => readable(parameter) ? `${value}${suffix}` : '--'
const powerAvailable = () => context.isDeviceControlAvailable(device())
const powerLabel = (on: string, off: string) => powerAvailable() ? context.isDeviceTurnedOn(device()) ? on : off : '暂不可用'
return { available, readable, display, powerAvailable, powerLabel }
}
+1
View File
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import 'vant/lib/index.css'
import './styles.css'
import './styles/page-polish.css'
import App from './App.vue'
import router from './router'
+8 -1
View File
@@ -5,10 +5,13 @@ import AssessmentView from './views/AssessmentView.vue'
import TcmAssessmentView from './views/TcmAssessmentView.vue'
import ProfileView from './views/ProfileView.vue'
import DeviceControlView from './views/DeviceControlView.vue'
import DeviceControlLayout from './views/DeviceControlLayout.vue'
import DeviceDetailView from './views/DeviceDetailView.vue'
import { hasToken } from './services/auth'
const router = createRouter({
history: createWebHistory(),
scrollBehavior: (_to, _from, savedPosition) => savedPosition ?? { left: 0, top: 0 },
routes: [
{ path: '/', redirect: '/dashboard' },
{ path: '/auth', component: AuthView, meta: { guest: true } },
@@ -17,7 +20,11 @@ const router = createRouter({
{ path: '/assessment', component: AssessmentView },
{ path: '/tcm-assessment', component: TcmAssessmentView },
{ path: '/profile', component: ProfileView },
{ path: '/device-control', component: DeviceControlView }
{ path: '/device-control', component: DeviceControlLayout, children: [
{ path: '', name: 'device-overview', component: DeviceControlView },
{ path: ':deviceId', name: 'device-detail', component: DeviceDetailView },
] },
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }
]
})
+42
View File
@@ -0,0 +1,42 @@
import type { HealthAssessmentAnswers } from './health'
export function finiteNumberInRange(value: unknown, minimum: number, maximum: number, integer = false): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= minimum && value <= maximum && (!integer || Number.isInteger(value))
}
// Birthdays are calendar dates, not instants. Keep the displayed day unchanged
// even when the API serializes a DateTime or the browser has a UTC offset.
// Admin.NET returns yyyy-MM-dd HH:mm:ss; also accept date-only and ISO timestamps.
export function ageFromBirthday(value: unknown, today = new Date()): number | undefined {
if (typeof value !== 'string') return undefined
const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)?$/.exec(value)
if (!match) return undefined
const [, yearText, monthText, dayText] = match
const year = Number(yearText), month = Number(monthText), day = Number(dayText)
const birthday = new Date(year, month - 1, day)
if (birthday.getFullYear() !== year || birthday.getMonth() !== month - 1 || birthday.getDate() !== day) return undefined
const currentDay = new Date(today.getFullYear(), today.getMonth(), today.getDate())
if (birthday > currentDay) return undefined
const beforeBirthday = today.getMonth() < month - 1 || (today.getMonth() === month - 1 && today.getDate() < day)
return today.getFullYear() - year - (beforeBirthday ? 1 : 0)
}
const healthAnswerOptions: Record<keyof HealthAssessmentAnswers, readonly string[]> = {
sleepDuration: ['<6', '6-7', '7-8', '>8'],
fallingAsleepSpeed: ['hard', 'normal', 'fast'],
temperaturePreference: ['heat_fear', 'cold_fear', 'normal'],
nightAwakening: ['frequent', 'light', 'none'],
mattressPreference: ['firm', 'medium', 'soft'],
exerciseFrequency: ['none', '1-2', '3-5', 'daily'],
muscleFatigueArea: ['back_neck', 'waist_back', 'legs', 'none'],
activeTimeOfDay: ['morning', 'evening', 'night', 'irregular'],
mealRegularity: ['regular', 'skip_breakfast', 'irregular'],
flavorPreference: ['light', 'moderate', 'heavy'],
lateNightEating: ['often', 'sometimes', 'rarely'],
dailyWaterIntake: ['abundant', 'moderate', 'scarce']
}
export function healthAnswersComplete(answers: HealthAssessmentAnswers): boolean {
return (Object.keys(healthAnswerOptions) as (keyof HealthAssessmentAnswers)[])
.every(key => healthAnswerOptions[key].includes(answers[key]))
}
+19
View File
@@ -111,6 +111,20 @@ export type MattressComfort = {
weatherObservedAt?: string | null
weatherReason: string
firmnessReason: string
indoorTemperatureCelsius?: number | null
indoorTemperatureAvailable: boolean
indoorTemperatureObservedAt?: string | null
heatingReason: string
climateAdvice?: ClimateAdvice
}
export type ClimateAdvice = {
mode: 'unavailable' | 'off' | 'heat' | 'cool'
recommendedMinimumCelsius: number
recommendedMaximumCelsius: number
targetTemperatureCelsius?: number | null
pendingIntegration: boolean
reason: string
}
export type TcmAssessmentInput = {
@@ -143,6 +157,9 @@ export type HealthDeviceCategory =
| 'Lighting'
| 'Media'
| 'Other'
| 'Climate'
| 'Curtain'
| 'SleepMonitoring'
export type HomeAssistantEntity = {
entityId: string
@@ -155,6 +172,7 @@ export type HomeAssistantEntity = {
}
export type HomeAssistantDiscoveredDevice = {
moduleKey?: string
deviceId: string
name: string
manufacturer: string
@@ -253,6 +271,7 @@ export type HomeAssistantSceneAction = {
}
export type HomeAssistantSceneResult = {
climateAdvice?: ClimateAdvice | null
sceneCode: string
sceneName: string
success: boolean
+23 -1
View File
@@ -2,12 +2,26 @@ import axios from 'axios'
import { clearTokens, getToken } from './auth'
type UnifiedResult<T> = {
code?: number
type?: string
message?: string
result?: T
errors?: unknown
}
let redirectingToLogin = false
function handleUnauthorized(requestAuthorization?: unknown) {
// A response from the previous session must not clear a newly signed-in session.
const currentToken = getToken()
if (currentToken && requestAuthorization !== `Bearer ${currentToken}`) return
clearTokens()
if (typeof window !== 'undefined' && window.location.pathname !== '/auth' && !redirectingToLogin) {
redirectingToLogin = true
window.location.replace('/auth')
}
}
const http = axios.create({
baseURL: '/',
timeout: 15000
@@ -22,6 +36,12 @@ http.interceptors.request.use((config) => {
http.interceptors.response.use(
((response: any) => {
const body = response.data as UnifiedResult<unknown>
if (body && typeof body === 'object') {
if (body.code === 401) {
handleUnauthorized(response.config?.headers?.Authorization)
return Promise.reject(new Error(body.message || '登录已过期,请重新登录'))
}
}
if (body && typeof body === 'object' && 'result' in body) {
if (body.type && body.type !== 'success') {
return Promise.reject(new Error(body.message || '请求失败'))
@@ -31,7 +51,9 @@ http.interceptors.response.use(
return body
}) as any,
(error) => {
if (error.response?.status === 401) clearTokens()
if (error.response?.status === 401) {
handleUnauthorized(error.config?.headers?.Authorization ?? error.response?.config?.headers?.Authorization)
}
const body = error.response?.data as UnifiedResult<unknown> | undefined
const message = body?.message || error.message || '网络请求失败'
return Promise.reject(new Error(message))
+8 -8
View File
@@ -790,20 +790,20 @@ button { cursor: pointer; }
}
.dashboard-page {
padding: 16px 14px 84px !important;
padding: 20px var(--page-gutter, 16px) calc(88px + env(safe-area-inset-bottom, 0px));
}
.dashboard-header {
margin: 0 auto 12px !important;
padding: 4px 4px 0 !important;
margin: 0 auto 20px;
padding: 4px 4px 0;
}
.dashboard-header h1 {
margin: 2px 0 3px !important;
font-size: 26px !important;
margin: 2px 0 6px;
font-size: 24px;
}
.dashboard-header > div > p:last-child {
margin: 0 !important;
font-size: 13px !important;
line-height: 1.4 !important;
margin: 0;
font-size: 12px;
line-height: 1.5;
}
/* 评估未填提醒卡片 */
+583
View File
@@ -0,0 +1,583 @@
.control-page {
background: #f2f0e9;
min-height: 100vh;
padding-bottom: 40px;
overflow-x: hidden;
}
.control-page .top-nav {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(18, 63, 55, 0.08);
}
.control-page .back-btn {
display: flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: #0c5045;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.control-page .top-nav h2 {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #153e37;
}
.control-page .refresh-btn {
border: 0;
background: transparent;
color: #0c5045;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.control-page .refresh-btn:disabled,
.control-page .text-refresh:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.control-page .control-content {
padding: 14px;
}
/* 汇总卡片 */
.control-page .summary-box {
background: linear-gradient(135deg, #0c5045, #14443c);
color: #fff;
border-radius: 20px;
padding: 16px;
margin-bottom: 14px;
box-shadow: 0 6px 20px rgba(12, 80, 69, 0.12);
}
.control-page .summary-info {
display: flex;
justify-content: space-around;
padding-bottom: 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
}
.control-page .stat-item {
text-align: center;
}
.control-page .stat-item .num {
display: block;
font-size: 22px;
font-weight: 700;
font-family: serif;
}
.control-page .stat-item .num.online {
color: #92eec4;
}
.control-page .stat-item .label {
font-size: 11px;
color: rgba(255, 255, 255, 0.75);
}
.control-page .scene-actions-grid {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 14px;
}
.control-page .scene-btn {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
cursor: pointer;
text-align: left;
transition: all 0.2s;
}
.control-page .scene-btn:disabled {
opacity: 0.62;
cursor: wait;
}
.control-page .sleep-btn {
background: rgba(214, 166, 87, 0.2);
color: #fff;
}
.control-page .sleep-btn:active {
background: rgba(214, 166, 87, 0.35);
}
.control-page .off-btn {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
}
.control-page .s-icon {
font-size: 18px;
}
.control-page .s-text strong {
display: block;
font-size: 13px;
font-weight: 700;
}
.control-page .s-text small {
display: block;
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
margin-top: 2px;
}
.control-page .operation-status {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 38px;
padding: 9px 12px;
margin: -4px 2px 14px;
border: 1px solid rgba(12, 80, 69, 0.12);
border-radius: 12px;
background: rgba(255, 255, 255, 0.72);
color: #536a62;
font-size: 11px;
}
.control-page .operation-status.success {
border-color: rgba(12, 80, 69, 0.22);
background: #f0f8f5;
color: #0c5045;
}
.control-page .operation-status.fail {
border-color: rgba(176, 72, 56, 0.24);
background: #fff4f1;
color: #9f3f32;
}
.control-page .operation-message {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 7px;
font-weight: 600;
}
.control-page .operation-status small {
flex: none;
color: #86968f;
font-size: 10px;
}
.control-page .operation-result {
display: inline-grid;
place-items: center;
width: 17px;
height: 17px;
border-radius: 50%;
background: currentColor;
color: #fff;
font-size: 11px;
font-style: normal;
}
.control-page .operation-spinner {
width: 15px;
height: 15px;
border: 2px solid rgba(12, 80, 69, 0.18);
border-top-color: #0c5045;
border-radius: 50%;
animation: operation-spin 0.75s linear infinite;
}
@keyframes operation-spin {
to { transform: rotate(360deg); }
}
/* 设备卡片 */
.control-page .device-card {
background: #ffffff;
border-radius: 20px;
padding: 16px;
margin-bottom: 14px;
border: 1px solid rgba(18, 63, 55, 0.06);
box-shadow: 0 4px 16px rgba(18, 63, 55, 0.04);
}
.control-page .device-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(18, 63, 55, 0.06);
}
.control-page .speaker-actions {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.control-page .category-tag {
font-size: 10px;
color: #0c5045;
background: #eef6f3;
padding: 2px 6px;
border-radius: 4px;
font-weight: 700;
}
.control-page .dev-info h3 {
margin: 4px 0 2px;
font-size: 15px;
color: #173d36;
font-weight: 700;
}
.control-page .status-dot {
font-size: 11px;
color: #8c9e97;
}
.control-page .status-dot.online {
color: #0c5045;
font-weight: 600;
}
.control-page .text-refresh {
border: 0;
background: transparent;
color: #0c5045;
font-size: 11px;
font-weight: 600;
}
.control-page .params-body {
display: flex;
flex-direction: column;
gap: 18px;
}
.control-page .param-row {
display: flex;
flex-direction: column;
position: relative;
}
.control-page .param-row.inline-row {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.control-page .param-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.control-page .p-title {
font-size: 13px;
color: #556c64;
font-weight: 600;
line-height: 1.2;
}
.control-page .p-val {
font-size: 14px;
color: #0c5045;
font-weight: 700;
line-height: 1.2;
}
.control-page .p-tip {
font-size: 11px;
color: #8fa099;
}
.control-page .mattress-mode-note {
margin: 0;
padding: 9px 11px;
border-radius: 9px;
background: #fff7e8;
color: #8a6428;
font-size: 11px;
line-height: 1.55;
}
.control-page .mattress-recommendation {
padding: 12px;
border: 1px solid #cde3dc;
border-radius: 12px;
background: #f3faf7;
color: #174d40;
font-size: 12px;
line-height: 1.55;
}
.control-page .mattress-recommendation strong { font-size: 13px; }
.control-page .mattress-recommendation p { margin: 7px 0 0; }
.control-page .mattress-recommendation small { display: block; margin-top: 6px; color: #6a8479; }
.control-page .mattress-advanced { border-top: 1px solid #e8efea; padding-top: 12px; }
.control-page .mattress-advanced summary { cursor: pointer; color: #0c5045; font-weight: 700; font-size: 14px; }
.control-page .mattress-advanced .mattress-mode-note { margin: 12px 0; }
.control-page .mattress-select-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 0; border-bottom: 1px solid #edf1ee; }
.control-page .mattress-select-row label { color: #556c64; font-size: 12px; }
.control-page .mattress-select-row select { width: 48%; min-height: 36px; border: 1px solid #d9e6de; border-radius: 9px; background: #fff; padding: 5px 8px; color: #174d40; font-size: 12px; }
.control-page .light-mode-panel {
padding: 12px;
border-radius: 12px;
background: #fff8ec;
border: 1px solid #f2e3c7;
}
.control-page .light-mode-panel strong,
.control-page .light-mode-panel small {
display: block;
}
.control-page .light-mode-panel strong {
color: #684b24;
font-size: 13px;
}
.control-page .light-mode-panel small {
margin-top: 4px;
color: #8b7759;
font-size: 11px;
line-height: 1.5;
}
.control-page .light-mode-actions {
display: flex;
gap: 8px;
margin-top: 11px;
}
.control-page .light-mode-actions button {
flex: 1;
padding: 8px 10px;
border: 1px solid #dab989;
border-radius: 9px;
background: #fff;
color: #795320;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.control-page .light-mode-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 滑块样式:左右各预留 10px 间距,使滑块在 0% 和 100% 端点时,外凸的圆形按钮完整居中且不被裁剪 */
.control-page .param-row .van-slider {
margin: 10px 10px 14px 10px;
height: 4px;
border-radius: 2px;
background-color: #e6eae8;
box-sizing: border-box;
}
.control-page .param-row .van-slider__bar {
height: 4px;
border-radius: 2px;
}
.control-page .param-row .van-slider__button-wrapper {
z-index: 2;
}
.control-page .param-row .van-slider__button {
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #ffffff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.16);
border: 1px solid rgba(0, 0, 0, 0.05);
}
.control-page .slider-scale {
display: flex;
justify-content: space-between;
font-size: 10px;
color: #8d9e97;
padding: 0 2px;
}
.control-page .btn-group {
display: flex;
gap: 8px;
}
.control-page .btn-group button {
flex: 1;
padding: 8px 4px;
background: #f7f6f2;
border: 1px solid rgba(18, 63, 55, 0.1);
border-radius: 8px;
font-size: 12px;
color: #4b5e57;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.control-page .btn-group button.active {
background: #0c5045;
color: #fff;
border-color: #0c5045;
font-weight: 700;
}
.control-page .mattress-mode-buttons button.active:not(.running) {
background: #e4f1ec;
color: #0c5045;
border-color: #87b9a7;
}
.control-page .btn-group button:disabled,
.control-page .mini-toggle:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.control-page .mini-toggle {
padding: 6px 14px;
border-radius: 14px;
border: 1px solid rgba(18, 63, 55, 0.15);
background: #f4f3ef;
color: #6a7c76;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.control-page .mini-toggle.active {
background: #0c5045;
color: #fff;
border-color: #0c5045;
}
/* 传感器网格 */
.control-page .sensor-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.control-page .sensor-box {
background: #f8f7f2;
border-radius: 12px;
padding: 10px 8px;
text-align: center;
}
.control-page .s-label {
display: block;
font-size: 11px;
color: #798a83;
margin-bottom: 4px;
}
.control-page .sensor-box strong {
font-size: 14px;
color: #173d36;
}
.control-page .sensor-box small {
font-size: 10px;
color: #8c9e97;
}
.control-page .scale-note {
margin: 0;
font-size: 12px;
color: #72847d;
line-height: 1.6;
}
.control-page .tcm-guide-button {
width: 100%;
margin-top: 10px;
border: 1px solid rgba(255, 255, 255, .35);
border-radius: 12px;
background: rgba(255, 255, 255, .12);
color: #fff;
min-height: 40px;
font-size: 12px;
font-weight: 700;
}
/* 总览只放简洁设备卡片;完整参数继续复用独立详情卡片。 */
.control-page .climate-readings { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 12px; }
.control-page .device-mode-buttons { flex-wrap: wrap; margin-top: 10px; }
.control-page .device-mode-buttons button { min-height: 40px; min-width: 58px; }
.control-page .curtain-actions button { flex: 1; }
.control-page .top-nav h2 { min-width: 0; margin: 0 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.control-page .back-btn, .control-page .refresh-btn { flex-shrink: 0; }
.control-page .device-list-heading { display: flex; align-items: center; justify-content: space-between; padding: 4px 2px 0; }
.control-page .device-list-heading h3 { margin: 0; color: #173d36; font-size: 18px; }
.control-page .device-list-heading > span { color: #86948b; font-size: 12px; }
.control-page .device-list-hint { margin: 7px 2px 14px; color: #84948b; font-size: 12px; line-height: 1.6; }
.control-page .device-room-tabs { display: flex; gap: 22px; overflow-x: auto; padding: 0 2px; margin-bottom: 14px; scrollbar-width: none; }
.control-page .device-room-tabs button { flex-shrink: 0; border: 0; border-bottom: 3px solid transparent; background: transparent; padding: 8px 0; color: #8a978f; font-size: 14px; cursor: pointer; }
.control-page .device-room-tabs button.active { color: #0c5045; font-weight: 700; border-bottom-color: #0c5045; }
.control-page .device-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.control-page .device-overview-tile { position: relative; min-width: 0; background: #fff; border: 1px solid rgba(18, 63, 55, .06); border-radius: 20px; overflow: hidden; box-shadow: 0 3px 16px rgba(18, 63, 55, .025); }
.control-page .device-overview-tile.powered { border-color: #bfd7cb; }
.control-page .device-tile-open { display: flex; flex-direction: column; align-items: flex-start; width: 100%; min-height: 230px; padding: 17px 14px 13px; border: 0; background: transparent; color: #31584a; text-align: left; cursor: pointer; }
.control-page .device-tile-category { width: calc(100% - 42px); font-size: 10px; color: #87968c; }
.control-page .device-glyph { width: 76px; height: 76px; margin: 13px 0 9px; flex-shrink: 0; }
.control-page .device-tile-name { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; width: 100%; min-height: 38px; line-height: 19px; font-size: 14px; font-weight: 700; color: #203d32; overflow-wrap: anywhere; }
.control-page .device-tile-status { display: flex; align-items: center; gap: 5px; margin-top: 7px; color: #708279; font-size: 11px; line-height: 16px; }
.control-page .device-tile-status i { width: 5px; height: 5px; border-radius: 50%; background: #bcc4be; flex-shrink: 0; }
.control-page .device-tile-status i.online { background: #609b7f; }
.control-page .device-tile-metric { margin-top: 3px; color: #9aa59d; font-size: 10px; line-height: 16px; }
.control-page .device-tile-footer { display: flex; justify-content: space-between; align-items: center; gap: 4px; width: 100%; padding-top: 10px; margin-top: auto; color: #a0aaa2; font-size: 10px; line-height: 15px; }
.control-page .device-tile-footer > span { font-size: 18px; color: #738b7d; }
.control-page .device-tile-power { position: absolute; right: 11px; top: 12px; display: grid; place-items: center; width: 38px; height: 38px; border: 0; border-radius: 50%; background: #f0f2ed; color: #849187; cursor: pointer; }
.control-page .device-tile-power.on { background: #e3f0e8; color: #0c5045; }
.control-page .device-tile-power svg { width: 21px; height: 21px; }
.control-page .device-tile-power:disabled { opacity: .45; cursor: not-allowed; }
.control-page .device-overview-tile.offline .device-glyph { opacity: .45; }
.control-page .device-overview-tile.offline .device-tile-name { color: #8e9991; }
.control-page .device-tile-open:focus-visible, .control-page .device-tile-power:focus-visible, .control-page .device-room-tabs button:focus-visible { outline: 2px solid #649b82; outline-offset: -3px; }
.control-page .device-tile-skeleton { height: 252px; border-radius: 20px; background: #f9faf6; border: 1px solid rgba(18, 63, 55, .04); }
.control-page .device-empty-state { padding: 28px 20px; border-radius: 20px; background: #fff; text-align: center; color: #86968d; font-size: 13px; line-height: 1.7; }
.control-page .device-empty-state strong { display: block; color: #3f5e4d; font-size: 15px; }
.control-page .device-empty-state p { margin: 10px 0 16px; overflow-wrap: anywhere; }
.control-page .device-connection-note { padding: 11px 13px; margin-bottom: 14px; background: #fff5e5; border-radius: 12px; color: #a47b43; font-size: 12px; line-height: 1.6; }
.control-page .device-information { padding: 17px; border-radius: 20px; background: #fff; }
.control-page .device-information h3 { margin: 0 0 10px; font-size: 13px; color: #607a6b; }
.control-page .device-information p { display: flex; justify-content: space-between; gap: 16px; margin: 10px 0 0; font-size: 12px; color: #87968d; }
.control-page .device-information p span:last-child { color: #486657; text-align: right; overflow-wrap: anywhere; }
@media (max-width: 350px) {
.control-page .device-grid { gap: 8px; }
.control-page .device-tile-open { padding-left: 11px; padding-right: 11px; }
.control-page .device-tile-name { font-size: 13px; }
.control-page .operation-status { flex-wrap: wrap; }
}
+178
View File
@@ -0,0 +1,178 @@
/* Demo 的共用视觉层:沿用暖白 / 深绿,不改动设备参数或评分规则。 */
:root {
--page-gutter: 16px;
--surface-radius: 20px;
--surface-shadow: 0 4px 18px rgba(18, 63, 55, .035);
--muted-text: #65776e;
}
button, summary { -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
button:focus-visible, summary:focus-visible, a:focus-visible {
outline: 2px solid #4d937b;
outline-offset: 3px;
}
input:focus-visible, textarea:focus-visible, select:focus-visible {
outline: 2px solid rgba(12, 80, 69, .35);
outline-offset: 1px;
}
button:disabled { cursor: not-allowed; }
button, input, textarea, select { max-width: 100%; }
input, textarea { accent-color: var(--green); }
#app :is(.page-shell, .assessment-page, .tcm-page, .profile-page) {
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
}
#app :is(.section-card, .profile-card, .tcm-card, .card-section) {
border-radius: var(--surface-radius);
box-shadow: var(--surface-shadow);
margin-bottom: 16px;
}
#app :is(.dashboard-header, .assessment-header, .tcm-header) h1 {
line-height: 1.3;
overflow-wrap: anywhere;
}
#app .dashboard-header { gap: 16px; align-items: center; }
#app .dashboard-header > div { min-width: 0; }
#app .dashboard-header .eyebrow { margin-bottom: 7px; font-size: 10px; }
#app .avatar-button { flex-shrink: 0; font-weight: 700; box-shadow: 0 0 0 5px rgba(12, 80, 69, .045); }
#app .dashboard-page .personalized-startup-card { padding: 20px 18px 16px; }
#app .personalized-startup-card .section-title { gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
#app .personalized-startup-card .section-title > span:first-child { font-size: 19px; }
#app .constitution-chip { background: #eaf3ed; padding: 5px 10px; border-radius: 20px; color: #0c5045; font-size: 12px; font-weight: 600; }
#app .startup-card-desc { color: var(--muted-text); font-size: 12px; line-height: 1.7; margin: 0 0 12px; }
.scene-availability { margin: 0 0 14px; padding: 10px 12px; background: #f4f7f2; border-radius: 12px; color: #547263; font-size: 12px; line-height: 1.6; }
.scene-availability.unavailable { background: #faf5eb; color: #9a7647; }
#app .startup-scene-actions { margin: 0; }
#app .startup-scene-actions button { min-height: 48px; font-size: 14px; border-radius: 14px; }
.scene-feedback { margin: 12px 0 0; font-size: 12px; line-height: 1.7; padding: 10px 12px; border-radius: 11px; background: #f4f7f2; color: #547263; overflow-wrap: anywhere; }
.scene-feedback.fail { color: #9b6144; background: #fff4ea; }
.scene-feedback.success { color: #0c5045; background: #eef7f1; }
.inline-link { border: 0; background: transparent; color: #0c5045; font-size: 13px; padding: 10px 0 0; min-height: 44px; font-weight: 600; }
#app .detail-entry-bar { width: 100%; text-align: left; margin-top: 16px; background: #f8f8f4; border: 1px solid #edf0e8; padding: 13px 12px; }
#app .entry-left { min-width: 0; gap: 12px; }
#app .entry-left strong { font-size: 14px; }
#app .entry-left small { font-size: 12px; margin-top: 3px; line-height: 1.5; }
.entry-symbol { width: 34px; height: 34px; display: grid; place-items: center; flex-shrink: 0; background: #e9f1e9; color: #0c5045; border-radius: 10px; font-size: 23px; }
.startup-preset-details { border-top: 1px solid #edf0e9; margin-top: 16px; padding-top: 1px; }
.startup-preset-details summary { min-height: 44px; display: flex; align-items: center; justify-content: space-between; list-style: none; color: #698172; font-size: 12px; cursor: pointer; }
.startup-preset-details summary::-webkit-details-marker { display: none; }
.startup-preset-details summary::after { content: '+'; color: #8da194; font-size: 18px; }
.startup-preset-details[open] summary::after { content: '−'; }
#app .startup-param-badge { border: 0; padding: 12px; }
#app .startup-param-badge .lbl { font-size: 11px; margin-bottom: 5px; }
#app .startup-param-badge strong { line-height: 1.6; font-weight: 600; }
#app .assessment-guide-banner { background: #faf5e9; color: #745d3e; box-shadow: none; border: 1px solid #efe4cd; padding: 16px; }
#app .guide-badge { background: #efe4cc; color: #8e7148; }
#app .guide-text p { color: #8a785b; font-size: 12px; line-height: 1.6; }
#app .guide-text h3 { font-size: 14px; }
#app .guide-btn { min-height: 42px; padding: 8px 12px; flex-shrink: 0; box-shadow: none; }
#app .health-advice-card { padding: 20px; border-radius: 20px; }
#app .health-advice-card .eyebrow { color: #e5c385; letter-spacing: .08em; }
#app .health-advice-card h2 { font-size: 22px; line-height: 1.4; }
#app .health-advice-card > p:not(.eyebrow) { font-size: 13px; line-height: 1.7; margin-bottom: 10px; }
.health-advice-link { background: transparent; border: 0; color: #e3d5b6; font-size: 12px; min-height: 36px; padding: 0; }
#app .metric-strip div { min-width: 0; padding: 4px 12px; }
#app .metric-strip strong { font-variant-numeric: tabular-nums; font-size: 22px; }
#app .medical-note { color: #829188; line-height: 1.7; margin: 20px 10px; }
#app :is(.page-load-state, .page-error-state) { text-align: center; padding: 28px 20px; font-size: 13px; color: var(--muted-text); line-height: 1.7; }
#app :is(.page-load-state, .page-error-state) p { margin: 10px 0; overflow-wrap: anywhere; }
#app .page-error-state :is(h2, h3) { margin: 0 0 10px; color: #315a4a; font-size: 16px; }
.page-loading-mark { display: block; width: 28px; height: 28px; margin: 0 auto 14px; border: 2px solid #dde9df; border-top-color: #34795e; border-radius: 50%; animation: page-spin .9s linear infinite; }
.retry-button { border: 1px solid #d3e1d5; border-radius: 12px; background: #f4f8f1; color: #0c5045; font-size: 13px; min-height: 44px; min-width: 126px; padding: 10px 18px; font-weight: 600; margin-top: 10px; }
@keyframes page-spin { to { transform: rotate(360deg); } }
#app .profile-content, #app .assessment-content { padding-left: var(--page-gutter); padding-right: var(--page-gutter); }
#app .profile-header { padding: 30px 22px; border-radius: 0 0 28px 28px; box-shadow: none; }
#app .avatar-box { width: 56px; height: 56px; flex-shrink: 0; font-size: 24px; }
#app .user-info { min-width: 0; }
#app .user-info h2 { overflow-wrap: anywhere; line-height: 1.4; }
#app .profile-card { padding: 20px 16px; }
#app .card-head { gap: 10px; }
#app .card-head h3 { min-width: 0; line-height: 1.5; }
#app .card-head :is(.status-indicator, .version-tag, .edit-btn) { flex-shrink: 0; }
#app .edit-btn { min-height: 36px; padding: 4px 0 4px 8px; }
#app .metric-item { padding: 12px 6px; min-width: 0; font-variant-numeric: tabular-nums; }
#app .bmi-metric { grid-column: 1 / -1; flex-direction: row; gap: 10px; padding: 12px 14px; }
#app .bmi-metric .m-label { margin: 0 auto 0 0; font-size: 12px; }
#app .bmi-metric .m-status-tag { margin: 0; padding: 4px 8px; }
#app .plan-summary-text { font-size: 13px; line-height: 1.7; }
#app .menu-item { width: 100%; background: #fff; border: 0; border-bottom: 1px solid #f0f2ec; text-align: left; font: inherit; padding: 15px 16px; gap: 12px; }
#app .menu-item:last-child { border-bottom: 0; }
#app .menu-item:active { background: #f6f8f2; }
#app .menu-left { min-width: 0; }
#app .menu-icon { flex-shrink: 0; }
#app .menu-text { min-width: 0; line-height: 1.5; }
#app .menu-text small { font-size: 12px; }
#app .logout-wrap { margin-top: 20px; }
#app .logout-btn { min-height: 48px; box-shadow: none; }
#app .assessment-header { padding: 26px 22px 24px; }
#app .assessment-header h1 { font-size: 25px; margin-bottom: 12px; }
#app .header-desc { font-size: 13px; line-height: 1.7; }
#app .card-section { padding: 20px 16px; }
#app .section-subtitle { line-height: 1.65; }
#app .form-grid { gap: 16px 12px; }
#app .form-field label { font-size: 12px; }
#app .input-wrap input { font-size: 16px; min-height: 44px; }
#app .toggle-group button { min-height: 44px; }
#app :is(.option-pill, .choice-grid button) { min-height: 44px; line-height: 1.5; }
#app .option-card { width: 100%; text-align: left; font: inherit; }
#app .opt-head { gap: 8px; flex-wrap: wrap; }
#app .opt-desc { font-size: 12px; line-height: 1.65; }
#app :is(.saved-assessment-note, .tcm-dirty-note) { font-size: 12px; line-height: 1.7; }
#app .tcm-page { padding-left: var(--page-gutter); padding-right: var(--page-gutter); }
#app .tcm-header { padding: 14px 5px 22px; }
#app .tcm-header h1 { font-size: 26px; }
#app .tcm-card { padding: 20px 16px; }
#app .section-heading { gap: 10px; align-items: flex-start; }
#app .section-heading h2 { flex-shrink: 0; font-size: 18px; }
#app .section-heading p { line-height: 1.6; }
#app .choice-grid button { padding: 10px 8px; }
#app .note-label textarea { font-size: 16px; min-height: 80px; line-height: 1.6; background: #fbfcf8; }
#app .result-top { gap: 8px; flex-wrap: wrap; }
#app .recommendations div { align-items: flex-start; }
.assessment-progress { display: block; width: 100%; height: 5px; border: 0; border-radius: 4px; overflow: hidden; margin-top: 12px; background: #e4e9df; accent-color: #0c5045; }
.assessment-progress::-webkit-progress-bar { background: #e4e9df; border-radius: 4px; }
.assessment-progress::-webkit-progress-value { background: #0c5045; border-radius: 4px; }
.assessment-progress::-moz-progress-bar { background: #0c5045; border-radius: 4px; }
#app .auth-page { padding-bottom: env(safe-area-inset-bottom, 0px); }
#app .brand-panel { padding: 32px 24px 38px; border-radius: 0 0 30px 30px; }
#app .brand-panel h1 { font-size: 32px; line-height: 1.3; }
#app .brand-copy { font-size: 13px; line-height: 1.7; }
#app .feature-pill-row span { padding: 5px 10px; font-size: 11px; }
#app .auth-card { padding: 24px 20px; box-shadow: var(--surface-shadow); }
#app .auth-heading h2 { font-size: 22px; line-height: 1.4; }
#app .auth-heading p { font-size: 13px; line-height: 1.6; }
#app .auth-card .van-field__control { font-size: 16px; }
#app .auth-tab-switch button { min-height: 44px; }
#app .auth-tab-switch button:disabled { opacity: .55; }
#app .privacy-note { color: #819086; }
#app .app-tab-bar { box-shadow: 0 -2px 12px rgba(18, 63, 55, .025); }
#app .tab-item { margin: 0 4px; border-radius: 12px; }
#app .tab-item.active { background: #edf4ed; }
#app .tab-label { font-size: 11px; }
#app .control-page .control-content { padding: 16px; }
#app .control-page .param-row { padding-top: 17px; padding-bottom: 17px; }
#app .control-page :is(.p-tip, .scale-note, .mattress-mode-note) { font-size: 12px; line-height: 1.7; }
#app .control-page .mini-toggle { min-height: 36px; }
#app .control-page .device-tile-power { width: 42px; height: 42px; }
#app .control-page .device-tile-category { width: calc(100% - 46px); }
#app .control-page .device-tile-status { font-size: 12px; color: #5e7467; }
#app .control-page .device-tile-metric { font-size: 11px; color: #6a7c70; }
#app .control-page .device-tile-footer { color: #718175; }
#app .control-page .device-information { border: 1px solid rgba(18, 63, 55, .05); }
.device-detail-hint { margin: 0 0 14px; padding: 0 3px; font-size: 12px; color: #758579; line-height: 1.7; }
@media (max-width: 359px) {
:root { --page-gutter: 12px; }
#app .dashboard-page .personalized-startup-card { padding: 18px 14px 14px; }
#app .metrics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
#app .options-grid.cols-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
#app .metric-strip div { padding: 4px 8px; }
#app .control-page .control-content { padding: 12px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
}
+193 -120
View File
@@ -3,6 +3,8 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button as VanButton, showToast } from 'vant'
import AppTabBar from '../components/AppTabBar.vue'
import { usePageRequest } from '../composables/usePageRequest'
import { ageFromBirthday, finiteNumberInRange, healthAnswersComplete } from '../services/formValidation'
import {
healthApi,
type HealthAssessmentAnswers,
@@ -10,10 +12,13 @@ import {
} from '../services/health'
const router = useRouter()
const loading = ref(false)
const loading = ref(true)
const submitting = ref(false)
const loadError = ref('')
const hasPreviousAssessment = ref(false)
const age = ref<number>()
const savedAge = ref<number>()
const pageRequest = usePageRequest()
const basicInfo = reactive<HealthProfile>({
gender: undefined,
@@ -39,7 +44,7 @@ const answers = reactive<HealthAssessmentAnswers>({
// 计算 BMI
const computedBmi = computed(() => {
if (!basicInfo.heightCm || !basicInfo.weightKg || basicInfo.heightCm <= 0) return null
if (!finiteNumberInRange(basicInfo.heightCm, 80, 250) || !finiteNumberInRange(basicInfo.weightKg, 20, 250)) return null
const heightM = basicInfo.heightCm / 100
const val = basicInfo.weightKg / (heightM * heightM)
return Math.round(val * 10) / 10
@@ -55,77 +60,76 @@ const bmiStatus = computed(() => {
})
async function loadExisting() {
if (submitting.value) return
const requestId = pageRequest.begin()
loading.value = true
loadError.value = ''
try {
const res = await healthApi.getAssessment()
if (res && res.hasCompletedAssessment) {
hasPreviousAssessment.value = true
if (res.basicInfo) Object.assign(basicInfo, res.basicInfo)
if (res.answers) Object.assign(answers, res.answers)
if (res.basicInfo?.birthday) {
const birthday = new Date(res.basicInfo.birthday)
const today = new Date()
let years = today.getFullYear() - birthday.getFullYear()
if (today < new Date(today.getFullYear(), birthday.getMonth(), birthday.getDate())) years--
age.value = years
}
}
} catch {
// ignore
if (!pageRequest.isCurrent(requestId)) return
if (!res) throw new Error('健康评估记录读取失败,请重试')
hasPreviousAssessment.value = res.hasCompletedAssessment
if (res.basicInfo) Object.assign(basicInfo, res.basicInfo)
if (res.answers) Object.assign(answers, res.answers)
savedAge.value = ageFromBirthday(res.basicInfo?.birthday)
age.value = savedAge.value
} catch (error) {
if (!pageRequest.isCurrent(requestId)) return
loadError.value = error instanceof Error ? error.message : '健康评估记录加载失败,请重试'
} finally {
loading.value = false
if (pageRequest.isCurrent(requestId)) loading.value = false
}
}
async function handleSave() {
if (loading.value || loadError.value || submitting.value) return
if (basicInfo.gender !== '男' && basicInfo.gender !== '女') {
showToast('请选择性别')
return
}
if (!age.value || age.value < 1 || age.value > 120) {
if (!finiteNumberInRange(age.value, 1, 120, true)) {
showToast('请正确填写年龄 (1~120岁)')
return
}
if (!basicInfo.heightCm || basicInfo.heightCm < 80 || basicInfo.heightCm > 250) {
if (!finiteNumberInRange(basicInfo.heightCm, 80, 250)) {
showToast('请正确填写身高 (80~250cm)')
return
}
if (!basicInfo.weightKg || basicInfo.weightKg < 20 || basicInfo.weightKg > 250) {
if (!finiteNumberInRange(basicInfo.weightKg, 20, 250)) {
showToast('请正确填写体重 (20~250kg)')
return
}
// 验证所有选项均已作答
const requiredAnswerKeys: (keyof HealthAssessmentAnswers)[] = [
'sleepDuration', 'fallingAsleepSpeed', 'temperaturePreference', 'nightAwakening', 'mattressPreference',
'exerciseFrequency', 'muscleFatigueArea', 'activeTimeOfDay',
'mealRegularity', 'flavorPreference', 'lateNightEating', 'dailyWaterIntake'
]
for (const key of requiredAnswerKeys) {
if (!answers[key]) {
showToast('三大健康板块每个选项均为必选,请作答完整')
return
}
if (!healthAnswersComplete(answers)) {
showToast('三大健康板块每个选项均为必选,请作答完整')
return
}
const requestId = pageRequest.begin()
submitting.value = true
try {
basicInfo.birthday = `${new Date().getFullYear() - age.value}-01-01`
basicInfo.averageSleepHours = ({ '<6': 5.5, '6-7': 6.5, '7-8': 7.5, '>8': 8.5 } as Record<string, number>)[answers.sleepDuration]
basicInfo.dailyExerciseMinutes = ({ none: 0, '1-2': 15, '3-5': 30, daily: 45 } as Record<string, number>)[answers.exerciseFrequency]
basicInfo.dietPreference = answers.flavorPreference
basicInfo.healthGoal = '改善睡眠、运动与饮食健康'
await healthApi.saveAssessment({
basicInfo: { ...basicInfo },
const profile: HealthProfile = {
...basicInfo,
birthday: basicInfo.birthday && savedAge.value === age.value
? basicInfo.birthday : `${new Date().getFullYear() - age.value}-01-01`,
averageSleepHours: ({ '<6': 5.5, '6-7': 6.5, '7-8': 7.5, '>8': 8.5 } as Record<string, number>)[answers.sleepDuration],
dailyExerciseMinutes: ({ none: 0, '1-2': 15, '3-5': 30, daily: 45 } as Record<string, number>)[answers.exerciseFrequency],
dietPreference: answers.flavorPreference,
healthGoal: '改善睡眠、运动与饮食健康'
}
const saved = await healthApi.saveAssessment({
basicInfo: profile,
answers: { ...answers }
})
if (!pageRequest.isCurrent(requestId)) return
if (!saved?.hasCompletedAssessment) throw new Error('健康评估未保存成功,请重试')
showToast({ type: 'success', message: '健康评估已保存' })
router.replace('/dashboard')
} catch (err) {
if (!pageRequest.isCurrent(requestId)) return
showToast(err instanceof Error ? err.message : '保存失败,请重试')
} finally {
submitting.value = false
if (pageRequest.isCurrent(requestId)) submitting.value = false
}
}
@@ -146,6 +150,15 @@ onMounted(() => {
</header>
<main class="assessment-content">
<section v-if="loading" class="card-section page-load-state" role="status">正在读取健康评估记录…</section>
<section v-else-if="loadError" class="card-section page-error-state" role="alert">
<h3>暂时无法读取评估记录</h3>
<p>{{ loadError }}</p>
<p>为避免覆盖已保存的信息,请重试后再填写。</p>
<button class="retry-button" type="button" @click="loadExisting">重新加载</button>
</section>
<fieldset v-else class="assessment-form" :disabled="submitting" :inert="submitting || undefined" :aria-busy="submitting">
<p v-if="hasPreviousAssessment" class="saved-assessment-note">已载入上次评估,修改后保存即可更新。</p>
<!-- 身体基础指标卡片 -->
<section class="card-section">
<div class="section-title">
@@ -183,7 +196,9 @@ onMounted(() => {
type="number"
min="1"
max="120"
step="1"
placeholder="请输入年龄"
aria-label="年龄(岁)"
/>
<span class="unit">岁</span>
</div>
@@ -197,7 +212,9 @@ onMounted(() => {
type="number"
min="80"
max="250"
step="0.1"
placeholder="175"
aria-label="身高(厘米)"
/>
<span class="unit">cm</span>
</div>
@@ -211,7 +228,9 @@ onMounted(() => {
type="number"
min="20"
max="250"
step="0.1"
placeholder="68"
aria-label="体重(千克)"
/>
<span class="unit">kg</span>
</div>
@@ -246,34 +265,38 @@ onMounted(() => {
<strong>每晚平均睡眠时长</strong>
</div>
<div class="options-grid cols-2">
<div
<button type="button"
class="option-pill"
:class="{ active: answers.sleepDuration === '<6' }"
:aria-pressed="answers.sleepDuration === '<6'"
@click="answers.sleepDuration = '<6'"
>
不足 6 小时 (睡眠欠缺)
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.sleepDuration === '6-7' }"
:aria-pressed="answers.sleepDuration === '6-7'"
@click="answers.sleepDuration = '6-7'"
>
6 - 7 小时 (基本及格)
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.sleepDuration === '7-8' }"
:aria-pressed="answers.sleepDuration === '7-8'"
@click="answers.sleepDuration = '7-8'"
>
7 - 8 小时 (黄金充沛)
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.sleepDuration === '>8' }"
:aria-pressed="answers.sleepDuration === '>8'"
@click="answers.sleepDuration = '>8'"
>
8 小时以上 (长睡眠)
</div>
</button>
</div>
</div>
@@ -284,9 +307,10 @@ onMounted(() => {
<strong>入睡耗时与困难程度</strong>
</div>
<div class="options-grid cols-1">
<div
<button type="button"
class="option-card"
:class="{ active: answers.fallingAsleepSpeed === 'hard' }"
:aria-pressed="answers.fallingAsleepSpeed === 'hard'"
@click="answers.fallingAsleepSpeed = 'hard'"
>
<div class="opt-head">
@@ -294,11 +318,12 @@ onMounted(() => {
<span class="opt-tag">入睡较慢</span>
</div>
<p class="opt-desc">睡前思绪繁杂、对光线声音敏感,常在床上翻来覆去无法入眠</p>
</div>
</button>
<div
<button type="button"
class="option-card"
:class="{ active: answers.fallingAsleepSpeed === 'normal' }"
:aria-pressed="answers.fallingAsleepSpeed === 'normal'"
@click="answers.fallingAsleepSpeed = 'normal'"
>
<div class="opt-head">
@@ -306,11 +331,12 @@ onMounted(() => {
<span class="opt-tag">轻柔微光</span>
</div>
<p class="opt-desc">入睡平稳,偶有疲劳或压力,整体处于健康范围</p>
</div>
</button>
<div
<button type="button"
class="option-card"
:class="{ active: answers.fallingAsleepSpeed === 'fast' }"
:aria-pressed="answers.fallingAsleepSpeed === 'fast'"
@click="answers.fallingAsleepSpeed = 'fast'"
>
<div class="opt-head">
@@ -318,7 +344,7 @@ onMounted(() => {
<span class="opt-tag">常规舒眠</span>
</div>
<p class="opt-desc">沾枕头即入睡,深睡眠过渡平顺</p>
</div>
</button>
</div>
</div>
@@ -329,9 +355,10 @@ onMounted(() => {
<strong>睡眠时的冷热感受</strong>
</div>
<div class="options-grid cols-1">
<div
<button type="button"
class="option-card"
:class="{ active: answers.temperaturePreference === 'heat_fear' }"
:aria-pressed="answers.temperaturePreference === 'heat_fear'"
@click="answers.temperaturePreference = 'heat_fear'"
>
<div class="opt-head">
@@ -339,11 +366,12 @@ onMounted(() => {
<span class="opt-tag tag-cool">偏热</span>
</div>
<p class="opt-desc">被窝温度稍高就烦躁踢被,需要更干爽清凉的卧房环境</p>
</div>
</button>
<div
<button type="button"
class="option-card"
:class="{ active: answers.temperaturePreference === 'cold_fear' }"
:aria-pressed="answers.temperaturePreference === 'cold_fear'"
@click="answers.temperaturePreference = 'cold_fear'"
>
<div class="opt-head">
@@ -351,11 +379,12 @@ onMounted(() => {
<span class="opt-tag tag-warm">偏冷</span>
</div>
<p class="opt-desc">入睡半天手脚捂不热,稍有微风吹拂即觉寒凉</p>
</div>
</button>
<div
<button type="button"
class="option-card"
:class="{ active: answers.temperaturePreference === 'normal' }"
:aria-pressed="answers.temperaturePreference === 'normal'"
@click="answers.temperaturePreference = 'normal'"
>
<div class="opt-head">
@@ -363,7 +392,7 @@ onMounted(() => {
<span class="opt-tag">冷热适中</span>
</div>
<p class="opt-desc">体感舒适度正常,对温湿度耐受度均衡</p>
</div>
</button>
</div>
</div>
@@ -374,27 +403,30 @@ onMounted(() => {
<strong>夜间醒来与多梦浅睡情况</strong>
</div>
<div class="options-grid cols-3">
<div
<button type="button"
class="option-pill"
:class="{ active: answers.nightAwakening === 'frequent' }"
:aria-pressed="answers.nightAwakening === 'frequent'"
@click="answers.nightAwakening = 'frequent'"
>
频繁易惊醒
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.nightAwakening === 'light' }"
:aria-pressed="answers.nightAwakening === 'light'"
@click="answers.nightAwakening = 'light'"
>
偶有起夜1次
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.nightAwakening === 'none' }"
:aria-pressed="answers.nightAwakening === 'none'"
@click="answers.nightAwakening = 'none'"
>
一觉到天亮
</div>
</button>
</div>
</div>
@@ -405,27 +437,30 @@ onMounted(() => {
<strong>床垫承托硬度偏好</strong>
</div>
<div class="options-grid cols-3">
<div
<button type="button"
class="option-pill"
:class="{ active: answers.mattressPreference === 'firm' }"
:aria-pressed="answers.mattressPreference === 'firm'"
@click="answers.mattressPreference = 'firm'"
>
偏硬护脊 (7级)
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.mattressPreference === 'medium' }"
:aria-pressed="answers.mattressPreference === 'medium'"
@click="answers.mattressPreference = 'medium'"
>
软硬适中 (5-6级)
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.mattressPreference === 'soft' }"
:aria-pressed="answers.mattressPreference === 'soft'"
@click="answers.mattressPreference = 'soft'"
>
偏软包裹 (4级)
</div>
</button>
</div>
</div>
</section>
@@ -445,34 +480,38 @@ onMounted(() => {
<strong>每周活动与锻炼频率</strong>
</div>
<div class="options-grid cols-2">
<div
<button type="button"
class="option-pill"
:class="{ active: answers.exerciseFrequency === 'none' }"
:aria-pressed="answers.exerciseFrequency === 'none'"
@click="answers.exerciseFrequency = 'none'"
>
几乎不运动 (久坐为主)
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.exerciseFrequency === '1-2' }"
:aria-pressed="answers.exerciseFrequency === '1-2'"
@click="answers.exerciseFrequency = '1-2'"
>
每周 1 - 2 次 (轻度锻炼)
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.exerciseFrequency === '3-5' }"
:aria-pressed="answers.exerciseFrequency === '3-5'"
@click="answers.exerciseFrequency = '3-5'"
>
每周 3 - 5 次 (规律运动)
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.exerciseFrequency === 'daily' }"
:aria-pressed="answers.exerciseFrequency === 'daily'"
@click="answers.exerciseFrequency = 'daily'"
>
每天坚持 (中高强度)
</div>
</button>
</div>
</div>
@@ -483,34 +522,38 @@ onMounted(() => {
<strong>日常最易酸痛或劳损部位</strong>
</div>
<div class="options-grid cols-2">
<div
<button type="button"
class="option-pill"
:class="{ active: answers.muscleFatigueArea === 'back_neck' }"
:aria-pressed="answers.muscleFatigueArea === 'back_neck'"
@click="answers.muscleFatigueArea = 'back_neck'"
>
肩颈僵硬酸痛
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.muscleFatigueArea === 'waist_back' }"
:aria-pressed="answers.muscleFatigueArea === 'waist_back'"
@click="answers.muscleFatigueArea = 'waist_back'"
>
腰肌劳损/腰背空悬
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.muscleFatigueArea === 'legs' }"
:aria-pressed="answers.muscleFatigueArea === 'legs'"
@click="answers.muscleFatigueArea = 'legs'"
>
下肢水肿酸胀
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.muscleFatigueArea === 'none' }"
:aria-pressed="answers.muscleFatigueArea === 'none'"
@click="answers.muscleFatigueArea = 'none'"
>
全身轻松无明显酸痛
</div>
</button>
</div>
</div>
@@ -521,34 +564,38 @@ onMounted(() => {
<strong>主要活动锻炼时段</strong>
</div>
<div class="options-grid cols-2">
<div
<button type="button"
class="option-pill"
:class="{ active: answers.activeTimeOfDay === 'morning' }"
:aria-pressed="answers.activeTimeOfDay === 'morning'"
@click="answers.activeTimeOfDay = 'morning'"
>
清晨早间活动
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.activeTimeOfDay === 'evening' }"
:aria-pressed="answers.activeTimeOfDay === 'evening'"
@click="answers.activeTimeOfDay = 'evening'"
>
下午或傍晚下班后
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.activeTimeOfDay === 'night' }"
:aria-pressed="answers.activeTimeOfDay === 'night'"
@click="answers.activeTimeOfDay = 'night'"
>
临睡前 1-2 小时
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.activeTimeOfDay === 'irregular' }"
:aria-pressed="answers.activeTimeOfDay === 'irregular'"
@click="answers.activeTimeOfDay = 'irregular'"
>
时间无规律
</div>
</button>
</div>
</div>
</section>
@@ -568,27 +615,30 @@ onMounted(() => {
<strong>三餐定时定量规律度</strong>
</div>
<div class="options-grid cols-3">
<div
<button type="button"
class="option-pill"
:class="{ active: answers.mealRegularity === 'regular' }"
:aria-pressed="answers.mealRegularity === 'regular'"
@click="answers.mealRegularity = 'regular'"
>
三餐基本规律
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.mealRegularity === 'skip_breakfast' }"
:aria-pressed="answers.mealRegularity === 'skip_breakfast'"
@click="answers.mealRegularity = 'skip_breakfast'"
>
常不吃早餐
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.mealRegularity === 'irregular' }"
:aria-pressed="answers.mealRegularity === 'irregular'"
@click="answers.mealRegularity = 'irregular'"
>
三餐时常颠倒
</div>
</button>
</div>
</div>
@@ -599,27 +649,30 @@ onMounted(() => {
<strong>日常饮食口味倾向</strong>
</div>
<div class="options-grid cols-3">
<div
<button type="button"
class="option-pill"
:class="{ active: answers.flavorPreference === 'light' }"
:aria-pressed="answers.flavorPreference === 'light'"
@click="answers.flavorPreference = 'light'"
>
清淡低盐少油
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.flavorPreference === 'moderate' }"
:aria-pressed="answers.flavorPreference === 'moderate'"
@click="answers.flavorPreference = 'moderate'"
>
荤素平衡适度
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.flavorPreference === 'heavy' }"
:aria-pressed="answers.flavorPreference === 'heavy'"
@click="answers.flavorPreference = 'heavy'"
>
重油重辣厚味
</div>
</button>
</div>
</div>
@@ -630,9 +683,10 @@ onMounted(() => {
<strong>夜宵及睡前进食习惯</strong>
</div>
<div class="options-grid cols-1">
<div
<button type="button"
class="option-card"
:class="{ active: answers.lateNightEating === 'often' }"
:aria-pressed="answers.lateNightEating === 'often'"
@click="answers.lateNightEating = 'often'"
>
<div class="opt-head">
@@ -640,11 +694,12 @@ onMounted(() => {
<span class="opt-tag">经常晚间进食</span>
</div>
<p class="opt-desc">睡前进食可能影响休息,可逐步调整进食时间</p>
</div>
</button>
<div
<button type="button"
class="option-card"
:class="{ active: answers.lateNightEating === 'sometimes' }"
:aria-pressed="answers.lateNightEating === 'sometimes'"
@click="answers.lateNightEating = 'sometimes'"
>
<div class="opt-head">
@@ -652,11 +707,12 @@ onMounted(() => {
<span class="opt-tag">偶尔晚间进食</span>
</div>
<p class="opt-desc">偶尔因加班或聚餐改变晚餐时间</p>
</div>
</button>
<div
<button type="button"
class="option-card"
:class="{ active: answers.lateNightEating === 'rarely' }"
:aria-pressed="answers.lateNightEating === 'rarely'"
@click="answers.lateNightEating = 'rarely'"
>
<div class="opt-head">
@@ -664,7 +720,7 @@ onMounted(() => {
<span class="opt-tag">较少夜宵</span>
</div>
<p class="opt-desc">通常能给晚餐和入睡留出间隔</p>
</div>
</button>
</div>
</div>
@@ -675,27 +731,30 @@ onMounted(() => {
<strong>每日饮水充足度</strong>
</div>
<div class="options-grid cols-3">
<div
<button type="button"
class="option-pill"
:class="{ active: answers.dailyWaterIntake === 'abundant' }"
:aria-pressed="answers.dailyWaterIntake === 'abundant'"
@click="answers.dailyWaterIntake = 'abundant'"
>
充沛 (>2000ml)
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.dailyWaterIntake === 'moderate' }"
:aria-pressed="answers.dailyWaterIntake === 'moderate'"
@click="answers.dailyWaterIntake = 'moderate'"
>
适中 (1500ml)
</div>
<div
</button>
<button type="button"
class="option-pill"
:class="{ active: answers.dailyWaterIntake === 'scarce' }"
:aria-pressed="answers.dailyWaterIntake === 'scarce'"
@click="answers.dailyWaterIntake = 'scarce'"
>
偏少 (经常口渴)
</div>
</button>
</div>
</div>
</section>
@@ -709,11 +768,13 @@ onMounted(() => {
size="large"
color="#0c5045"
:loading="submitting"
:disabled="submitting"
@click="handleSave"
>
保存健康评估
</VanButton>
</div>
</fieldset>
</main>
<!-- 固定底部导航栏 -->
@@ -765,6 +826,12 @@ onMounted(() => {
padding: 16px 14px;
}
.assessment-form { min-width: 0; padding: 0; margin: 0; border: 0; }
.assessment-form[disabled] { opacity: .72; }
.saved-assessment-note { margin: 0 4px 14px; color: #527169; font-size: 12px; line-height: 1.6; }
.page-error-state h3 { margin: 0 0 8px; font-size: 16px; }
.page-error-state p { font-size: 13px; line-height: 1.6; }
.card-section {
background: #ffffff;
border-radius: 20px;
@@ -980,6 +1047,7 @@ onMounted(() => {
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
font-family: inherit;
}
.option-pill.active {
@@ -996,8 +1064,13 @@ onMounted(() => {
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
font-family: inherit;
text-align: left;
}
.option-pill:focus-visible, .option-card:focus-visible { outline: 2px solid #0c5045; outline-offset: 3px; }
.option-card.active {
background: #eef7f3;
border-color: #0c5045;
+28 -13
View File
@@ -4,26 +4,36 @@ import { useRouter } from 'vue-router'
import { Button as VanButton, Field as VanField, Form as VanForm, showToast } from 'vant'
import { healthApi } from '../services/health'
import { saveTokens } from '../services/auth'
import { usePageRequest } from '../composables/usePageRequest'
const router = useRouter()
const mode = ref<'login' | 'register'>('login')
const loading = ref(false)
const form = reactive({ phone: '', password: '', displayName: '' })
const title = computed(() => mode.value === 'login' ? '欢迎回来' : '开启专属健康档案')
const pageRequest = usePageRequest()
async function submit() {
if (loading.value) return
const requestMode = mode.value
const phone = form.phone.trim(), displayName = form.displayName.trim()
if (!/^1[3-9]\d{9}$/.test(phone)) { showToast('请输入正确的手机号'); return }
if (!form.password || (requestMode === 'register' && form.password.length < 8)) { showToast(requestMode === 'register' ? '注册密码至少 8 位' : '请输入密码'); return }
if (requestMode === 'register' && !displayName) { showToast('请输入姓名或称呼'); return }
const requestId = pageRequest.begin()
loading.value = true
try {
const output = mode.value === 'login'
? await healthApi.login({ phone: form.phone, password: form.password })
: await healthApi.register(form)
const output = requestMode === 'login'
? await healthApi.login({ phone, password: form.password })
: await healthApi.register({ phone, password: form.password, displayName })
if (!pageRequest.isCurrent(requestId)) return
saveTokens(output.accessToken, output.refreshToken)
showToast({ type: 'success', message: mode.value === 'login' ? '登录成功' : '注册成功' })
await router.replace(mode.value === 'login' ? '/dashboard' : '/tcm-assessment')
showToast({ type: 'success', message: requestMode === 'login' ? '登录成功' : '注册成功' })
await router.replace(requestMode === 'login' ? '/dashboard' : '/tcm-assessment')
} catch (error) {
showToast(error instanceof Error ? error.message : '操作失败')
if (pageRequest.isCurrent(requestId)) showToast(error instanceof Error ? error.message : '操作失败')
} finally {
loading.value = false
if (pageRequest.isCurrent(requestId)) loading.value = false
}
}
</script>
@@ -39,7 +49,7 @@ async function submit() {
<h1>一人一方<br />健康与空间协同</h1>
<p class="brand-copy">以睡眠为切入点,融合五感空间与动态健康方案,开启日常健康管理。</p>
<div class="feature-pill-row">
<span>🌿 六维处方</span><span>🌙 五感微环境</span><span>📊 闭环调理</span>
<span>体质评估</span><span>舒睡联动</span><span>健康档案</span>
</div>
</section>
@@ -48,6 +58,7 @@ async function submit() {
<button
type="button"
:class="{ active: mode === 'login' }"
:aria-pressed="mode === 'login'" :disabled="loading"
@click="mode = 'login'"
>
账号登录
@@ -55,6 +66,7 @@ async function submit() {
<button
type="button"
:class="{ active: mode === 'register' }"
:aria-pressed="mode === 'register'" :disabled="loading"
@click="mode = 'register'"
>
注册建档
@@ -62,8 +74,8 @@ async function submit() {
</div>
<div class="auth-heading">
<h2 style="font-size: 20px; font-weight: 700; color: #142e27; margin-bottom: 4px;">{{ title }}</h2>
<p style="color: #72847d; font-size: 13px; margin-bottom: 16px;">
<h2>{{ title }}</h2>
<p>
{{ mode === 'login' ? '登录后查看今日健康方案与空间联动' : '第一步,填写信息创建您的专属健康档案' }}
</p>
</div>
@@ -73,12 +85,14 @@ async function submit() {
v-if="mode === 'register'"
v-model="form.displayName"
label="姓名"
:disabled="loading" autocomplete="name" maxlength="30"
placeholder="请输入姓名或称呼"
:rules="[{ required: true, message: '请输入姓名' }]"
/>
<van-field
v-model="form.phone"
type="tel"
:disabled="loading" autocomplete="username" inputmode="tel"
label="手机号"
maxlength="11"
placeholder="请输入 11 位手机号"
@@ -87,11 +101,12 @@ async function submit() {
<van-field
v-model="form.password"
type="password"
:disabled="loading" :autocomplete="mode === 'register' ? 'new-password' : 'current-password'"
label="密码"
placeholder="至少 8 位密码"
:rules="[{ required: true, message: '请输入至少 8 位密码' }]"
:placeholder="mode === 'register' ? '设置至少 8 位密码' : '请输入密码'"
:rules="[{ required: true, message: '请输入密码' }, { validator: (value: string) => mode === 'login' || value.length >= 8, message: '注册密码至少 8 位' }]"
/>
<van-button block round color="#0c5045" native-type="submit" :loading="loading">
<van-button block round color="#0c5045" native-type="submit" :loading="loading" :disabled="loading">
{{ mode === 'login' ? '登 录' : '创建档案并继续' }}
</van-button>
</van-form>
+88 -156
View File
@@ -1,83 +1,65 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button as VanButton, showToast } from 'vant'
import AppTabBar from '../components/AppTabBar.vue'
import { clearTokens } from '../services/auth'
import {
healthApi,
type Dashboard,
type HomeAssistantStatus
} from '../services/health'
import { useDeviceScenes } from '../composables/useDeviceScenes'
import { healthApi, type Dashboard, type HomeAssistantStatus } from '../services/health'
const router = useRouter()
const loading = ref(true)
const sceneRunning = ref<'sleep' | 'all-off'>()
const loadError = ref('')
const dashboard = ref<Dashboard>()
const homeAssistant = ref<HomeAssistantStatus>()
const sceneRefreshTimers: ReturnType<typeof setTimeout>[] = []
const actionFeedback = ref<{ type: 'success' | 'fail'; message: string }>()
let active = true, loadSequence = 0, statusSequence = 0
const { sceneRunning, executeScene } = useDeviceScenes({
hasConstitution: () => Boolean(dashboard.value?.tcmResult?.hasCompleted),
needsConstitution: () => { void router.push('/tcm-assessment') },
refresh: refreshDevices,
feedback: (type, message) => { if (active) actionFeedback.value = { type, message } },
})
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 11) return '早上好'
if (hour < 18) return '下午好'
return '晚上好'
return hour < 11 ? '早上好' : hour < 18 ? '下午好' : '晚上好'
})
const sceneAvailable = computed(() => homeAssistant.value?.sceneDevices?.available ?? 0)
const sceneHint = computed(() => {
if (!dashboard.value?.tcmResult?.hasCompleted) return '完成中医四诊、生成体质倾向后,即可一键开启。'
if (!homeAssistant.value?.connected) return '设备连接暂时不可用,可进入设备总览检查并重试。'
if (sceneAvailable.value === 0) return '当前没有可用场景设备,请在设备总览检查连接。'
return `${sceneAvailable.value} 台场景设备可用 · 加热看室温,承托看 BMI`
})
const comfortReason = computed(() => [dashboard.value?.mattressComfort?.heatingReason || dashboard.value?.mattressComfort?.weatherReason,
dashboard.value?.mattressComfort?.firmnessReason].filter(Boolean).join(';'))
async function load() {
const sequence = ++loadSequence, statusRequest = ++statusSequence
loading.value = true
loadError.value = ''
try {
const [dashboardResult, homeAssistantResult] = await Promise.all([
healthApi.dashboard(),
healthApi.homeAssistantStatus().catch(() => undefined)
healthApi.dashboard(), healthApi.homeAssistantStatus().catch(() => undefined),
])
if (!active || sequence !== loadSequence) return
if (!dashboardResult) throw new Error('健康档案暂时无法读取,请重试')
dashboard.value = dashboardResult
homeAssistant.value = homeAssistantResult
if (statusRequest === statusSequence) homeAssistant.value = homeAssistantResult
} catch (error) {
showToast(error instanceof Error ? error.message : '加载失败')
if (active && sequence === loadSequence) loadError.value = error instanceof Error ? error.message : '加载失败,请重试'
} finally {
loading.value = false
if (active && sequence === loadSequence) loading.value = false
}
}
async function executeScene(scene: 'sleep' | 'all-off') {
if (scene === 'sleep' && !dashboard.value?.tcmResult?.hasCompleted) {
showToast('请先完成中医四诊,生成体质倾向')
router.push('/tcm-assessment')
return
}
sceneRunning.value = scene
async function refreshDevices() {
if (!active) return
const sequence = ++statusSequence
try {
const result = scene === 'sleep'
? await healthApi.executeSleepScene()
: await healthApi.executeAllOffScene()
showToast({ type: result.status === 'Succeeded' ? 'success' : 'fail', message: result.message })
homeAssistant.value = await healthApi.homeAssistantStatus()
} catch (error) {
showToast(error instanceof Error ? error.message : '场景执行失败')
} finally {
sceneRunning.value = undefined
for (const timer of sceneRefreshTimers) clearTimeout(timer)
sceneRefreshTimers.length = 0
for (const delay of [2000, 5000, 10000]) {
sceneRefreshTimers.push(setTimeout(async () => {
try { homeAssistant.value = await healthApi.homeAssistantStatus() }
catch { /* 下次刷新继续读取设备状态。 */ }
}, delay))
}
}
const result = await healthApi.homeAssistantStatus()
if (active && sequence === statusSequence) homeAssistant.value = result
} catch { /* 保留最后回读,不把请求失败显示成全部关闭。 */ }
}
function logout() {
clearTokens()
router.replace('/auth')
}
onMounted(load)
onUnmounted(() => {
for (const timer of sceneRefreshTimers) clearTimeout(timer)
})
onUnmounted(() => { active = false; loadSequence++; statusSequence++ })
</script>
<template>
@@ -85,123 +67,73 @@ onUnmounted(() => {
<header class="dashboard-header">
<div>
<p class="eyebrow">EVERGREEN · TODAY</p>
<h1>{{ greeting }},{{ dashboard?.user.displayName || '您好' }}</h1>
<h1>{{ greeting }},{{ dashboard?.user.displayName || '欢迎您' }}</h1>
<p>{{ new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' }).format(new Date()) }}</p>
</div>
<button class="avatar-button" type="button" @click="logout">
<button class="avatar-button" type="button" aria-label="打开我的健康档案" @click="router.push('/profile')">
{{ dashboard?.user.displayName?.slice(0, 1) || '常' }}
</button>
</header>
<section v-if="loading" class="section-card loading-card">正在读取今日健康方案…</section>
<template v-else>
<section class="hero-card">
<p class="eyebrow">今日健康建议</p>
<h2>{{ dashboard?.currentPlan?.title || '尚未完成健康评估' }}</h2>
<p>{{ dashboard?.currentPlan?.summary || '完成健康评估后,系统会整理您的基本情况并生成日常健康建议。' }}</p>
<van-button
v-if="!dashboard?.currentPlan"
round
color="#f1c37a"
text-color="#183b34"
@click="router.replace('/assessment')"
>
去健康评估
</van-button>
</section>
<!-- 未完成评估提示横幅 -->
<section v-if="!dashboard?.hasCompletedAssessment" class="assessment-guide-banner" @click="router.replace('/assessment')">
<div class="guide-badge">重要提醒</div>
<div class="guide-body">
<div class="guide-text">
<h3>完善个人基本信息与健康评估</h3>
<p>尚未记录身体及睡眠质量、运动情况、饮食情况。健康评估仅用于了解基本情况;设备启动参数由中医四诊体质结果生成。</p>
</div>
<button class="guide-btn" type="button">去评估 &gt;</button>
</div>
</section>
<!-- 智能家居启动参数仅来源于中医四诊体质结果 -->
<section v-if="loading" class="section-card page-load-state" role="status" aria-live="polite">
<span class="page-loading-mark" aria-hidden="true"></span><p>正在读取健康档案与舒睡设备…</p>
</section>
<section v-else-if="loadError" class="section-card page-error-state" role="alert">
<h2>暂时无法读取首页</h2><p>{{ loadError }}</p>
<button class="retry-button" type="button" @click="load">重新加载</button>
</section>
<template v-else-if="dashboard">
<section class="section-card personalized-startup-card">
<div class="section-title">
<span>体质专属舒睡启动参数</span>
<span>我的舒睡空间</span>
<span class="constitution-chip">{{ dashboard.tcmResult?.hasCompleted ? dashboard.tcmResult.constitutionName : '待生成体质' }}</span>
</div>
<p class="startup-card-desc">
{{ dashboard?.tcmResult?.hasCompleted
? `依据${dashboard.tcmResult.constitutionName}倾向生成基础预设;床垫加热看重庆天气,软硬度看 BMI。`
: '先完成中医四诊并生成体质倾向,才可一键开启专属场景。' }}
<a v-if="!dashboard?.tcmResult?.hasCompleted" href="/tcm-assessment" @click.prevent="router.push('/tcm-assessment')">去完成四诊 ›</a>
</p>
<div v-if="dashboard?.tcmResult?.hasCompleted" class="startup-params-tags">
<div class="startup-param-badge">
<span class="lbl">恒湿目标</span>
<strong>{{ dashboard?.sceneConfig?.targetHumidity || 55 }}% · {{ dashboard?.sceneConfig?.fanLevel || '1挡' }}</strong>
</div>
<div class="startup-param-badge">
<span class="lbl">助眠灯光</span>
<strong>50%渐暗至关闭 · {{ dashboard?.sceneConfig?.lightColorTemperatureKelvin || 2700 }}K</strong>
</div>
<div class="startup-param-badge">
<span class="lbl">助眠音箱</span>
<strong>体质音乐循环播放 · 50% 起每 5 秒降 1% 至停止 · {{ dashboard?.sceneConfig?.enableSpeakerSleepMode ? '开启睡眠模式' : '关闭睡眠模式' }}</strong>
</div>
<div class="startup-param-badge">
<span class="lbl">8H 床垫</span>
<strong>左右加热 {{ dashboard?.mattressComfort?.heatingLevel ?? 0 }} 档 · 软硬度{{ dashboard?.mattressComfort?.firmness || '待填写 BMI' }} · 右侧全身轻柔按摩</strong>
</div>
<div class="startup-param-badge" v-if="dashboard?.sceneConfig?.enableUv">
<span class="lbl">除湿消杀</span>
<strong style="color: #7b42b5;">UV 深度消杀已开启</strong>
</div>
</div>
<p v-if="dashboard?.tcmResult?.hasCompleted" class="startup-card-desc">
{{ dashboard?.mattressComfort?.weatherReason }};{{ dashboard?.mattressComfort?.firmnessReason }}
</p>
<p class="startup-card-desc">体质决定基础预设,身体档案与室内环境用于补充调整。</p>
<p class="scene-availability" :class="{ unavailable: !homeAssistant?.connected || sceneAvailable === 0 }">{{ sceneHint }}</p>
<div class="startup-scene-actions">
<button
class="startup-action-primary"
type="button"
:disabled="!dashboard?.tcmResult?.hasCompleted || !homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('sleep')"
>
{{ sceneRunning === 'sleep' ? '正在开启…' : '一键开启' }}
</button>
<button
class="startup-action-secondary"
type="button"
:disabled="!homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('all-off')"
>
{{ sceneRunning === 'all-off' ? '正在关闭…' : '一键关闭' }}
</button>
<button class="startup-action-primary" type="button"
:disabled="!dashboard.tcmResult?.hasCompleted || !homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('sleep')">{{ sceneRunning === 'sleep' ? '正在开启…' : '一键开启舒睡' }}</button>
<button class="startup-action-secondary" type="button" :disabled="sceneRunning === 'all-off'"
@click="executeScene('all-off')">{{ sceneRunning === 'all-off' ? '正在关闭…' : '一键关闭' }}</button>
</div>
<div class="detail-entry-bar" @click="router.push('/device-control')">
<div class="entry-left">
<span class="icon">⚙️</span>
<div>
<strong>进入设备详情与独立参数调节</strong>
<small>显示每个设备在线状态,可单独开关与精细滑块调节</small>
</div>
<p v-if="sceneRunning || actionFeedback" class="scene-feedback" :class="actionFeedback?.type" role="status" aria-live="polite">
{{ sceneRunning ? sceneRunning === 'sleep' ? '正在开启设备;如需中止,可点击一键关闭。' : '正在关闭设备,请等待回读结果。' : actionFeedback?.message }}
</p>
<button v-if="!dashboard.tcmResult?.hasCompleted" class="inline-link" type="button" @click="router.push('/tcm-assessment')">去完成中医四诊 ›</button>
<button class="detail-entry-bar" type="button" @click="router.push('/device-control')">
<span class="entry-left"><span class="entry-symbol" aria-hidden="true">▦</span><span>
<strong>查看舒睡设备</strong><small>设备总览、快捷开关与完整参数</small>
</span></span><span class="arrow" aria-hidden="true">›</span>
</button>
<details v-if="dashboard.tcmResult?.hasCompleted" class="startup-preset-details">
<summary>查看体质启动预设</summary>
<div class="startup-params-tags">
<div class="startup-param-badge"><span class="lbl">恒湿目标</span><strong>{{ dashboard.sceneConfig?.targetHumidity ?? '--' }}% · {{ dashboard.sceneConfig?.fanLevel || '待读取' }}</strong></div>
<div class="startup-param-badge"><span class="lbl">助眠灯光</span><strong>{{ dashboard.sceneConfig?.lightBrightnessPercent ?? '--' }}% 渐暗 · {{ dashboard.sceneConfig?.lightColorTemperatureKelvin ?? '--' }}K</strong></div>
<div class="startup-param-badge"><span class="lbl">体质音乐</span><strong>循环播放,50% 起每 5 秒降 1%</strong></div>
<div class="startup-param-badge"><span class="lbl">智能床垫</span><strong>{{ dashboard.mattressComfort?.heatingLevel ?? 0 }} 档加热 · {{ dashboard.mattressComfort?.firmness || '待填写 BMI' }}</strong></div>
</div>
<span class="arrow">&gt;</span>
</div>
<p class="startup-card-desc">右侧全身轻柔按摩;灯灭后停止按摩。香型需人工更换。</p>
<p v-if="dashboard.sceneConfig?.enableUv" class="startup-card-desc">除湿机预设开启 UV 消杀。</p>
<p v-if="comfortReason" class="startup-card-desc">{{ comfortReason }}</p>
</details>
</section>
<section class="metric-strip">
<div><small>睡眠目标</small><strong>7–8<em>h</em></strong></div>
<div><small>活动目标</small><strong>30<em>min</em></strong></div>
<div><small>方案版本</small><strong>V{{ dashboard?.currentPlan?.version || 0 }}</strong></div>
<section v-if="!dashboard.hasCompletedAssessment" class="assessment-guide-banner">
<span class="guide-badge">完善档案</span>
<div class="guide-body"><div class="guide-text"><h3>补充身体指标与生活习惯</h3><p>健康评估记录基本情况,不生成另一套设备启动参数。</p></div>
<button class="guide-btn" type="button" @click="router.push('/assessment')">去评估 ›</button></div>
</section>
<p class="medical-note">健康建议仅用于日常管理演示,不构成医疗诊断或治疗意见。</p>
<section v-if="dashboard.currentPlan" class="hero-card health-advice-card">
<p class="eyebrow">日常健康建议</p><h2>{{ dashboard.currentPlan.title }}</h2><p>{{ dashboard.currentPlan.summary }}</p>
<button class="health-advice-link" type="button" @click="router.push('/profile')">查看我的健康档案 ›</button>
</section>
<section class="metric-strip" aria-label="日常参考与身体指标">
<div><small>参考睡眠目标</small><strong>7–8<em>h</em></strong></div>
<div><small>参考活动目标</small><strong>30<em>min</em></strong></div>
<div><small>身体 BMI</small><strong>{{ dashboard.bmi ?? '--' }}</strong></div>
</section>
<p class="medical-note">固定规则演示,不构成医疗诊断或治疗意见。</p>
</template>
<!-- 固定底部导航栏 -->
<AppTabBar />
</main>
</template>
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
import { provide } from 'vue'
import { useDeviceControl } from '../composables/useDeviceControl'
import { deviceControlKey } from '../devices/controlContext'
import '../styles/device-control.css'
// 总览与详情只创建一份控制上下文,子页面切换不重复加载或启动渐变轮询。
provide(deviceControlKey, useDeviceControl())
</script>
<template><router-view /></template>
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useDeviceControlContext } from '../devices/controlContext'
import { deviceCardFor } from '../devices/cardRegistry'
import DeviceOperationStatus from '../components/devices/DeviceOperationStatus.vue'
const route = useRoute()
const { router, loading, haStatus, operationBusy, loadStatus } = useDeviceControlContext()
const device = computed(() => haStatus.value?.devices.find(item => item.deviceId === route.params.deviceId))
const waiting = computed(() => !haStatus.value && loading.value)
const connected = computed(() => haStatus.value?.connected === true)
</script>
<template>
<div class="control-page device-detail-page">
<header class="top-nav">
<button class="back-btn" type="button" @click="router.replace('/device-control')">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="15 18 9 12 15 6" /></svg>
<span>全部设备</span>
</button>
<h2>{{ device?.name || '设备详情' }}</h2>
<button class="refresh-btn" type="button" :disabled="operationBusy" @click="loadStatus(true)">{{ loading ? '刷新中…' : '刷新' }}</button>
</header>
<main class="control-content">
<DeviceOperationStatus />
<section v-if="waiting" class="device-empty-state" role="status">正在读取设备详情…</section>
<section v-else-if="!connected" class="device-empty-state" role="alert">
<strong>暂时无法读取设备</strong><p>{{ haStatus?.message || '连接暂时不可用,请刷新后再试。' }}</p>
<button class="mini-toggle" type="button" :disabled="operationBusy" @click="loadStatus(true)">重新读取</button>
</section>
<template v-else-if="device">
<p class="device-detail-hint">灰色控件或 “--” 表示该能力当前不可用。<template v-if="device.moduleKey === 'audio'">音箱播放与音量通过独立网关控制。</template></p>
<div v-if="!device.online" class="device-connection-note">设备回读显示离线,操作可能无法完成;下方保留已有控制入口。</div>
<component :is="deviceCardFor(device)" :device="device" :key="device.deviceId" />
<section class="device-information" v-if="device.areaName || device.manufacturer || device.model">
<h3>设备信息</h3>
<p v-if="device.areaName"><span>所在房间</span><span>{{ device.areaName }}</span></p>
<p v-if="device.manufacturer"><span>品牌</span><span>{{ device.manufacturer }}</span></p>
<p v-if="device.model"><span>型号</span><span>{{ device.model }}</span></p>
</section>
</template>
<section v-else class="device-empty-state">
<strong>未找到这个设备</strong><p>设备可能已移除,或绑定信息已变更。返回设备总览重新选择即可。</p>
<button class="mini-toggle" type="button" @click="router.replace('/device-control')">返回设备总览</button>
</section>
</main>
</div>
</template>
+44 -29
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button as VanButton, showConfirmDialog, showToast } from 'vant'
import AppTabBar from '../components/AppTabBar.vue'
import { usePageRequest } from '../composables/usePageRequest'
import { ageFromBirthday } from '../services/formValidation'
import { clearTokens } from '../services/auth'
import { healthApi, type Dashboard, type HealthAssessmentOutput } from '../services/health'
@@ -10,34 +12,42 @@ const router = useRouter()
const loading = ref(true)
const dashboard = ref<Dashboard>()
const assessment = ref<HealthAssessmentOutput>()
const loadError = ref('')
const pageRequest = usePageRequest()
const logoutBusy = ref(false)
let active = true
async function loadData() {
const requestId = pageRequest.begin()
loading.value = true
loadError.value = ''
try {
const [dashRes, assessRes] = await Promise.all([
healthApi.dashboard(),
healthApi.getAssessment().catch(() => undefined)
healthApi.getAssessment()
])
if (!pageRequest.isCurrent(requestId)) return
if (!dashRes || !assessRes) throw new Error('健康档案暂时无法读取,请重试')
dashboard.value = dashRes
assessment.value = assessRes
} catch (err) {
showToast(err instanceof Error ? err.message : '加载失败')
if (pageRequest.isCurrent(requestId)) loadError.value = err instanceof Error ? err.message : '加载失败,请重试'
} finally {
loading.value = false
if (pageRequest.isCurrent(requestId)) loading.value = false
}
}
const bmiDisplay = computed(() => {
if (dashboard.value?.bmi) {
if (typeof dashboard.value?.bmi === 'number' && Number.isFinite(dashboard.value.bmi) && dashboard.value.bmi > 0) {
return {
val: dashboard.value.bmi,
status: dashboard.value.bmiStatus || '正常'
status: dashboard.value.bmiStatus || '未提供分类'
}
}
if (assessment.value?.bmi) {
if (typeof assessment.value?.bmi === 'number' && Number.isFinite(assessment.value.bmi) && assessment.value.bmi > 0) {
return {
val: assessment.value.bmi,
status: assessment.value.bmiStatus || '正常'
status: assessment.value.bmiStatus || '未提供分类'
}
}
return null
@@ -45,12 +55,7 @@ const bmiDisplay = computed(() => {
const ageDisplay = computed(() => {
const raw = dashboard.value?.profile?.birthday || assessment.value?.basicInfo.birthday
if (!raw) return undefined
const birthday = new Date(raw)
const today = new Date()
let age = today.getFullYear() - birthday.getFullYear()
if (today < new Date(today.getFullYear(), birthday.getMonth(), birthday.getDate())) age--
return age
return ageFromBirthday(raw)
})
// 健康评估只展示生活习惯画像;体质由中医四诊独立判定。
@@ -64,7 +69,7 @@ const profileTags = computed(() => {
list.push({ label: '夜间偏热感', desc: '自述夜间较容易感到闷热', type: 'blue' })
} else if (ans.temperaturePreference === 'cold_fear') {
list.push({ label: '夜间偏冷感', desc: '自述睡眠时较容易感到寒冷', type: 'gold' })
} else {
} else if (ans.temperaturePreference === 'normal') {
list.push({ label: '冷热感受适中', desc: '自述夜间温度感受较平稳', type: 'green' })
}
@@ -86,19 +91,23 @@ const profileTags = computed(() => {
})
function handleLogout() {
if (!active || logoutBusy.value) return
logoutBusy.value = true
showConfirmDialog({
title: '确认退出',
message: '确定要退出当前账号登录吗?'
}).then(() => {
if (!active) return
clearTokens()
showToast('已安全退出')
router.replace('/auth')
}).catch(() => {})
}).catch(() => {}).finally(() => { if (active) logoutBusy.value = false })
}
onMounted(() => {
loadData()
})
onUnmounted(() => { active = false })
</script>
<template>
@@ -110,7 +119,7 @@ onMounted(() => {
<span>{{ (dashboard?.user.displayName || '客').slice(0, 1) }}</span>
</div>
<div class="user-info">
<h2>{{ dashboard?.user.displayName || '尊贵会员' }}</h2>
<h2>{{ dashboard?.user.displayName || '我的健康档案' }}</h2>
<p class="phone-text">{{ dashboard?.user.phone || '健康管理账号' }}</p>
<span class="user-badge">常青树 · 智能健康睡眠会员</span>
</div>
@@ -118,17 +127,22 @@ onMounted(() => {
</header>
<main class="profile-content">
<section v-if="loading" class="profile-card page-load-state" role="status"><span class="page-loading-mark" aria-hidden="true"></span><p>正在读取您的健康档案…</p></section>
<section v-else-if="loadError" class="profile-card page-error-state" role="alert">
<h3>暂时无法读取健康档案</h3><p>{{ loadError }}</p><button class="retry-button" type="button" @click="loadData">重新加载</button>
</section>
<template v-else-if="dashboard">
<!-- 身体指标基线档案 -->
<section class="profile-card">
<div class="card-head">
<h3>身体基线档案</h3>
<button class="edit-btn" @click="router.replace('/assessment')">编辑指标</button>
<button class="edit-btn" type="button" @click="router.replace('/assessment')">编辑指标</button>
</div>
<div class="metrics-grid">
<div class="metric-item">
<span class="m-label">性别</span>
<strong class="m-val">{{ dashboard?.profile?.gender || assessment?.basicInfo.gender || '已保密' }}</strong>
<strong class="m-val">{{ dashboard?.profile?.gender || assessment?.basicInfo.gender || '未填写' }}</strong>
</div>
<div class="metric-item">
<span class="m-label">身高</span>
@@ -142,7 +156,7 @@ onMounted(() => {
<span class="m-label">体重</span>
<strong class="m-val">{{ dashboard?.profile?.weightKg || assessment?.basicInfo.weightKg || '--' }} <small>kg</small></strong>
</div>
<div class="metric-item">
<div class="metric-item bmi-metric">
<span class="m-label">BMI 指数</span>
<strong class="m-val" :class="{ highlight: bmiDisplay }">
{{ bmiDisplay?.val || '--' }}
@@ -155,7 +169,7 @@ onMounted(() => {
<!-- 专属评估画像标签 -->
<section class="profile-card">
<div class="card-head">
<h3>健康评估画像与专属策略</h3>
<h3>生活习惯画像</h3>
<span class="status-indicator" :class="{ done: assessment?.hasCompletedAssessment }">
{{ assessment?.hasCompletedAssessment ? '已完成评估' : '未评估' }}
</span>
@@ -208,8 +222,8 @@ onMounted(() => {
<!-- 当前健康方案 -->
<section class="profile-card" v-if="dashboard?.currentPlan">
<div class="card-head">
<h3>当前执行方案</h3>
<span class="version-tag">版本 v{{ dashboard.currentPlan.version }}.0</span>
<h3>日常健康建议</h3>
<span class="version-tag">版本 v{{ dashboard.currentPlan.version }}</span>
</div>
<p class="plan-summary-text">{{ dashboard.currentPlan.summary }}</p>
@@ -231,7 +245,7 @@ onMounted(() => {
<!-- 快捷功能入口与操作菜单 -->
<section class="action-card-menu">
<div class="menu-item" @click="router.replace('/assessment')">
<button class="menu-item" type="button" @click="router.replace('/assessment')">
<div class="menu-left">
<div class="menu-icon icon-assess">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
@@ -247,9 +261,9 @@ onMounted(() => {
</div>
</div>
<span class="arrow">&gt;</span>
</div>
</button>
<div class="menu-item" @click="router.push('/device-control')">
<button class="menu-item" type="button" @click="router.push('/device-control')">
<div class="menu-left">
<div class="menu-icon icon-device">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
@@ -263,16 +277,17 @@ onMounted(() => {
</div>
<div class="menu-text">
<strong>设备详情与参数调节</strong>
<small>查看7台设备状态、独立开关与精细参数设置</small>
<small>查看设备状态、快捷开关与完整参数</small>
</div>
</div>
<span class="arrow">&gt;</span>
</div>
</button>
</section>
</template>
<!-- 退出登录按钮 -->
<div class="logout-wrap">
<button class="logout-btn" @click="handleLogout">
<button class="logout-btn" type="button" :disabled="logoutBusy" @click="handleLogout">
退出登录
</button>
</div>
+57 -13
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { showFailToast, showSuccessToast } from 'vant'
import AppTabBar from '../components/AppTabBar.vue'
import { usePageRequest } from '../composables/usePageRequest'
import { healthApi, type TcmAssessmentResult } from '../services/health'
type Question = { key: string; title: string; options: { value: string; label: string }[] }
@@ -75,42 +76,70 @@ const notes = reactive<Record<string, string>>({ tongue: '', odor: '', breath: '
const result = ref<TcmAssessmentResult>()
const loading = ref(true)
const saving = ref(false)
const loadError = ref('')
const savedSnapshot = ref<string>()
const pageRequest = usePageRequest()
const questions = sections.flatMap(section => section.questions)
const answeredCount = computed(() => questions.filter(question => answers[question.key]).length)
const validAnswer = (question: Question) => question.options.some(option => option.value === answers[question.key])
const answeredCount = computed(() => questions.filter(validAnswer).length)
const formSnapshot = () => JSON.stringify({
answers: Object.fromEntries(questions.map(question => [question.key, answers[question.key] ?? ''])),
notes: Object.fromEntries(Object.keys(notes).sort().map(key => [key, notes[key] ?? '']))
})
const resultOutdated = computed(() => result.value?.hasCompleted && savedSnapshot.value !== formSnapshot())
onMounted(async () => {
async function loadExisting() {
if (saving.value) return
const requestId = pageRequest.begin()
loading.value = true
loadError.value = ''
try {
const saved = await healthApi.getTcmAssessment()
if (!pageRequest.isCurrent(requestId)) return
if (!saved) throw new Error('四诊记录读取失败,请重试')
if (saved.hasCompleted) {
result.value = saved
Object.assign(answers, saved.answers)
Object.assign(notes, saved.notes)
savedSnapshot.value = formSnapshot()
}
} catch (error) {
showFailToast(error instanceof Error ? error.message : '四诊记录加载失败')
if (!pageRequest.isCurrent(requestId)) return
loadError.value = error instanceof Error ? error.message : '四诊记录加载失败,请重试'
} finally {
loading.value = false
if (pageRequest.isCurrent(requestId)) loading.value = false
}
})
}
onMounted(loadExisting)
async function submit() {
const missing = questions.find(question => !answers[question.key])
if (loading.value || loadError.value || saving.value) return
const missing = questions.find(question => !validAnswer(question))
if (missing) {
showFailToast(`请先选择“${missing.title}”`)
document.getElementById(`tcm-${missing.key}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
const requestId = pageRequest.begin()
saving.value = true
try {
result.value = await healthApi.saveTcmAssessment({
const saved = await healthApi.saveTcmAssessment({
answers: { ...answers }, notes: { ...notes }
})
if (!pageRequest.isCurrent(requestId)) return
if (!saved?.hasCompleted) throw new Error('未生成完整体质结果,请重试')
result.value = saved
savedSnapshot.value = formSnapshot()
showSuccessToast('体质倾向已生成,下次一键启动将使用新参数')
await nextTick()
if (!pageRequest.isCurrent(requestId)) return
document.getElementById('tcm-result')?.scrollIntoView({ behavior: 'smooth' })
} catch (error) {
if (!pageRequest.isCurrent(requestId)) return
showFailToast(error instanceof Error ? error.message : '生成失败,请稍后重试')
} finally {
saving.value = false
if (pageRequest.isCurrent(requestId)) saving.value = false
}
}
</script>
@@ -121,11 +150,13 @@ async function submit() {
<p class="eyebrow">EVERGREEN · FOUR DIAGNOSES</p>
<h1>中医四诊</h1>
<p>填写望、闻、问、切的观察信息,生成九大体质中的演示倾向与家居参数。</p>
<div class="progress-row"><span>已完成 {{ answeredCount }}/{{ questions.length }} 项</span><span>{{ result?.hasCompleted ? '已生成结果' : '待生成' }}</span></div>
<div class="progress-row"><span>已完成 {{ answeredCount }}/{{ questions.length }} 项</span><span>{{ loading ? '读取中' : loadError ? '读取失败' : resultOutdated ? '修改待更新' : result?.hasCompleted ? '已生成结果' : '待生成' }}</span></div>
<progress class="assessment-progress" :value="answeredCount" :max="questions.length" aria-label="四诊必选项完成进度" />
</header>
<section v-if="result?.hasCompleted" id="tcm-result" class="tcm-card result-card">
<div class="result-top"><span>当前体质倾向</span><small>演示参考 · 非医学诊断</small></div>
<div class="result-top"><span>{{ resultOutdated ? '上次保存的体质倾向' : '当前体质倾向' }}</span><small>演示参考 · 非医学诊断</small></div>
<p v-if="resultOutdated" class="result-change-note" role="status">答案已修改,以下仍是上次保存的结果与参数。请点击重新评估,更新后才会用于一键启动。</p>
<h2>{{ result.constitutionName }}</h2>
<p v-if="result.secondaryName">次要倾向:{{ result.secondaryName }}</p>
<p>{{ result.summary }}</p>
@@ -139,12 +170,19 @@ async function submit() {
<span>一键启动预设</span>
<p>湿度 {{ result.sceneConfig.targetHumidity }}% · 灯光 {{ result.sceneConfig.lightColorTemperatureKelvin }}K</p>
<p>香氛{{ result.sceneConfig.enableFragrance ? '开启' : '关闭' }} · 体质音乐循环播放,从 50% 每 5 秒降 1% 至停止</p>
<p>床垫加热按重庆冬季天气决定;软硬度按健康档案 BMI 给出可手动修改的演示起点。</p>
<p>床垫加热根据当前室温与体质建议区间决定;室温不可用时不自动加热。软硬度按健康档案 BMI 给出演示起点,可手动调整。</p>
</div>
</section>
<div v-if="loading" class="tcm-card">正在读取四诊记录…</div>
<div v-if="loading" class="tcm-card page-load-state" role="status">正在读取四诊记录…</div>
<section v-else-if="loadError" class="tcm-card page-error-state" role="alert">
<h2>暂时无法读取四诊记录</h2>
<p>{{ loadError }}</p>
<p>为避免覆盖已保存的结果,请重试后再填写。</p>
<button class="retry-button" type="button" @click="loadExisting">重新加载</button>
</section>
<template v-else>
<fieldset class="tcm-form" :disabled="saving" :inert="saving || undefined" :aria-busy="saving">
<section v-for="section in sections" :key="section.title" class="tcm-card">
<div class="section-heading"><h2>{{ section.title }}</h2><p>{{ section.hint }}</p></div>
<div v-for="question in section.questions" :id="`tcm-${question.key}`" :key="question.key" class="question">
@@ -163,6 +201,7 @@ async function submit() {
</template>
<label v-if="section.title === '切诊'" class="note-label">切诊备注(选填)<textarea v-model="notes.pulseNote" maxlength="300" placeholder="如有专业测量结果可补充" /></label>
</section>
</fieldset>
<button class="submit-button" type="button" :disabled="saving" @click="submit">
{{ saving ? '正在生成…' : result?.hasCompleted ? '重新评估并更新参数' : '生成我的体质与家居参数' }}
</button>
@@ -180,6 +219,11 @@ async function submit() {
.tcm-header > p:not(.eyebrow) { color: #71837b; font-size: 13px; line-height: 1.65; }
.progress-row { display: flex; justify-content: space-between; color: #527169; font-size: 12px; margin-top: 18px; }
.tcm-card { background: #fff; border: 1px solid #e9eee9; box-shadow: 0 8px 26px rgba(18, 62, 55, .06); border-radius: 20px; padding: 20px; margin-bottom: 15px; }
.tcm-form { min-width: 0; margin: 0; padding: 0; border: 0; }
.tcm-form[disabled] { opacity: .72; }
.page-error-state h2 { margin: 0 0 8px; font-size: 17px; }
.page-error-state p { color: #71837b; font-size: 13px; line-height: 1.6; }
.result-change-note { padding: 10px 12px; border-radius: 10px; background: rgba(238,221,187,.15); color: #f2e4c8; }
.section-heading { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; border-bottom: 1px solid #edf1ee; padding-bottom: 12px; }
.section-heading h2 { margin: 0; font-size: 19px; }
.section-heading p { margin: 0; color: #86958d; font-size: 11px; text-align: right; }