Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20467ca479 | ||
|
|
563836f789 | ||
|
|
4925c4c74d | ||
|
|
65eff42cc8 |
@@ -4,6 +4,12 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview --host 0.0.0.0"
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// 全站页面的假数据逻辑和 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, compileStyle } 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'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reduced-motion stops all loading animations rather than making infinite loops faster', () => {
|
||||||
|
const readStylesheet = relative => {
|
||||||
|
const filename = path.join(root, relative)
|
||||||
|
const result = compileStyle({ source: fs.readFileSync(filename, 'utf8'), filename, id: 'test-motion' })
|
||||||
|
assert.deepEqual(result.errors, [], relative)
|
||||||
|
return result.rawResult.root
|
||||||
|
}
|
||||||
|
const stylesheet = readStylesheet('styles/page-polish.css')
|
||||||
|
const reducedMotion = stylesheet.nodes.find(node => node.type === 'atrule'
|
||||||
|
&& node.name === 'media' && node.params === '(prefers-reduced-motion: reduce)')
|
||||||
|
assert.ok(reducedMotion, 'keep the reduced-motion preference supported')
|
||||||
|
const rule = reducedMotion.nodes.find(node => node.type === 'rule'
|
||||||
|
&& ['*', '*::before', '*::after'].every(selector => node.selectors.includes(selector)))
|
||||||
|
assert.ok(rule, 'cover page indicators, device indicators and Vant loading animations')
|
||||||
|
const declarations = Object.fromEntries(rule.nodes.filter(node => node.type === 'decl')
|
||||||
|
.map(node => [node.prop, node]))
|
||||||
|
assert.equal(declarations.animation?.value, 'none')
|
||||||
|
assert.equal(declarations.animation?.important, true)
|
||||||
|
assert.equal(declarations['transition-duration']?.value, '0s')
|
||||||
|
assert.equal(declarations['transition-duration']?.important, true)
|
||||||
|
reducedMotion.walkDecls('animation-duration', declaration => {
|
||||||
|
assert.notEqual(declaration.value, '.01ms', 'never accelerate an infinite loading animation')
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadingRule = stylesheet.nodes.find(node => node.type === 'rule' && node.selector === '.page-loading-mark')
|
||||||
|
assert.ok(loadingRule.nodes.some(node => node.prop === 'animation' && node.value === 'page-spin .9s linear infinite'),
|
||||||
|
'normal motion keeps the existing loading speed')
|
||||||
|
const deviceStyles = readStylesheet('styles/device-control.css')
|
||||||
|
const deviceLoadingRule = deviceStyles.nodes.find(node => node.type === 'rule' && node.selector === '.control-page .operation-spinner')
|
||||||
|
assert.ok(deviceLoadingRule.nodes.some(node => node.prop === 'animation' && node.value === 'operation-spin 0.75s linear infinite'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('loading indicators keep visible status text even when their animation is stopped', async () => {
|
||||||
|
for (const [name, routePath, message] of [
|
||||||
|
['DashboardView', '/dashboard', '正在读取健康档案与舒睡设备…'],
|
||||||
|
['ProfileView', '/profile', '正在读取您的健康档案…'],
|
||||||
|
]) {
|
||||||
|
const html = await render(name, { loading: true }, routePath)
|
||||||
|
assert.ok(html.includes('page-loading-mark'), name)
|
||||||
|
assert.ok(html.includes(message), name)
|
||||||
|
assert.ok(html.includes('role="status"'), name)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,665 @@
|
|||||||
|
// 无真实网络/家具的回归检查;使用现有 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 watchers = []
|
||||||
|
const vue = {
|
||||||
|
ref: value => ({ value }), reactive: value => value,
|
||||||
|
computed: getter => ({ get value() { return typeof getter === 'function' ? getter() : getter.get() }, set value(value) { getter.set?.(value) } }),
|
||||||
|
watch: (getter, callback) => { watchers.push({ getter, callback, value: 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,
|
||||||
|
flushWatches: () => { for (const watcher of watchers) { const value = watcher.getter(); if (value !== watcher.value) { watcher.callback(value, watcher.value); watcher.value = value } } },
|
||||||
|
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 standardLight = (id = 'light.strip', mode = 'color_temp', attributes = {}) => device('', 'Lighting', [entity(id, 'light', 'on', {
|
||||||
|
supported_color_modes: [mode], min_color_temp_kelvin: 2700, max_color_temp_kelvin: 6500, supported_features: 4,
|
||||||
|
brightness: 128, color_temp_kelvin: 3000, effect_list: ['阅读', '助眠'], effect: '阅读', ...attributes,
|
||||||
|
})], { deviceId: id, primaryControlEntityId: id })
|
||||||
|
|
||||||
|
test('configured strip defaults to its supported sleep effect without inventing a reported effect or sending on page load', () => {
|
||||||
|
const env = environment(), { useLightControls } = env.load('devices/lighting/useLightControls')
|
||||||
|
const item = standardLight('light.strip', 'color_temp', { effect: null, effect_list: ['阅读模式', '助眠模式'] })
|
||||||
|
item.moduleKey = 'sleep-light-strip'; item.defaultLightEffect = '助眠模式'
|
||||||
|
let writes = 0
|
||||||
|
const hook = useLightControls(() => item, () => false, async () => { writes++; return true })
|
||||||
|
assert.equal(hook.effectTarget.value, '助眠模式'); assert.equal(hook.defaultEffect.value, '助眠模式')
|
||||||
|
assert.equal(hook.capability.value.effect, undefined); assert.equal(writes, 0)
|
||||||
|
item.entities[0].attributes.effect = '阅读模式'
|
||||||
|
assert.equal(hook.effectTarget.value, '阅读模式'); assert.equal(hook.capability.value.effect, '阅读模式')
|
||||||
|
item.defaultLightEffect = '未声明模式'; item.entities[0].attributes.effect = null
|
||||||
|
assert.equal(hook.effectTarget.value, ''); assert.equal(hook.defaultEffect.value, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multiple lights use only their own fade status and continue polling when the original light has stopped', async () => {
|
||||||
|
let current = { entityId: 'light.primary', running: false, brightnessPercent: 0, mode: 'none', lights: [
|
||||||
|
{ entityId: 'light.primary', running: false, brightnessPercent: 0, mode: 'none' },
|
||||||
|
{ entityId: 'light.strip', running: true, brightnessPercent: 46, mode: 'sleep' },
|
||||||
|
] }
|
||||||
|
const env = environment({ lightTransitionStatus: async () => current })
|
||||||
|
const helpers = env.load('devices/lighting/transitions'), { deviceSummary } = env.load('devices/deviceSummary')
|
||||||
|
const strip = standardLight(); strip.moduleKey = 'sleep-light-strip'
|
||||||
|
assert.equal(helpers.lightTransitionForDevice(strip, current).brightnessPercent, 46)
|
||||||
|
assert.equal(helpers.anyLightTransitionRunning(current), true)
|
||||||
|
assert.equal(helpers.lightTransitionForDevice(standardLight('light.unrelated'), current), undefined)
|
||||||
|
const summary = deviceSummary(strip, { light: current })
|
||||||
|
assert.equal(summary.statusText, '助眠渐暗中'); assert.equal(summary.metricText, '亮度 46%')
|
||||||
|
let ended = 0
|
||||||
|
const params = env.load('devices/parameterRegistry').createDeviceParameterState()
|
||||||
|
params.lightBrightness = 10
|
||||||
|
const hook = env.load('composables/useDeviceTransitions').useDeviceTransitions(params, async () => { ended++ })
|
||||||
|
await hook.syncLightTransitionStatus()
|
||||||
|
assert.equal(env.intervals.size, 1); assert.equal(params.lightBrightness, 10)
|
||||||
|
current = { ...current, lights: current.lights.map(light => ({ ...light, running: false, brightnessPercent: 0 })) }
|
||||||
|
for (const tick of [...env.intervals.values()]) tick()
|
||||||
|
await flush(); assert.equal(env.intervals.size, 0); assert.equal(ended, 1)
|
||||||
|
env.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('explicit entity markers and legacy single-light responses cannot leak into another lamp', () => {
|
||||||
|
const helpers = environment().load('devices/lighting/transitions')
|
||||||
|
const primary = standardLight('light.primary'); primary.moduleKey = 'lighting'
|
||||||
|
const strip = standardLight()
|
||||||
|
const legacy = { running: true, brightnessPercent: 50, mode: 'sleep' }
|
||||||
|
assert.equal(helpers.lightTransitionForDevice(primary, legacy), legacy)
|
||||||
|
assert.equal(helpers.lightTransitionForDevice(strip, legacy), undefined)
|
||||||
|
assert.equal(helpers.lightTransitionForDevice(primary, { ...legacy, entityId: 'light.strip' }), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a failed new-light fade is not reported as success merely because the original light finished', async () => {
|
||||||
|
let current = { entityId: 'light.primary', running: true, success: true, brightnessPercent: 1, mode: 'sleep', lights: [
|
||||||
|
{ entityId: 'light.primary', running: true, success: true, brightnessPercent: 1, mode: 'sleep' },
|
||||||
|
{ entityId: 'light.strip', running: true, success: true, brightnessPercent: 2, mode: 'sleep' },
|
||||||
|
] }
|
||||||
|
const env = environment({ lightTransitionStatus: async () => current })
|
||||||
|
const hook = env.load('composables/useDeviceControl').useDeviceControl(); await hook.loadStatus()
|
||||||
|
current = { ...current, running: false, message: '原灯渐暗完成', lights: [
|
||||||
|
{ entityId: 'light.primary', running: false, success: true, mode: 'sleep', message: '原灯渐暗完成' },
|
||||||
|
{ entityId: 'light.strip', running: false, success: false, mode: 'sleep', message: '灯带离线,渐变中断' },
|
||||||
|
] }
|
||||||
|
for (const tick of [...env.intervals.values()]) tick()
|
||||||
|
await flush(); await flush()
|
||||||
|
assert.equal(hook.actionFeedback.value.type, 'fail')
|
||||||
|
assert.equal(hook.actionFeedback.value.message, '灯带离线,渐变中断')
|
||||||
|
env.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('standard light capabilities are per entity, not per category or another lamps configured parameters', () => {
|
||||||
|
const { lightCapabilities } = environment().load('devices/lighting/capabilities')
|
||||||
|
const item = standardLight(), capability = lightCapabilities(item)
|
||||||
|
assert.equal(capability.available, true); assert.equal(capability.canDim, true); assert.equal(capability.brightnessPercent, 50)
|
||||||
|
assert.equal(capability.temperatureRange.min, 2700); assert.equal(capability.temperatureRange.max, 6500)
|
||||||
|
assert.equal(capability.colorTemperature, 3000); assert.equal(capability.effect, '阅读')
|
||||||
|
const simple = lightCapabilities(standardLight('light.simple', 'onoff', { effect_list: [], supported_features: 0 }))
|
||||||
|
assert.equal(simple.canDim, false); assert.equal(simple.temperatureRange, undefined); assert.equal(simple.effects.length, 0)
|
||||||
|
const dim = lightCapabilities(standardLight('light.dim', 'brightness'))
|
||||||
|
assert.equal(dim.canDim, true); assert.equal(dim.temperatureRange, undefined)
|
||||||
|
const rgb = lightCapabilities(standardLight('light.rgb', 'rgb'))
|
||||||
|
assert.equal(rgb.canDim, true); assert.equal(rgb.temperatureRange, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('light unknown main state, foreign primary and missing capability cannot borrow online auxiliary data', () => {
|
||||||
|
const { lightCapabilities } = environment().load('devices/lighting/capabilities'), item = standardLight()
|
||||||
|
item.entities.push(entity('light.foreign', 'light', 'on', { supported_color_modes: ['color_temp'] }))
|
||||||
|
item.entities[0].state = 'unknown'; assert.equal(lightCapabilities(item).available, false)
|
||||||
|
assert.equal(lightCapabilities(item).brightnessPercent, undefined); assert.equal(lightCapabilities(item).colorTemperature, undefined)
|
||||||
|
item.entities[0].state = 'on'; item.entities[0].available = false
|
||||||
|
assert.equal(lightCapabilities(item).available, false)
|
||||||
|
item.primaryControlEntityId = 'light.missing'; assert.equal(lightCapabilities(item).entity, undefined)
|
||||||
|
const noCapabilities = standardLight(); noCapabilities.entities[0].attributes = {}
|
||||||
|
assert.equal(lightCapabilities(noCapabilities).canDim, false); assert.equal(lightCapabilities(noCapabilities).temperatureRange, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('light ranges, feature flags and reported readings reject malformed or unsupported values', () => {
|
||||||
|
const { lightCapabilities } = environment().load('devices/lighting/capabilities')
|
||||||
|
for (const min of [undefined, 0, -1, 2700.5, 'garbage'])
|
||||||
|
assert.equal(lightCapabilities(standardLight('light.strip', 'color_temp', { min_color_temp_kelvin: min })).temperatureRange, undefined)
|
||||||
|
for (const max of [undefined, 2700, 2000, Infinity, 3000000000])
|
||||||
|
assert.equal(lightCapabilities(standardLight('light.strip', 'color_temp', { max_color_temp_kelvin: max })).temperatureRange, undefined)
|
||||||
|
for (const supported_features of [undefined, 0, 3, -1, 4.5, Infinity])
|
||||||
|
assert.equal(lightCapabilities(standardLight('light.strip', 'color_temp', { supported_features })).effects.length, 0)
|
||||||
|
const item = standardLight('light.strip', 'color_temp', { brightness: null, color_temp_kelvin: null, effect: '未声明模式' })
|
||||||
|
assert.equal(lightCapabilities(item).brightnessPercent, undefined); assert.equal(lightCapabilities(item).effect, undefined)
|
||||||
|
item.entities[0].state = 'off'; assert.equal(lightCapabilities(item).brightnessPercent, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('light drafts are instance-local and standard sends use exactly the displayed devices main entity', async () => {
|
||||||
|
const env = environment(), { useLightControls } = env.load('devices/lighting/useLightControls'), writes = []
|
||||||
|
let item = standardLight('light.first'), busy = false
|
||||||
|
const hook = useLightControls(() => item, () => busy, async (...args) => { writes.push(args); return true })
|
||||||
|
hook.brightnessTarget.value = 61; await hook.setBrightness()
|
||||||
|
assert.equal(writes[0][0], 'light.first'); assert.equal(writes[0][1], 'light_brightness'); assert.equal(writes[0][2], 61); assert.equal(writes[0][3], 'light.first')
|
||||||
|
hook.temperatureTarget.value = 6500; await hook.setTemperature()
|
||||||
|
assert.equal(writes[1][1], 'light_color_temperature'); assert.equal(writes[1][2], 6500)
|
||||||
|
hook.effectTarget.value = '助眠'; await hook.setEffect(); assert.equal(writes[2][1], 'light_effect')
|
||||||
|
hook.temperatureTarget.value = 2200; assert.equal(await hook.setTemperature(), false)
|
||||||
|
hook.effectTarget.value = '删除遥控器'; assert.equal(await hook.setEffect(), false)
|
||||||
|
busy = true; hook.brightnessTarget.value = 60; assert.equal(await hook.setBrightness(), false)
|
||||||
|
busy = false; hook.brightnessTarget.value = 87
|
||||||
|
item = standardLight('light.second', 'brightness', { brightness: 51 }); env.flushWatches()
|
||||||
|
assert.equal(hook.brightnessTarget.value, 20); assert.equal(hook.capability.value.temperatureRange, undefined)
|
||||||
|
await hook.setBrightness(); assert.equal(writes[3][0], 'light.second'); assert.equal(writes[3][3], 'light.second')
|
||||||
|
item.entities[0].state = 'unavailable'; assert.equal(await hook.setBrightness(), false)
|
||||||
|
assert.equal(writes.length, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('late light operation cannot reset another devices draft and failed operation restores true reading', async () => {
|
||||||
|
const env = environment(), { useLightControls } = env.load('devices/lighting/useLightControls'), pending = deferred()
|
||||||
|
let item = standardLight('light.first')
|
||||||
|
const hook = useLightControls(() => item, () => false, () => pending.promise)
|
||||||
|
hook.brightnessTarget.value = 70; const task = hook.setBrightness()
|
||||||
|
item = standardLight('light.second'); env.flushWatches(); hook.brightnessTarget.value = 83
|
||||||
|
pending.resolve(false); await task
|
||||||
|
assert.equal(hook.brightnessTarget.value, 83)
|
||||||
|
const failed = useLightControls(() => item, () => false, async () => false)
|
||||||
|
failed.brightnessTarget.value = 70; await failed.setBrightness()
|
||||||
|
assert.equal(failed.brightnessTarget.value, 50)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('new light overview does not inherit the configured bedroom lamps transition runtime', () => {
|
||||||
|
const { deviceSummary } = environment().load('devices/deviceSummary'), item = standardLight()
|
||||||
|
const summary = deviceSummary(item, { light: { running: true, mode: 'wake', brightnessPercent: 77 } })
|
||||||
|
assert.equal(summary.statusText, '已点亮'); assert.equal(summary.metricText, '亮度 50% · 3000K')
|
||||||
|
assert.equal(summary.canToggle, true); assert.equal(summary.icon, 'lighting')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('readonly generic button unknown state is not mislabeled as an unavailable action', () => {
|
||||||
|
const { entityStateLabel } = environment().load('devices/entityState')
|
||||||
|
assert.equal(entityStateLabel(entity('button.test', 'button', 'unknown')), '动作按钮(尚未触发)')
|
||||||
|
assert.equal(entityStateLabel(entity('button.test', 'button', 'unavailable', {}, false)), '不可用')
|
||||||
|
assert.equal(entityStateLabel(entity('sensor.test', 'sensor', 'unknown', {}, false)), '状态未回读')
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
// 渲染真实 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,
|
||||||
|
supported_color_modes: ['color_temp'], min_color_temp_kelvin: 2700, max_color_temp_kelvin: 6500,
|
||||||
|
color_temp_kelvin: 3000, supported_features: 4, effect_list: ['阅读', '助眠'], effect: '阅读' })], { 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
const standardLight = (mode = 'color_temp', attributes = {}) => device('strip', '新灯带', '', 'Lighting', [reading('light.strip', 'light', 'off', {
|
||||||
|
supported_color_modes: [mode], min_color_temp_kelvin: 2700, max_color_temp_kelvin: 6500, supported_features: 4,
|
||||||
|
brightness: null, color_temp_kelvin: null, effect: null, effect_list: ['阅读模式', '助眠模式'], ...attributes,
|
||||||
|
}), { ...reading('number.packed', 'number', '842138120'), name: '内部保护设置' }], { primaryControlEntityId: 'light.strip' })
|
||||||
|
const lightSnapshot = item => ({ connected: true, devices: [item], summary: { totalDevices: 1, onlineDevices: item.online ? 1 : 0 }, controlParameters: {} })
|
||||||
|
|
||||||
|
test('configured sleep strip preselects its default and displays its own fade without copying wake or massage features', async () => {
|
||||||
|
const item = standardLight(); item.moduleKey = 'sleep-light-strip'; item.defaultLightEffect = '助眠模式'
|
||||||
|
item.entities[0].state = 'on'; item.entities[0].attributes.brightness = 128
|
||||||
|
const transition = { entityId: 'light.room', running: true, brightnessPercent: 11, mode: 'wake', lights: [
|
||||||
|
{ entityId: 'light.room', running: true, brightnessPercent: 11, mode: 'wake' },
|
||||||
|
{ entityId: 'light.strip', running: true, brightnessPercent: 46, mode: 'sleep' },
|
||||||
|
] }
|
||||||
|
const { html } = await render('/device-control/strip', { haStatus: ref(lightSnapshot(item)), lightTransition: ref(transition) })
|
||||||
|
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)[0]
|
||||||
|
assert.ok(card.includes('助眠渐暗中 · 46%')); assert.match(card, /<strong class="p-val"[^>]*>46%<\/strong>/)
|
||||||
|
assert.match(card, /<option value="助眠模式"[^>]* selected>/)
|
||||||
|
assert.ok(card.includes('舒睡默认 助眠模式')); assert.ok(card.includes('已加入一键舒睡和关闭'))
|
||||||
|
assert.ok(card.includes('每5秒降低1%')); assert.ok(card.includes('实际模式以顶部回读为准'))
|
||||||
|
for (const text of ['晨起唤醒', '灯灭停止按摩', '11%', '不自动加入舒睡']) assert.ok(!card.includes(text), text)
|
||||||
|
const overview = (await render('/device-control', { haStatus: ref(lightSnapshot(item)), lightTransition: ref(transition) })).html
|
||||||
|
assert.ok(overview.includes('助眠渐暗中')); assert.ok(overview.includes('亮度 46%'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('both configured light details keep their own runtime brightness when displayed from one shared response', async () => {
|
||||||
|
const strip = standardLight(); strip.moduleKey = 'sleep-light-strip'; strip.defaultLightEffect = '助眠模式'; strip.entities[0].state = 'on'
|
||||||
|
const snapshot = { ...lightSnapshot(strip), devices: [devices[0], strip] }
|
||||||
|
const transition = { entityId: 'light.room', running: true, brightnessPercent: 11, mode: 'sleep', lights: [
|
||||||
|
{ entityId: 'light.room', running: true, brightnessPercent: 11, mode: 'sleep' },
|
||||||
|
{ entityId: 'light.strip', running: true, brightnessPercent: 46, mode: 'sleep' },
|
||||||
|
] }
|
||||||
|
const primary = (await render('/device-control/lamp', { haStatus: ref(snapshot), lightTransition: ref(transition) })).html
|
||||||
|
assert.match(primary, /<strong class="p-val"[^>]*>11%<\/strong>/)
|
||||||
|
const detail = (await render('/device-control/strip', { haStatus: ref(snapshot), lightTransition: ref(transition) })).html
|
||||||
|
assert.match(detail, /<strong class="p-val"[^>]*>46%<\/strong>/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unbound new strip has standard brightness temperature and effects without copying scene light controls', async () => {
|
||||||
|
const item = standardLight()
|
||||||
|
const { html } = await render('/device-control/strip', { haStatus: ref(lightSnapshot(item)), lightTransition: ref({ running: true, mode: 'wake', brightnessPercent: 77 }) })
|
||||||
|
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)[0]
|
||||||
|
for (const text of ['灯光控制', '新灯带', '灯光亮度', '灯光色温调节', '2700K 暖光', '6500K 冷光', '灯光模式', '阅读模式', '助眠模式', '已熄灭']) assert.ok(html.includes(text), text)
|
||||||
|
for (const text of ['开始助眠渐暗', '晨起唤醒中', '842138120', '内部保护设置', '查看全部能力', '设备能力']) assert.ok(!card.includes(text), text)
|
||||||
|
assert.equal((html.match(/van-slider__bar/g) ?? []).length, 2)
|
||||||
|
assert.match(card, /<strong class="p-val"[^>]*>0%<\/strong>/)
|
||||||
|
assert.doesNotMatch(card, /<strong class="p-val"[^>]*>2700K<\/strong>/)
|
||||||
|
assert.ok(html.includes('目标色温 2700K')); assert.ok(!html.includes('van-slider--disabled'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('only actually supported controls render for onoff-only brightness-only and alternate-range lamps', async () => {
|
||||||
|
const item = standardLight('onoff', { supported_features: 0, effect_list: [] })
|
||||||
|
let html = (await render('/device-control/strip', { haStatus: ref(lightSnapshot(item)) })).html
|
||||||
|
assert.ok(html.includes('目前仅提供开关')); assert.ok(!html.includes('van-slider')); assert.ok(!html.includes('灯光色温调节')); assert.ok(!html.includes('选择灯光模式'))
|
||||||
|
item.entities[0].attributes.supported_color_modes = ['brightness']
|
||||||
|
html = (await render('/device-control/strip', { haStatus: ref(lightSnapshot(item)) })).html
|
||||||
|
assert.ok(html.includes('灯光亮度')); assert.ok(!html.includes('灯光色温调节')); assert.equal((html.match(/van-slider__bar/g) ?? []).length, 1)
|
||||||
|
item.entities[0].attributes = { supported_color_modes: ['color_temp'], min_color_temp_kelvin: 2000, max_color_temp_kelvin: 4000 }
|
||||||
|
html = (await render('/device-control/strip', { haStatus: ref(lightSnapshot(item)) })).html
|
||||||
|
assert.ok(html.includes('2000K 暖光')); assert.ok(html.includes('4000K 冷光')); assert.ok(!html.includes('6500K'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('offline main light disables standard controls even when an auxiliary entity remains online', async () => {
|
||||||
|
const item = standardLight(); item.entities[0].available = false; item.entities[0].state = 'unavailable'
|
||||||
|
const { html } = await render('/device-control/strip', { haStatus: ref(lightSnapshot(item)) })
|
||||||
|
assert.ok(html.includes('灯光主控当前不可用')); assert.ok(html.includes('van-slider--disabled'))
|
||||||
|
assert.doesNotMatch(html, /<strong class="p-val"[^>]*>0%<\/strong>/)
|
||||||
|
assert.match(html, /<select[^>]*disabled/)
|
||||||
|
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)[0]
|
||||||
|
assert.ok(card.match(/<button[^>]*disabled/)); assert.ok(!card.includes('目标色温 2700K'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('configured scene light composes shared controls with its own fade and wake extensions', async () => {
|
||||||
|
const { html } = await render('/device-control/lamp')
|
||||||
|
for (const text of ['灯光亮度', '灯光色温调节', '灯光模式', '开始助眠渐暗', '晨起唤醒', '灯灭停止按摩']) assert.ok(html.includes(text), text)
|
||||||
|
assert.equal((html.match(/<section class="device-card"/g) ?? []).length, 1)
|
||||||
|
assert.ok(!html.includes('不自动加入舒睡或唤醒场景'))
|
||||||
|
})
|
||||||
|
|
||||||
|
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/)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -27,10 +27,11 @@ function navigate(target: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="app-tab-bar">
|
<nav class="app-tab-bar" aria-label="主导航">
|
||||||
<div
|
<button type="button"
|
||||||
class="tab-item"
|
class="tab-item"
|
||||||
:class="{ active: currentTab === 'home' }"
|
:class="{ active: currentTab === 'home' }"
|
||||||
|
:aria-current="currentTab === 'home' ? 'page' : undefined"
|
||||||
@click="navigate('home')"
|
@click="navigate('home')"
|
||||||
>
|
>
|
||||||
<div class="tab-icon">
|
<div class="tab-icon">
|
||||||
@@ -40,11 +41,12 @@ function navigate(target: string) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="tab-label">首页</span>
|
<span class="tab-label">首页</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div
|
<button type="button"
|
||||||
class="tab-item"
|
class="tab-item"
|
||||||
:class="{ active: currentTab === 'tcm' }"
|
:class="{ active: currentTab === 'tcm' }"
|
||||||
|
:aria-current="currentTab === 'tcm' ? 'page' : undefined"
|
||||||
@click="navigate('tcm')"
|
@click="navigate('tcm')"
|
||||||
>
|
>
|
||||||
<div class="tab-icon">
|
<div class="tab-icon">
|
||||||
@@ -56,11 +58,12 @@ function navigate(target: string) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="tab-label">中医四诊</span>
|
<span class="tab-label">中医四诊</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div
|
<button type="button"
|
||||||
class="tab-item"
|
class="tab-item"
|
||||||
:class="{ active: currentTab === 'assessment' }"
|
:class="{ active: currentTab === 'assessment' }"
|
||||||
|
:aria-current="currentTab === 'assessment' ? 'page' : undefined"
|
||||||
@click="navigate('assessment')"
|
@click="navigate('assessment')"
|
||||||
>
|
>
|
||||||
<div class="tab-icon">
|
<div class="tab-icon">
|
||||||
@@ -73,11 +76,12 @@ function navigate(target: string) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="tab-label">健康评估</span>
|
<span class="tab-label">健康评估</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div
|
<button type="button"
|
||||||
class="tab-item"
|
class="tab-item"
|
||||||
:class="{ active: currentTab === 'profile' }"
|
:class="{ active: currentTab === 'profile' }"
|
||||||
|
:aria-current="currentTab === 'profile' ? 'page' : undefined"
|
||||||
@click="navigate('profile')"
|
@click="navigate('profile')"
|
||||||
>
|
>
|
||||||
<div class="tab-icon">
|
<div class="tab-icon">
|
||||||
@@ -87,7 +91,7 @@ function navigate(target: string) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="tab-label">我的</span>
|
<span class="tab-label">我的</span>
|
||||||
</div>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -116,6 +120,9 @@ function navigate(target: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||||
|
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||||
|
import { entityStateLabel } from '../../devices/entityState'
|
||||||
|
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">{{ entityStateLabel(entity) }} {{ 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>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||||
|
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||||
|
import { lightCapabilities } from '../../devices/lighting/capabilities'
|
||||||
|
import StandardLightCard from './StandardLightCard.vue'
|
||||||
|
import { lightTransitionForDevice } from '../../devices/lighting/transitions'
|
||||||
|
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
|
||||||
|
const { operationBusy, lightTransition, controlLightTransition } = useDeviceControlContext()
|
||||||
|
const transition = computed(() => lightTransitionForDevice(props.device, lightTransition.value))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<StandardLightCard :device="device" category-label="卧室灯光"
|
||||||
|
:reported-brightness="transition?.running ? transition.brightnessPercent : undefined"
|
||||||
|
note="手动调节会停止当前渐变;本灯保留助眠、唤醒及灯灭停止按摩的专属联动。">
|
||||||
|
<template #before-controls>
|
||||||
|
<div class="light-mode-panel">
|
||||||
|
<div>
|
||||||
|
<strong v-if="transition?.running">{{ transition.mode === 'wake' ? '晨起唤醒中' : '助眠渐暗中' }} · {{ transition.brightnessPercent }}%</strong>
|
||||||
|
<strong v-else>渐变灯光</strong>
|
||||||
|
<small>{{ transition?.message || '开灯从50%逐渐变暗;晨起从当前亮度逐渐变亮至70%' }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="light-mode-actions">
|
||||||
|
<button type="button" :disabled="operationBusy || !lightCapabilities(device).available || !lightCapabilities(device).canDim" @click="controlLightTransition('sleep')">🌙 开始助眠渐暗</button>
|
||||||
|
<button type="button" :disabled="operationBusy || !lightCapabilities(device).available || !lightCapabilities(device).canDim" @click="controlLightTransition('wake')">🌅 晨起唤醒</button>
|
||||||
|
<button v-if="transition?.running" type="button" :disabled="operationBusy" @click="controlLightTransition('stop')">停止渐变</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</StandardLightCard>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<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 { lightCapabilities } from '../../devices/lighting/capabilities'
|
||||||
|
import StandardLightControls from './StandardLightControls.vue'
|
||||||
|
import { lightTransitionForDevice } from '../../devices/lighting/transitions'
|
||||||
|
const props = withDefaults(defineProps<{ device: HomeAssistantDiscoveredDevice; categoryLabel?: string; note?: string; reportedBrightness?: number }>(),
|
||||||
|
{ categoryLabel: '灯光控制' })
|
||||||
|
const { operationBusy, switchingDeviceId, toggleDevice, lightTransition } = useDeviceControlContext()
|
||||||
|
const capability = computed(() => lightCapabilities(props.device))
|
||||||
|
const transition = computed(() => lightTransitionForDevice(props.device, lightTransition.value))
|
||||||
|
const brightness = computed(() => props.reportedBrightness ?? (transition.value?.running ? transition.value.brightnessPercent : undefined))
|
||||||
|
const note = computed(() => props.note ?? (props.device.defaultLightEffect
|
||||||
|
? '已加入一键舒睡和关闭;从50%开始,每5秒降低1%,最终关灯。手动调节仅停止本灯渐变。'
|
||||||
|
: '按本灯实际上报的能力显示参数;单独控制,不自动加入舒睡或唤醒场景。'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="device-card">
|
||||||
|
<div class="device-head">
|
||||||
|
<div class="dev-info"><span class="category-tag">{{ categoryLabel }}</span><h3>{{ device.name }}</h3>
|
||||||
|
<span class="status-dot" :class="{ online: capability.available }">{{ capability.available ? capability.entity?.state === 'on' ? '已点亮' : '已熄灭' : '灯光主控当前不可用' }}</span>
|
||||||
|
</div>
|
||||||
|
<VanButton size="small" round :type="capability.entity?.state === 'on' ? 'primary' : 'default'"
|
||||||
|
:loading="switchingDeviceId === device.deviceId" :disabled="operationBusy || !capability.available" @click="toggleDevice(device)">
|
||||||
|
{{ capability.available ? capability.entity?.state === 'on' ? '关闭' : '开启' : '暂不可用' }}
|
||||||
|
</VanButton>
|
||||||
|
</div>
|
||||||
|
<div class="params-body">
|
||||||
|
<slot name="before-controls" />
|
||||||
|
<p v-if="device.defaultLightEffect && (transition?.running || transition?.message)" class="scale-note"
|
||||||
|
:class="{ unavailable: transition?.success === false }">{{ transition?.running ? `助眠渐暗中 · ${transition.brightnessPercent}%` : transition?.message }}</p>
|
||||||
|
<StandardLightControls :device="device" :reported-brightness="brightness" />
|
||||||
|
<slot name="after-controls" />
|
||||||
|
<p class="scale-note">{{ note }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Slider as VanSlider } from 'vant'
|
||||||
|
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||||
|
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||||
|
import { useLightControls } from '../../devices/lighting/useLightControls'
|
||||||
|
const props = defineProps<{ device: HomeAssistantDiscoveredDevice; reportedBrightness?: number }>()
|
||||||
|
const { operationBusy, applyParam } = useDeviceControlContext()
|
||||||
|
const { capability, brightnessReading, brightnessTarget, temperatureTarget, effectTarget, defaultEffect, setBrightness, setTemperature, setEffect } =
|
||||||
|
useLightControls(() => props.device, () => operationBusy.value, applyParam, () => props.reportedBrightness)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="capability.canDim" class="param-row">
|
||||||
|
<div class="param-header"><span class="p-title">灯光亮度</span><strong class="p-val">{{ brightnessReading === undefined ? '--' : `${brightnessReading}%` }}</strong></div>
|
||||||
|
<VanSlider v-model="brightnessTarget" :min="0" :max="100" :step="1" :disabled="operationBusy || !capability.available"
|
||||||
|
active-color="#d6a657" aria-label="待设置的灯光亮度" @change="setBrightness" />
|
||||||
|
<div class="slider-scale"><span>0% 关闭</span><span>100% 满亮</span></div>
|
||||||
|
<p v-if="capability.available" class="scale-note">目标亮度 {{ brightnessTarget }}%;滑动后设置,非当前回读值。</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="capability.temperatureRange" class="param-row">
|
||||||
|
<div class="param-header"><span class="p-title">灯光色温调节</span><strong class="p-val">{{ capability.colorTemperature === undefined ? '--' : `${capability.colorTemperature}K` }}</strong></div>
|
||||||
|
<VanSlider v-model="temperatureTarget" :min="capability.temperatureRange.min" :max="capability.temperatureRange.max" :step="1"
|
||||||
|
:disabled="operationBusy || !capability.available" active-color="#c87f2b" aria-label="待设置的灯光色温" @change="setTemperature" />
|
||||||
|
<div class="slider-scale"><span>{{ capability.temperatureRange.min }}K 暖光</span><span>{{ capability.temperatureRange.max }}K 冷光</span></div>
|
||||||
|
<p v-if="capability.available" class="scale-note">目标色温 {{ temperatureTarget }}K;滑动后设置,关灯时调节会点亮灯。</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="capability.effects.length" class="param-row">
|
||||||
|
<div class="param-header"><span class="p-title">灯光模式</span><strong class="p-val">{{ capability.effect || '--' }}</strong></div>
|
||||||
|
<select v-model="effectTarget" class="standard-light-select" aria-label="选择灯光模式" :disabled="operationBusy || !capability.available" @change="setEffect">
|
||||||
|
<option value="" disabled>选择要设置的模式</option>
|
||||||
|
<option v-for="effect in capability.effects" :key="effect" :value="effect">{{ effect }}</option>
|
||||||
|
</select>
|
||||||
|
<p class="scale-note">使用这盏灯实际提供的模式;选择后可能点亮灯。</p>
|
||||||
|
<p v-if="defaultEffect" class="scale-note">舒睡默认 {{ defaultEffect }};实际模式以顶部回读为准。</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="!capability.canDim && !capability.temperatureRange && !capability.effects.length" class="scale-note">这盏灯未上报可用的调光能力,目前仅提供开关。</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.standard-light-select { width: 100%; min-height: 40px; padding: 8px 12px; border: 1px solid #d9e6de; border-radius: 10px; background: #fff; color: #174d40; font: inherit; font-size: 13px; }
|
||||||
|
.standard-light-select:disabled { opacity: .5; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
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'
|
||||||
|
import { lightCapabilities } from '../devices/lighting/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)
|
||||||
|
const status = lightTransition.value
|
||||||
|
const failure = status?.lights?.find(light => light.success === false && light.message)
|
||||||
|
const completed = failure ?? status?.lights?.find(light => light.mode !== 'none' && light.message) ?? status
|
||||||
|
if (completed?.message) setFeedback(completed.success ? 'success' : 'fail', completed.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
|
||||||
|
if (target?.domain === 'light') return lightCapabilities(device).available
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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'
|
||||||
|
import { anyLightTransitionRunning } from '../devices/lighting/transitions'
|
||||||
|
|
||||||
|
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 (anyLightTransitionRunning(status) && !lightTimer) lightTimer = setInterval(() => { void pollLight() }, 5000)
|
||||||
|
else if (!anyLightTransitionRunning(status) && lightTimer) { clearInterval(lightTimer); lightTimer = undefined }
|
||||||
|
}
|
||||||
|
async function pollLight() {
|
||||||
|
if (!active || lightPolling) return
|
||||||
|
lightPolling = true
|
||||||
|
try {
|
||||||
|
const wasRunning = anyLightTransitionRunning(lightTransition.value)
|
||||||
|
await syncLightTransitionStatus()
|
||||||
|
if (active && wasRunning && !anyLightTransitionRunning(lightTransition.value)) await onLightEnded()
|
||||||
|
} catch { /* 状态读取失败不伪造成功,下个周期重试。 */ }
|
||||||
|
finally { lightPolling = false }
|
||||||
|
}
|
||||||
|
onUnmounted(() => {
|
||||||
|
active = false
|
||||||
|
if (lightTimer) clearInterval(lightTimer)
|
||||||
|
if (musicTimer) clearInterval(musicTimer)
|
||||||
|
})
|
||||||
|
return { lightTransition, musicTransition, syncLightTransitionStatus, syncMusicTransitionStatus }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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 StandardLightCard from '../components/devices/StandardLightCard.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
|
||||||
|
// 新版没有模块绑定的设备只展示通用能力,不能误用同类设备的已配置参数。
|
||||||
|
const specialized = cards[deviceModuleKey(device)]
|
||||||
|
if (specialized) return specialized
|
||||||
|
// 没有专属模块的标准灯仍可调光;不根据家具名称或类别给所有设备套相同控件。
|
||||||
|
if (device.entities.some(entity => entity.entityId === device.primaryControlEntityId && entity.domain === 'light'))
|
||||||
|
return StandardLightCard
|
||||||
|
return GenericDeviceCard
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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'
|
||||||
|
import { lightCapabilities } from './lighting/capabilities'
|
||||||
|
import { lightTransitionForDevice } from './lighting/transitions'
|
||||||
|
|
||||||
|
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> = {
|
||||||
|
'standard-light'(device, input, power) {
|
||||||
|
const capability = lightCapabilities(device)
|
||||||
|
const transition = lightTransitionForDevice(device, input.light)
|
||||||
|
if (transition?.running) return { statusText: transition.mode === 'wake' ? '晨起唤醒中' : '助眠渐暗中', metricText: `亮度 ${transition.brightnessPercent}%` }
|
||||||
|
const readings = [power === true && capability.brightnessPercent !== undefined ? `亮度 ${capability.brightnessPercent}%` : '',
|
||||||
|
capability.colorTemperature !== undefined ? `${capability.colorTemperature}K` : ''].filter(Boolean)
|
||||||
|
return { statusText: powerText(power, '已点亮'), metricText: readings.join(' · ') || '查看本灯支持的控制' }
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
const transition = lightTransitionForDevice(device, input.light)
|
||||||
|
return {
|
||||||
|
statusText: transition?.running ? transition.mode === 'wake' ? '晨起唤醒中' : '助眠渐暗中' : powerText(power, '已点亮'),
|
||||||
|
metricText: transition?.running ? `亮度 ${transition.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 target = primaryDeviceEntity(device)
|
||||||
|
const moduleKey = deviceModuleKey(device)
|
||||||
|
const key = presenters[moduleKey] ? moduleKey : target?.domain === 'light' ? 'standard-light' : moduleKey
|
||||||
|
const icon = key === 'standard-light' ? 'lighting' : categoryIcons[device.category] ?? 'other'
|
||||||
|
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
|
||||||
|
: target?.domain === 'light' ? lightCapabilities(device).available ? target.state === 'on' : null : reportedPower(target)
|
||||||
|
const canToggle = outletControl ? outletCapabilities(device).available
|
||||||
|
: target?.domain === 'light' ? lightCapabilities(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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { HomeAssistantEntity } from '../services/health'
|
||||||
|
|
||||||
|
/** 按钮是动作而非持续状态,unknown 不代表这个动作离线;通用列表仍只读,不开放危险动作。 */
|
||||||
|
export function entityStateLabel(entity: HomeAssistantEntity): string {
|
||||||
|
if (entity.state === 'unavailable' || !entity.available && entity.state !== 'unknown') return '不可用'
|
||||||
|
if (entity.domain === 'button' && entity.available) return entity.state === 'unknown' ? '动作按钮(尚未触发)' : `最近触发:${entity.state}`
|
||||||
|
if (entity.state === 'unknown' || !entity.state) return '状态未回读'
|
||||||
|
return entity.state
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||||
|
import { numberValue } from '../parameterState'
|
||||||
|
|
||||||
|
const dimmableModes = ['brightness', 'color_temp', 'hs', 'rgb', 'rgbw', 'rgbww', 'xy', 'white']
|
||||||
|
const strings = (value: unknown): string[] => Array.isArray(value)
|
||||||
|
? [...new Set(value.filter((item): item is string => typeof item === 'string' && Boolean(item.trim())))] : []
|
||||||
|
|
||||||
|
/** 标准能力只取当前设备的主灯实体;不借用全局参数、其他灯的范围或当前读数。 */
|
||||||
|
export function lightCapabilities(device: HomeAssistantDiscoveredDevice) {
|
||||||
|
const entity = device.entities.find(item => item.entityId === device.primaryControlEntityId && item.domain === 'light')
|
||||||
|
const attributes = entity?.attributes ?? {}, modes = strings(attributes.supported_color_modes)
|
||||||
|
const available = Boolean(device.online && entity?.available && ['on', 'off'].includes(entity.state))
|
||||||
|
const canDim = modes.some(mode => dimmableModes.includes(mode))
|
||||||
|
const min = numberValue(attributes.min_color_temp_kelvin), max = numberValue(attributes.max_color_temp_kelvin)
|
||||||
|
const temperatureRange = modes.includes('color_temp') && min !== undefined && max !== undefined &&
|
||||||
|
Number.isInteger(min) && Number.isInteger(max) && min > 0 && max > min && max <= 2147483647 ? { min, max } : undefined
|
||||||
|
const features = numberValue(attributes.supported_features)
|
||||||
|
const effects = features !== undefined && Number.isInteger(features) && features >= 0 && features <= 2147483647 && (features & 4) !== 0
|
||||||
|
? strings(attributes.effect_list) : []
|
||||||
|
const brightness = numberValue(attributes.brightness), kelvin = numberValue(attributes.color_temp_kelvin)
|
||||||
|
const brightnessPercent = available && canDim ? entity!.state === 'off' ? 0
|
||||||
|
: brightness !== undefined && Number.isInteger(brightness) && brightness > 0 && brightness <= 255 ? Math.round(brightness / 255 * 100) : undefined : undefined
|
||||||
|
const colorTemperature = available && temperatureRange && kelvin !== undefined && Number.isInteger(kelvin) &&
|
||||||
|
kelvin >= temperatureRange.min && kelvin <= temperatureRange.max ? kelvin : undefined
|
||||||
|
const effect = available && typeof attributes.effect === 'string' && effects.includes(attributes.effect) ? attributes.effect : undefined
|
||||||
|
return { entity, available, canDim, temperatureRange, effects, brightnessPercent, colorTemperature, effect }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { clamp, numberValue, type ParameterSnapshot } from '../parameterState'
|
||||||
|
export const defaults = { lightBrightness: 50, lightColorTemp: 2700 }
|
||||||
|
export const labels = { brightness: '灯光亮度', color_temp: '灯光色温', light_brightness: '灯光亮度',
|
||||||
|
light_color_temperature: '灯光色温', light_effect: '灯光模式' }
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { HomeAssistantDiscoveredDevice, HomeAssistantLightTransition } from '../../services/health'
|
||||||
|
import { deviceModuleKey } from '../deviceKinds'
|
||||||
|
|
||||||
|
/** 渐变回读按实体归属,不让两盏灯共享同一个亮度或运行状态。 */
|
||||||
|
export function lightTransitionForDevice(device: HomeAssistantDiscoveredDevice, status?: HomeAssistantLightTransition) {
|
||||||
|
const entityId = device.primaryControlEntityId
|
||||||
|
if (!entityId || !device.entities.some(entity => entity.entityId === entityId && entity.domain === 'light')) return undefined
|
||||||
|
if (status?.lights?.length) return status.lights.find(light => light.entityId === entityId)
|
||||||
|
if (status?.entityId) return status.entityId === entityId ? status : undefined
|
||||||
|
// 兼容旧单灯接口,仅原专属卧室灯可使用没有实体标记的回读。
|
||||||
|
return deviceModuleKey(device) === 'lighting' ? status : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function anyLightTransitionRunning(status?: HomeAssistantLightTransition) {
|
||||||
|
return Boolean(status?.running || status?.lights?.some(light => light.running))
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||||
|
import { lightCapabilities } from './capabilities'
|
||||||
|
|
||||||
|
type Apply = (deviceId: string, parameter: string, value: unknown, entityId?: string) => Promise<boolean>
|
||||||
|
|
||||||
|
/** 每张卡片有独立编辑草稿;回读与待设置值分开,切换设备时不继承上一盏灯。 */
|
||||||
|
export function useLightControls(device: () => HomeAssistantDiscoveredDevice, busy: () => boolean, apply: Apply,
|
||||||
|
reportedBrightness: () => number | undefined = () => undefined) {
|
||||||
|
const capability = computed(() => lightCapabilities(device()))
|
||||||
|
const brightnessDraft = ref<number>(), temperatureDraft = ref<number>(), effectDraft = ref<string>()
|
||||||
|
const identity = () => `${device().deviceId}\0${device().primaryControlEntityId ?? ''}`
|
||||||
|
watch(identity, () => { brightnessDraft.value = undefined; temperatureDraft.value = undefined; effectDraft.value = undefined })
|
||||||
|
const brightnessReading = computed(() => {
|
||||||
|
const reported = reportedBrightness()
|
||||||
|
return capability.value.available && reported !== undefined && Number.isInteger(reported) && reported >= 0 && reported <= 100
|
||||||
|
? reported : capability.value.brightnessPercent
|
||||||
|
})
|
||||||
|
const brightnessTarget = computed({ get: () => brightnessDraft.value ?? brightnessReading.value ?? 50,
|
||||||
|
set: (value: number) => { brightnessDraft.value = value } })
|
||||||
|
const temperatureTarget = computed({ get: () => temperatureDraft.value ?? capability.value.colorTemperature ?? capability.value.temperatureRange?.min ?? 0,
|
||||||
|
set: (value: number) => { temperatureDraft.value = value } })
|
||||||
|
const defaultEffect = computed(() => capability.value.effects.includes(device().defaultLightEffect ?? '') ? device().defaultLightEffect! : '')
|
||||||
|
const effectTarget = computed({ get: () => effectDraft.value ?? capability.value.effect ?? defaultEffect.value,
|
||||||
|
set: (value: string) => { effectDraft.value = value } })
|
||||||
|
|
||||||
|
async function send(parameter: string, value: unknown, reset: () => void) {
|
||||||
|
const current = capability.value, owner = identity(), item = device()
|
||||||
|
if (busy() || !current.available || !current.entity) return false
|
||||||
|
const result = await apply(item.deviceId, parameter, value, current.entity.entityId)
|
||||||
|
if (owner === identity()) reset()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
function setBrightness() {
|
||||||
|
const value = brightnessTarget.value
|
||||||
|
if (!capability.value.canDim || !Number.isInteger(value) || value < 0 || value > 100) return Promise.resolve(false)
|
||||||
|
return send('light_brightness', value, () => { brightnessDraft.value = undefined })
|
||||||
|
}
|
||||||
|
function setTemperature() {
|
||||||
|
const range = capability.value.temperatureRange, value = temperatureTarget.value
|
||||||
|
if (!range || !Number.isInteger(value) || value < range.min || value > range.max) return Promise.resolve(false)
|
||||||
|
return send('light_color_temperature', value, () => { temperatureDraft.value = undefined })
|
||||||
|
}
|
||||||
|
function setEffect() {
|
||||||
|
const value = effectTarget.value
|
||||||
|
if (!capability.value.effects.includes(value)) return Promise.resolve(false)
|
||||||
|
return send('light_effect', value, () => { effectDraft.value = undefined })
|
||||||
|
}
|
||||||
|
return { capability, brightnessReading, brightnessTarget, temperatureTarget, effectTarget, defaultEffect, setBrightness, setTemperature, setEffect }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import 'vant/lib/index.css'
|
import 'vant/lib/index.css'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
import './styles/page-polish.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -5,10 +5,13 @@ import AssessmentView from './views/AssessmentView.vue'
|
|||||||
import TcmAssessmentView from './views/TcmAssessmentView.vue'
|
import TcmAssessmentView from './views/TcmAssessmentView.vue'
|
||||||
import ProfileView from './views/ProfileView.vue'
|
import ProfileView from './views/ProfileView.vue'
|
||||||
import DeviceControlView from './views/DeviceControlView.vue'
|
import DeviceControlView from './views/DeviceControlView.vue'
|
||||||
|
import DeviceControlLayout from './views/DeviceControlLayout.vue'
|
||||||
|
import DeviceDetailView from './views/DeviceDetailView.vue'
|
||||||
import { hasToken } from './services/auth'
|
import { hasToken } from './services/auth'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
|
scrollBehavior: (_to, _from, savedPosition) => savedPosition ?? { left: 0, top: 0 },
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', redirect: '/dashboard' },
|
{ path: '/', redirect: '/dashboard' },
|
||||||
{ path: '/auth', component: AuthView, meta: { guest: true } },
|
{ path: '/auth', component: AuthView, meta: { guest: true } },
|
||||||
@@ -17,7 +20,11 @@ const router = createRouter({
|
|||||||
{ path: '/assessment', component: AssessmentView },
|
{ path: '/assessment', component: AssessmentView },
|
||||||
{ path: '/tcm-assessment', component: TcmAssessmentView },
|
{ path: '/tcm-assessment', component: TcmAssessmentView },
|
||||||
{ path: '/profile', component: ProfileView },
|
{ 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' }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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]))
|
||||||
|
}
|
||||||
+34
-3
@@ -111,6 +111,20 @@ export type MattressComfort = {
|
|||||||
weatherObservedAt?: string | null
|
weatherObservedAt?: string | null
|
||||||
weatherReason: string
|
weatherReason: string
|
||||||
firmnessReason: 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 = {
|
export type TcmAssessmentInput = {
|
||||||
@@ -143,6 +157,9 @@ export type HealthDeviceCategory =
|
|||||||
| 'Lighting'
|
| 'Lighting'
|
||||||
| 'Media'
|
| 'Media'
|
||||||
| 'Other'
|
| 'Other'
|
||||||
|
| 'Climate'
|
||||||
|
| 'Curtain'
|
||||||
|
| 'SleepMonitoring'
|
||||||
|
|
||||||
export type HomeAssistantEntity = {
|
export type HomeAssistantEntity = {
|
||||||
entityId: string
|
entityId: string
|
||||||
@@ -155,6 +172,7 @@ export type HomeAssistantEntity = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type HomeAssistantDiscoveredDevice = {
|
export type HomeAssistantDiscoveredDevice = {
|
||||||
|
moduleKey?: string
|
||||||
deviceId: string
|
deviceId: string
|
||||||
name: string
|
name: string
|
||||||
manufacturer: string
|
manufacturer: string
|
||||||
@@ -163,6 +181,7 @@ export type HomeAssistantDiscoveredDevice = {
|
|||||||
category: HealthDeviceCategory | string
|
category: HealthDeviceCategory | string
|
||||||
online: boolean
|
online: boolean
|
||||||
primaryControlEntityId?: string | null
|
primaryControlEntityId?: string | null
|
||||||
|
defaultLightEffect?: string | null
|
||||||
entities: HomeAssistantEntity[]
|
entities: HomeAssistantEntity[]
|
||||||
metrics?: Record<string, unknown>
|
metrics?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
@@ -211,12 +230,21 @@ export type HomeAssistantDeviceSwitchOutput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type HomeAssistantLightTransition = {
|
export type HomeAssistantLightTransition = {
|
||||||
|
entityId?: string
|
||||||
success: boolean
|
success: boolean
|
||||||
running: boolean
|
running: boolean
|
||||||
mode: 'none' | 'sleep' | 'wake'
|
mode: 'none' | 'sleep' | 'wake'
|
||||||
brightnessPercent: number
|
brightnessPercent: number
|
||||||
targetPercent: number
|
targetPercent: number
|
||||||
message: string
|
message: string
|
||||||
|
lights?: HomeAssistantLightTransition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TcmMusicTransition = {
|
||||||
|
running: boolean
|
||||||
|
hasVolume: boolean
|
||||||
|
volumePercent: number
|
||||||
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HomeAssistantStatus = {
|
export type HomeAssistantStatus = {
|
||||||
@@ -246,6 +274,7 @@ export type HomeAssistantSceneAction = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type HomeAssistantSceneResult = {
|
export type HomeAssistantSceneResult = {
|
||||||
|
climateAdvice?: ClimateAdvice | null
|
||||||
sceneCode: string
|
sceneCode: string
|
||||||
sceneName: string
|
sceneName: string
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -285,6 +314,8 @@ export const healthApi = {
|
|||||||
http.get<never, HomeAssistantStatus>('/api/health/home-assistant/status'),
|
http.get<never, HomeAssistantStatus>('/api/health/home-assistant/status'),
|
||||||
lightTransitionStatus: () =>
|
lightTransitionStatus: () =>
|
||||||
http.get<never, HomeAssistantLightTransition>('/api/health/home-assistant/lights/transition'),
|
http.get<never, HomeAssistantLightTransition>('/api/health/home-assistant/lights/transition'),
|
||||||
|
musicTransitionStatus: () =>
|
||||||
|
http.get<never, TcmMusicTransition>('/api/health/home-assistant/music/transition'),
|
||||||
startSleepLight: () =>
|
startSleepLight: () =>
|
||||||
http.post<never, HomeAssistantLightTransition>('/api/health/home-assistant/lights/sleep'),
|
http.post<never, HomeAssistantLightTransition>('/api/health/home-assistant/lights/sleep'),
|
||||||
startWakeLight: () =>
|
startWakeLight: () =>
|
||||||
@@ -294,9 +325,9 @@ export const healthApi = {
|
|||||||
executeSleepScene: () =>
|
executeSleepScene: () =>
|
||||||
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/sleep', undefined, { timeout: 90000 }),
|
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/sleep', undefined, { timeout: 90000 }),
|
||||||
executeAllOffScene: () =>
|
executeAllOffScene: () =>
|
||||||
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/all-off'),
|
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/all-off', undefined, { timeout: 90000 }),
|
||||||
switchDevice: (payload: { deviceId: string; turnOn: boolean; entityId?: string }) =>
|
switchDevice: (payload: { deviceId: string; turnOn: boolean; entityId?: string }) =>
|
||||||
http.post<never, HomeAssistantDeviceSwitchOutput>('/api/health/home-assistant/devices/switch', payload),
|
http.post<never, HomeAssistantDeviceSwitchOutput>('/api/health/home-assistant/devices/switch', payload, { timeout: 45000 }),
|
||||||
setParameter: (payload: HomeAssistantSetParameterInput) =>
|
setParameter: (payload: HomeAssistantSetParameterInput) =>
|
||||||
http.post<never, HomeAssistantSetParameterOutput>('/api/health/home-assistant/devices/set-parameter', payload)
|
http.post<never, HomeAssistantSetParameterOutput>('/api/health/home-assistant/devices/set-parameter', payload, { timeout: 45000 })
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -2,12 +2,26 @@ import axios from 'axios'
|
|||||||
import { clearTokens, getToken } from './auth'
|
import { clearTokens, getToken } from './auth'
|
||||||
|
|
||||||
type UnifiedResult<T> = {
|
type UnifiedResult<T> = {
|
||||||
|
code?: number
|
||||||
type?: string
|
type?: string
|
||||||
message?: string
|
message?: string
|
||||||
result?: T
|
result?: T
|
||||||
errors?: unknown
|
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({
|
const http = axios.create({
|
||||||
baseURL: '/',
|
baseURL: '/',
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
@@ -22,6 +36,12 @@ http.interceptors.request.use((config) => {
|
|||||||
http.interceptors.response.use(
|
http.interceptors.response.use(
|
||||||
((response: any) => {
|
((response: any) => {
|
||||||
const body = response.data as UnifiedResult<unknown>
|
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 && typeof body === 'object' && 'result' in body) {
|
||||||
if (body.type && body.type !== 'success') {
|
if (body.type && body.type !== 'success') {
|
||||||
return Promise.reject(new Error(body.message || '请求失败'))
|
return Promise.reject(new Error(body.message || '请求失败'))
|
||||||
@@ -31,7 +51,9 @@ http.interceptors.response.use(
|
|||||||
return body
|
return body
|
||||||
}) as any,
|
}) as any,
|
||||||
(error) => {
|
(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 body = error.response?.data as UnifiedResult<unknown> | undefined
|
||||||
const message = body?.message || error.message || '网络请求失败'
|
const message = body?.message || error.message || '网络请求失败'
|
||||||
return Promise.reject(new Error(message))
|
return Promise.reject(new Error(message))
|
||||||
|
|||||||
+8
-8
@@ -790,20 +790,20 @@ button { cursor: pointer; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-page {
|
.dashboard-page {
|
||||||
padding: 16px 14px 84px !important;
|
padding: 20px var(--page-gutter, 16px) calc(88px + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
.dashboard-header {
|
.dashboard-header {
|
||||||
margin: 0 auto 12px !important;
|
margin: 0 auto 20px;
|
||||||
padding: 4px 4px 0 !important;
|
padding: 4px 4px 0;
|
||||||
}
|
}
|
||||||
.dashboard-header h1 {
|
.dashboard-header h1 {
|
||||||
margin: 2px 0 3px !important;
|
margin: 2px 0 6px;
|
||||||
font-size: 26px !important;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
.dashboard-header > div > p:last-child {
|
.dashboard-header > div > p:last-child {
|
||||||
margin: 0 !important;
|
margin: 0;
|
||||||
font-size: 13px !important;
|
font-size: 12px;
|
||||||
line-height: 1.4 !important;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 评估未填提醒卡片 */
|
/* 评估未填提醒卡片 */
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/* 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: none !important;
|
||||||
|
transition-duration: 0s !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
+189
-116
@@ -3,6 +3,8 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Button as VanButton, showToast } from 'vant'
|
import { Button as VanButton, showToast } from 'vant'
|
||||||
import AppTabBar from '../components/AppTabBar.vue'
|
import AppTabBar from '../components/AppTabBar.vue'
|
||||||
|
import { usePageRequest } from '../composables/usePageRequest'
|
||||||
|
import { ageFromBirthday, finiteNumberInRange, healthAnswersComplete } from '../services/formValidation'
|
||||||
import {
|
import {
|
||||||
healthApi,
|
healthApi,
|
||||||
type HealthAssessmentAnswers,
|
type HealthAssessmentAnswers,
|
||||||
@@ -10,10 +12,13 @@ import {
|
|||||||
} from '../services/health'
|
} from '../services/health'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = ref(false)
|
const loading = ref(true)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
const hasPreviousAssessment = ref(false)
|
const hasPreviousAssessment = ref(false)
|
||||||
const age = ref<number>()
|
const age = ref<number>()
|
||||||
|
const savedAge = ref<number>()
|
||||||
|
const pageRequest = usePageRequest()
|
||||||
|
|
||||||
const basicInfo = reactive<HealthProfile>({
|
const basicInfo = reactive<HealthProfile>({
|
||||||
gender: undefined,
|
gender: undefined,
|
||||||
@@ -39,7 +44,7 @@ const answers = reactive<HealthAssessmentAnswers>({
|
|||||||
|
|
||||||
// 计算 BMI
|
// 计算 BMI
|
||||||
const computedBmi = computed(() => {
|
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 heightM = basicInfo.heightCm / 100
|
||||||
const val = basicInfo.weightKg / (heightM * heightM)
|
const val = basicInfo.weightKg / (heightM * heightM)
|
||||||
return Math.round(val * 10) / 10
|
return Math.round(val * 10) / 10
|
||||||
@@ -55,77 +60,76 @@ const bmiStatus = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function loadExisting() {
|
async function loadExisting() {
|
||||||
|
if (submitting.value) return
|
||||||
|
const requestId = pageRequest.begin()
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
try {
|
try {
|
||||||
const res = await healthApi.getAssessment()
|
const res = await healthApi.getAssessment()
|
||||||
if (res && res.hasCompletedAssessment) {
|
if (!pageRequest.isCurrent(requestId)) return
|
||||||
hasPreviousAssessment.value = true
|
if (!res) throw new Error('健康评估记录读取失败,请重试')
|
||||||
|
hasPreviousAssessment.value = res.hasCompletedAssessment
|
||||||
if (res.basicInfo) Object.assign(basicInfo, res.basicInfo)
|
if (res.basicInfo) Object.assign(basicInfo, res.basicInfo)
|
||||||
if (res.answers) Object.assign(answers, res.answers)
|
if (res.answers) Object.assign(answers, res.answers)
|
||||||
if (res.basicInfo?.birthday) {
|
savedAge.value = ageFromBirthday(res.basicInfo?.birthday)
|
||||||
const birthday = new Date(res.basicInfo.birthday)
|
age.value = savedAge.value
|
||||||
const today = new Date()
|
} catch (error) {
|
||||||
let years = today.getFullYear() - birthday.getFullYear()
|
if (!pageRequest.isCurrent(requestId)) return
|
||||||
if (today < new Date(today.getFullYear(), birthday.getMonth(), birthday.getDate())) years--
|
loadError.value = error instanceof Error ? error.message : '健康评估记录加载失败,请重试'
|
||||||
age.value = years
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (pageRequest.isCurrent(requestId)) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
|
if (loading.value || loadError.value || submitting.value) return
|
||||||
if (basicInfo.gender !== '男' && basicInfo.gender !== '女') {
|
if (basicInfo.gender !== '男' && basicInfo.gender !== '女') {
|
||||||
showToast('请选择性别')
|
showToast('请选择性别')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!age.value || age.value < 1 || age.value > 120) {
|
if (!finiteNumberInRange(age.value, 1, 120, true)) {
|
||||||
showToast('请正确填写年龄 (1~120岁)')
|
showToast('请正确填写年龄 (1~120岁)')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!basicInfo.heightCm || basicInfo.heightCm < 80 || basicInfo.heightCm > 250) {
|
if (!finiteNumberInRange(basicInfo.heightCm, 80, 250)) {
|
||||||
showToast('请正确填写身高 (80~250cm)')
|
showToast('请正确填写身高 (80~250cm)')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!basicInfo.weightKg || basicInfo.weightKg < 20 || basicInfo.weightKg > 250) {
|
if (!finiteNumberInRange(basicInfo.weightKg, 20, 250)) {
|
||||||
showToast('请正确填写体重 (20~250kg)')
|
showToast('请正确填写体重 (20~250kg)')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证所有选项均已作答
|
if (!healthAnswersComplete(answers)) {
|
||||||
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('三大健康板块每个选项均为必选,请作答完整')
|
showToast('三大健康板块每个选项均为必选,请作答完整')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
const requestId = pageRequest.begin()
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
basicInfo.birthday = `${new Date().getFullYear() - age.value}-01-01`
|
const profile: HealthProfile = {
|
||||||
basicInfo.averageSleepHours = ({ '<6': 5.5, '6-7': 6.5, '7-8': 7.5, '>8': 8.5 } as Record<string, number>)[answers.sleepDuration]
|
...basicInfo,
|
||||||
basicInfo.dailyExerciseMinutes = ({ none: 0, '1-2': 15, '3-5': 30, daily: 45 } as Record<string, number>)[answers.exerciseFrequency]
|
birthday: basicInfo.birthday && savedAge.value === age.value
|
||||||
basicInfo.dietPreference = answers.flavorPreference
|
? basicInfo.birthday : `${new Date().getFullYear() - age.value}-01-01`,
|
||||||
basicInfo.healthGoal = '改善睡眠、运动与饮食健康'
|
averageSleepHours: ({ '<6': 5.5, '6-7': 6.5, '7-8': 7.5, '>8': 8.5 } as Record<string, number>)[answers.sleepDuration],
|
||||||
await healthApi.saveAssessment({
|
dailyExerciseMinutes: ({ none: 0, '1-2': 15, '3-5': 30, daily: 45 } as Record<string, number>)[answers.exerciseFrequency],
|
||||||
basicInfo: { ...basicInfo },
|
dietPreference: answers.flavorPreference,
|
||||||
|
healthGoal: '改善睡眠、运动与饮食健康'
|
||||||
|
}
|
||||||
|
const saved = await healthApi.saveAssessment({
|
||||||
|
basicInfo: profile,
|
||||||
answers: { ...answers }
|
answers: { ...answers }
|
||||||
})
|
})
|
||||||
|
if (!pageRequest.isCurrent(requestId)) return
|
||||||
|
if (!saved?.hasCompletedAssessment) throw new Error('健康评估未保存成功,请重试')
|
||||||
showToast({ type: 'success', message: '健康评估已保存' })
|
showToast({ type: 'success', message: '健康评估已保存' })
|
||||||
router.replace('/dashboard')
|
router.replace('/dashboard')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!pageRequest.isCurrent(requestId)) return
|
||||||
showToast(err instanceof Error ? err.message : '保存失败,请重试')
|
showToast(err instanceof Error ? err.message : '保存失败,请重试')
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
if (pageRequest.isCurrent(requestId)) submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +150,15 @@ onMounted(() => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="assessment-content">
|
<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">
|
<section class="card-section">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
@@ -183,7 +196,9 @@ onMounted(() => {
|
|||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="120"
|
max="120"
|
||||||
|
step="1"
|
||||||
placeholder="请输入年龄"
|
placeholder="请输入年龄"
|
||||||
|
aria-label="年龄(岁)"
|
||||||
/>
|
/>
|
||||||
<span class="unit">岁</span>
|
<span class="unit">岁</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,7 +212,9 @@ onMounted(() => {
|
|||||||
type="number"
|
type="number"
|
||||||
min="80"
|
min="80"
|
||||||
max="250"
|
max="250"
|
||||||
|
step="0.1"
|
||||||
placeholder="175"
|
placeholder="175"
|
||||||
|
aria-label="身高(厘米)"
|
||||||
/>
|
/>
|
||||||
<span class="unit">cm</span>
|
<span class="unit">cm</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +228,9 @@ onMounted(() => {
|
|||||||
type="number"
|
type="number"
|
||||||
min="20"
|
min="20"
|
||||||
max="250"
|
max="250"
|
||||||
|
step="0.1"
|
||||||
placeholder="68"
|
placeholder="68"
|
||||||
|
aria-label="体重(千克)"
|
||||||
/>
|
/>
|
||||||
<span class="unit">kg</span>
|
<span class="unit">kg</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,34 +265,38 @@ onMounted(() => {
|
|||||||
<strong>每晚平均睡眠时长</strong>
|
<strong>每晚平均睡眠时长</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="options-grid cols-2">
|
<div class="options-grid cols-2">
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.sleepDuration === '<6' }"
|
:class="{ active: answers.sleepDuration === '<6' }"
|
||||||
|
:aria-pressed="answers.sleepDuration === '<6'"
|
||||||
@click="answers.sleepDuration = '<6'"
|
@click="answers.sleepDuration = '<6'"
|
||||||
>
|
>
|
||||||
不足 6 小时 (睡眠欠缺)
|
不足 6 小时 (睡眠欠缺)
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.sleepDuration === '6-7' }"
|
:class="{ active: answers.sleepDuration === '6-7' }"
|
||||||
|
:aria-pressed="answers.sleepDuration === '6-7'"
|
||||||
@click="answers.sleepDuration = '6-7'"
|
@click="answers.sleepDuration = '6-7'"
|
||||||
>
|
>
|
||||||
6 - 7 小时 (基本及格)
|
6 - 7 小时 (基本及格)
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.sleepDuration === '7-8' }"
|
:class="{ active: answers.sleepDuration === '7-8' }"
|
||||||
|
:aria-pressed="answers.sleepDuration === '7-8'"
|
||||||
@click="answers.sleepDuration = '7-8'"
|
@click="answers.sleepDuration = '7-8'"
|
||||||
>
|
>
|
||||||
7 - 8 小时 (黄金充沛)
|
7 - 8 小时 (黄金充沛)
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.sleepDuration === '>8' }"
|
:class="{ active: answers.sleepDuration === '>8' }"
|
||||||
|
:aria-pressed="answers.sleepDuration === '>8'"
|
||||||
@click="answers.sleepDuration = '>8'"
|
@click="answers.sleepDuration = '>8'"
|
||||||
>
|
>
|
||||||
8 小时以上 (长睡眠)
|
8 小时以上 (长睡眠)
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -284,9 +307,10 @@ onMounted(() => {
|
|||||||
<strong>入睡耗时与困难程度</strong>
|
<strong>入睡耗时与困难程度</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="options-grid cols-1">
|
<div class="options-grid cols-1">
|
||||||
<div
|
<button type="button"
|
||||||
class="option-card"
|
class="option-card"
|
||||||
:class="{ active: answers.fallingAsleepSpeed === 'hard' }"
|
:class="{ active: answers.fallingAsleepSpeed === 'hard' }"
|
||||||
|
:aria-pressed="answers.fallingAsleepSpeed === 'hard'"
|
||||||
@click="answers.fallingAsleepSpeed = 'hard'"
|
@click="answers.fallingAsleepSpeed = 'hard'"
|
||||||
>
|
>
|
||||||
<div class="opt-head">
|
<div class="opt-head">
|
||||||
@@ -294,11 +318,12 @@ onMounted(() => {
|
|||||||
<span class="opt-tag">入睡较慢</span>
|
<span class="opt-tag">入睡较慢</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="opt-desc">睡前思绪繁杂、对光线声音敏感,常在床上翻来覆去无法入眠</p>
|
<p class="opt-desc">睡前思绪繁杂、对光线声音敏感,常在床上翻来覆去无法入眠</p>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div
|
<button type="button"
|
||||||
class="option-card"
|
class="option-card"
|
||||||
:class="{ active: answers.fallingAsleepSpeed === 'normal' }"
|
:class="{ active: answers.fallingAsleepSpeed === 'normal' }"
|
||||||
|
:aria-pressed="answers.fallingAsleepSpeed === 'normal'"
|
||||||
@click="answers.fallingAsleepSpeed = 'normal'"
|
@click="answers.fallingAsleepSpeed = 'normal'"
|
||||||
>
|
>
|
||||||
<div class="opt-head">
|
<div class="opt-head">
|
||||||
@@ -306,11 +331,12 @@ onMounted(() => {
|
|||||||
<span class="opt-tag">轻柔微光</span>
|
<span class="opt-tag">轻柔微光</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="opt-desc">入睡平稳,偶有疲劳或压力,整体处于健康范围</p>
|
<p class="opt-desc">入睡平稳,偶有疲劳或压力,整体处于健康范围</p>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div
|
<button type="button"
|
||||||
class="option-card"
|
class="option-card"
|
||||||
:class="{ active: answers.fallingAsleepSpeed === 'fast' }"
|
:class="{ active: answers.fallingAsleepSpeed === 'fast' }"
|
||||||
|
:aria-pressed="answers.fallingAsleepSpeed === 'fast'"
|
||||||
@click="answers.fallingAsleepSpeed = 'fast'"
|
@click="answers.fallingAsleepSpeed = 'fast'"
|
||||||
>
|
>
|
||||||
<div class="opt-head">
|
<div class="opt-head">
|
||||||
@@ -318,7 +344,7 @@ onMounted(() => {
|
|||||||
<span class="opt-tag">常规舒眠</span>
|
<span class="opt-tag">常规舒眠</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="opt-desc">沾枕头即入睡,深睡眠过渡平顺</p>
|
<p class="opt-desc">沾枕头即入睡,深睡眠过渡平顺</p>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -329,9 +355,10 @@ onMounted(() => {
|
|||||||
<strong>睡眠时的冷热感受</strong>
|
<strong>睡眠时的冷热感受</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="options-grid cols-1">
|
<div class="options-grid cols-1">
|
||||||
<div
|
<button type="button"
|
||||||
class="option-card"
|
class="option-card"
|
||||||
:class="{ active: answers.temperaturePreference === 'heat_fear' }"
|
:class="{ active: answers.temperaturePreference === 'heat_fear' }"
|
||||||
|
:aria-pressed="answers.temperaturePreference === 'heat_fear'"
|
||||||
@click="answers.temperaturePreference = 'heat_fear'"
|
@click="answers.temperaturePreference = 'heat_fear'"
|
||||||
>
|
>
|
||||||
<div class="opt-head">
|
<div class="opt-head">
|
||||||
@@ -339,11 +366,12 @@ onMounted(() => {
|
|||||||
<span class="opt-tag tag-cool">偏热</span>
|
<span class="opt-tag tag-cool">偏热</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="opt-desc">被窝温度稍高就烦躁踢被,需要更干爽清凉的卧房环境</p>
|
<p class="opt-desc">被窝温度稍高就烦躁踢被,需要更干爽清凉的卧房环境</p>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div
|
<button type="button"
|
||||||
class="option-card"
|
class="option-card"
|
||||||
:class="{ active: answers.temperaturePreference === 'cold_fear' }"
|
:class="{ active: answers.temperaturePreference === 'cold_fear' }"
|
||||||
|
:aria-pressed="answers.temperaturePreference === 'cold_fear'"
|
||||||
@click="answers.temperaturePreference = 'cold_fear'"
|
@click="answers.temperaturePreference = 'cold_fear'"
|
||||||
>
|
>
|
||||||
<div class="opt-head">
|
<div class="opt-head">
|
||||||
@@ -351,11 +379,12 @@ onMounted(() => {
|
|||||||
<span class="opt-tag tag-warm">偏冷</span>
|
<span class="opt-tag tag-warm">偏冷</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="opt-desc">入睡半天手脚捂不热,稍有微风吹拂即觉寒凉</p>
|
<p class="opt-desc">入睡半天手脚捂不热,稍有微风吹拂即觉寒凉</p>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div
|
<button type="button"
|
||||||
class="option-card"
|
class="option-card"
|
||||||
:class="{ active: answers.temperaturePreference === 'normal' }"
|
:class="{ active: answers.temperaturePreference === 'normal' }"
|
||||||
|
:aria-pressed="answers.temperaturePreference === 'normal'"
|
||||||
@click="answers.temperaturePreference = 'normal'"
|
@click="answers.temperaturePreference = 'normal'"
|
||||||
>
|
>
|
||||||
<div class="opt-head">
|
<div class="opt-head">
|
||||||
@@ -363,7 +392,7 @@ onMounted(() => {
|
|||||||
<span class="opt-tag">冷热适中</span>
|
<span class="opt-tag">冷热适中</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="opt-desc">体感舒适度正常,对温湿度耐受度均衡</p>
|
<p class="opt-desc">体感舒适度正常,对温湿度耐受度均衡</p>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -374,27 +403,30 @@ onMounted(() => {
|
|||||||
<strong>夜间醒来与多梦浅睡情况</strong>
|
<strong>夜间醒来与多梦浅睡情况</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="options-grid cols-3">
|
<div class="options-grid cols-3">
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.nightAwakening === 'frequent' }"
|
:class="{ active: answers.nightAwakening === 'frequent' }"
|
||||||
|
:aria-pressed="answers.nightAwakening === 'frequent'"
|
||||||
@click="answers.nightAwakening = 'frequent'"
|
@click="answers.nightAwakening = 'frequent'"
|
||||||
>
|
>
|
||||||
频繁易惊醒
|
频繁易惊醒
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.nightAwakening === 'light' }"
|
:class="{ active: answers.nightAwakening === 'light' }"
|
||||||
|
:aria-pressed="answers.nightAwakening === 'light'"
|
||||||
@click="answers.nightAwakening = 'light'"
|
@click="answers.nightAwakening = 'light'"
|
||||||
>
|
>
|
||||||
偶有起夜1次
|
偶有起夜1次
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.nightAwakening === 'none' }"
|
:class="{ active: answers.nightAwakening === 'none' }"
|
||||||
|
:aria-pressed="answers.nightAwakening === 'none'"
|
||||||
@click="answers.nightAwakening = 'none'"
|
@click="answers.nightAwakening = 'none'"
|
||||||
>
|
>
|
||||||
一觉到天亮
|
一觉到天亮
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -405,27 +437,30 @@ onMounted(() => {
|
|||||||
<strong>床垫承托硬度偏好</strong>
|
<strong>床垫承托硬度偏好</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="options-grid cols-3">
|
<div class="options-grid cols-3">
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.mattressPreference === 'firm' }"
|
:class="{ active: answers.mattressPreference === 'firm' }"
|
||||||
|
:aria-pressed="answers.mattressPreference === 'firm'"
|
||||||
@click="answers.mattressPreference = 'firm'"
|
@click="answers.mattressPreference = 'firm'"
|
||||||
>
|
>
|
||||||
偏硬护脊 (7级)
|
偏硬护脊 (7级)
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.mattressPreference === 'medium' }"
|
:class="{ active: answers.mattressPreference === 'medium' }"
|
||||||
|
:aria-pressed="answers.mattressPreference === 'medium'"
|
||||||
@click="answers.mattressPreference = 'medium'"
|
@click="answers.mattressPreference = 'medium'"
|
||||||
>
|
>
|
||||||
软硬适中 (5-6级)
|
软硬适中 (5-6级)
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.mattressPreference === 'soft' }"
|
:class="{ active: answers.mattressPreference === 'soft' }"
|
||||||
|
:aria-pressed="answers.mattressPreference === 'soft'"
|
||||||
@click="answers.mattressPreference = 'soft'"
|
@click="answers.mattressPreference = 'soft'"
|
||||||
>
|
>
|
||||||
偏软包裹 (4级)
|
偏软包裹 (4级)
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -445,34 +480,38 @@ onMounted(() => {
|
|||||||
<strong>每周活动与锻炼频率</strong>
|
<strong>每周活动与锻炼频率</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="options-grid cols-2">
|
<div class="options-grid cols-2">
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.exerciseFrequency === 'none' }"
|
:class="{ active: answers.exerciseFrequency === 'none' }"
|
||||||
|
:aria-pressed="answers.exerciseFrequency === 'none'"
|
||||||
@click="answers.exerciseFrequency = 'none'"
|
@click="answers.exerciseFrequency = 'none'"
|
||||||
>
|
>
|
||||||
几乎不运动 (久坐为主)
|
几乎不运动 (久坐为主)
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.exerciseFrequency === '1-2' }"
|
:class="{ active: answers.exerciseFrequency === '1-2' }"
|
||||||
|
:aria-pressed="answers.exerciseFrequency === '1-2'"
|
||||||
@click="answers.exerciseFrequency = '1-2'"
|
@click="answers.exerciseFrequency = '1-2'"
|
||||||
>
|
>
|
||||||
每周 1 - 2 次 (轻度锻炼)
|
每周 1 - 2 次 (轻度锻炼)
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.exerciseFrequency === '3-5' }"
|
:class="{ active: answers.exerciseFrequency === '3-5' }"
|
||||||
|
:aria-pressed="answers.exerciseFrequency === '3-5'"
|
||||||
@click="answers.exerciseFrequency = '3-5'"
|
@click="answers.exerciseFrequency = '3-5'"
|
||||||
>
|
>
|
||||||
每周 3 - 5 次 (规律运动)
|
每周 3 - 5 次 (规律运动)
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.exerciseFrequency === 'daily' }"
|
:class="{ active: answers.exerciseFrequency === 'daily' }"
|
||||||
|
:aria-pressed="answers.exerciseFrequency === 'daily'"
|
||||||
@click="answers.exerciseFrequency = 'daily'"
|
@click="answers.exerciseFrequency = 'daily'"
|
||||||
>
|
>
|
||||||
每天坚持 (中高强度)
|
每天坚持 (中高强度)
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -483,34 +522,38 @@ onMounted(() => {
|
|||||||
<strong>日常最易酸痛或劳损部位</strong>
|
<strong>日常最易酸痛或劳损部位</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="options-grid cols-2">
|
<div class="options-grid cols-2">
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.muscleFatigueArea === 'back_neck' }"
|
:class="{ active: answers.muscleFatigueArea === 'back_neck' }"
|
||||||
|
:aria-pressed="answers.muscleFatigueArea === 'back_neck'"
|
||||||
@click="answers.muscleFatigueArea = 'back_neck'"
|
@click="answers.muscleFatigueArea = 'back_neck'"
|
||||||
>
|
>
|
||||||
肩颈僵硬酸痛
|
肩颈僵硬酸痛
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.muscleFatigueArea === 'waist_back' }"
|
:class="{ active: answers.muscleFatigueArea === 'waist_back' }"
|
||||||
|
:aria-pressed="answers.muscleFatigueArea === 'waist_back'"
|
||||||
@click="answers.muscleFatigueArea = 'waist_back'"
|
@click="answers.muscleFatigueArea = 'waist_back'"
|
||||||
>
|
>
|
||||||
腰肌劳损/腰背空悬
|
腰肌劳损/腰背空悬
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.muscleFatigueArea === 'legs' }"
|
:class="{ active: answers.muscleFatigueArea === 'legs' }"
|
||||||
|
:aria-pressed="answers.muscleFatigueArea === 'legs'"
|
||||||
@click="answers.muscleFatigueArea = 'legs'"
|
@click="answers.muscleFatigueArea = 'legs'"
|
||||||
>
|
>
|
||||||
下肢水肿酸胀
|
下肢水肿酸胀
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.muscleFatigueArea === 'none' }"
|
:class="{ active: answers.muscleFatigueArea === 'none' }"
|
||||||
|
:aria-pressed="answers.muscleFatigueArea === 'none'"
|
||||||
@click="answers.muscleFatigueArea = 'none'"
|
@click="answers.muscleFatigueArea = 'none'"
|
||||||
>
|
>
|
||||||
全身轻松无明显酸痛
|
全身轻松无明显酸痛
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -521,34 +564,38 @@ onMounted(() => {
|
|||||||
<strong>主要活动锻炼时段</strong>
|
<strong>主要活动锻炼时段</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="options-grid cols-2">
|
<div class="options-grid cols-2">
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.activeTimeOfDay === 'morning' }"
|
:class="{ active: answers.activeTimeOfDay === 'morning' }"
|
||||||
|
:aria-pressed="answers.activeTimeOfDay === 'morning'"
|
||||||
@click="answers.activeTimeOfDay = 'morning'"
|
@click="answers.activeTimeOfDay = 'morning'"
|
||||||
>
|
>
|
||||||
清晨早间活动
|
清晨早间活动
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.activeTimeOfDay === 'evening' }"
|
:class="{ active: answers.activeTimeOfDay === 'evening' }"
|
||||||
|
:aria-pressed="answers.activeTimeOfDay === 'evening'"
|
||||||
@click="answers.activeTimeOfDay = 'evening'"
|
@click="answers.activeTimeOfDay = 'evening'"
|
||||||
>
|
>
|
||||||
下午或傍晚下班后
|
下午或傍晚下班后
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.activeTimeOfDay === 'night' }"
|
:class="{ active: answers.activeTimeOfDay === 'night' }"
|
||||||
|
:aria-pressed="answers.activeTimeOfDay === 'night'"
|
||||||
@click="answers.activeTimeOfDay = 'night'"
|
@click="answers.activeTimeOfDay = 'night'"
|
||||||
>
|
>
|
||||||
临睡前 1-2 小时
|
临睡前 1-2 小时
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.activeTimeOfDay === 'irregular' }"
|
:class="{ active: answers.activeTimeOfDay === 'irregular' }"
|
||||||
|
:aria-pressed="answers.activeTimeOfDay === 'irregular'"
|
||||||
@click="answers.activeTimeOfDay = 'irregular'"
|
@click="answers.activeTimeOfDay = 'irregular'"
|
||||||
>
|
>
|
||||||
时间无规律
|
时间无规律
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -568,27 +615,30 @@ onMounted(() => {
|
|||||||
<strong>三餐定时定量规律度</strong>
|
<strong>三餐定时定量规律度</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="options-grid cols-3">
|
<div class="options-grid cols-3">
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.mealRegularity === 'regular' }"
|
:class="{ active: answers.mealRegularity === 'regular' }"
|
||||||
|
:aria-pressed="answers.mealRegularity === 'regular'"
|
||||||
@click="answers.mealRegularity = 'regular'"
|
@click="answers.mealRegularity = 'regular'"
|
||||||
>
|
>
|
||||||
三餐基本规律
|
三餐基本规律
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.mealRegularity === 'skip_breakfast' }"
|
:class="{ active: answers.mealRegularity === 'skip_breakfast' }"
|
||||||
|
:aria-pressed="answers.mealRegularity === 'skip_breakfast'"
|
||||||
@click="answers.mealRegularity = 'skip_breakfast'"
|
@click="answers.mealRegularity = 'skip_breakfast'"
|
||||||
>
|
>
|
||||||
常不吃早餐
|
常不吃早餐
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.mealRegularity === 'irregular' }"
|
:class="{ active: answers.mealRegularity === 'irregular' }"
|
||||||
|
:aria-pressed="answers.mealRegularity === 'irregular'"
|
||||||
@click="answers.mealRegularity = 'irregular'"
|
@click="answers.mealRegularity = 'irregular'"
|
||||||
>
|
>
|
||||||
三餐时常颠倒
|
三餐时常颠倒
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -599,27 +649,30 @@ onMounted(() => {
|
|||||||
<strong>日常饮食口味倾向</strong>
|
<strong>日常饮食口味倾向</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="options-grid cols-3">
|
<div class="options-grid cols-3">
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.flavorPreference === 'light' }"
|
:class="{ active: answers.flavorPreference === 'light' }"
|
||||||
|
:aria-pressed="answers.flavorPreference === 'light'"
|
||||||
@click="answers.flavorPreference = 'light'"
|
@click="answers.flavorPreference = 'light'"
|
||||||
>
|
>
|
||||||
清淡低盐少油
|
清淡低盐少油
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.flavorPreference === 'moderate' }"
|
:class="{ active: answers.flavorPreference === 'moderate' }"
|
||||||
|
:aria-pressed="answers.flavorPreference === 'moderate'"
|
||||||
@click="answers.flavorPreference = 'moderate'"
|
@click="answers.flavorPreference = 'moderate'"
|
||||||
>
|
>
|
||||||
荤素平衡适度
|
荤素平衡适度
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.flavorPreference === 'heavy' }"
|
:class="{ active: answers.flavorPreference === 'heavy' }"
|
||||||
|
:aria-pressed="answers.flavorPreference === 'heavy'"
|
||||||
@click="answers.flavorPreference = 'heavy'"
|
@click="answers.flavorPreference = 'heavy'"
|
||||||
>
|
>
|
||||||
重油重辣厚味
|
重油重辣厚味
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -630,9 +683,10 @@ onMounted(() => {
|
|||||||
<strong>夜宵及睡前进食习惯</strong>
|
<strong>夜宵及睡前进食习惯</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="options-grid cols-1">
|
<div class="options-grid cols-1">
|
||||||
<div
|
<button type="button"
|
||||||
class="option-card"
|
class="option-card"
|
||||||
:class="{ active: answers.lateNightEating === 'often' }"
|
:class="{ active: answers.lateNightEating === 'often' }"
|
||||||
|
:aria-pressed="answers.lateNightEating === 'often'"
|
||||||
@click="answers.lateNightEating = 'often'"
|
@click="answers.lateNightEating = 'often'"
|
||||||
>
|
>
|
||||||
<div class="opt-head">
|
<div class="opt-head">
|
||||||
@@ -640,11 +694,12 @@ onMounted(() => {
|
|||||||
<span class="opt-tag">经常晚间进食</span>
|
<span class="opt-tag">经常晚间进食</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="opt-desc">睡前进食可能影响休息,可逐步调整进食时间</p>
|
<p class="opt-desc">睡前进食可能影响休息,可逐步调整进食时间</p>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div
|
<button type="button"
|
||||||
class="option-card"
|
class="option-card"
|
||||||
:class="{ active: answers.lateNightEating === 'sometimes' }"
|
:class="{ active: answers.lateNightEating === 'sometimes' }"
|
||||||
|
:aria-pressed="answers.lateNightEating === 'sometimes'"
|
||||||
@click="answers.lateNightEating = 'sometimes'"
|
@click="answers.lateNightEating = 'sometimes'"
|
||||||
>
|
>
|
||||||
<div class="opt-head">
|
<div class="opt-head">
|
||||||
@@ -652,11 +707,12 @@ onMounted(() => {
|
|||||||
<span class="opt-tag">偶尔晚间进食</span>
|
<span class="opt-tag">偶尔晚间进食</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="opt-desc">偶尔因加班或聚餐改变晚餐时间</p>
|
<p class="opt-desc">偶尔因加班或聚餐改变晚餐时间</p>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div
|
<button type="button"
|
||||||
class="option-card"
|
class="option-card"
|
||||||
:class="{ active: answers.lateNightEating === 'rarely' }"
|
:class="{ active: answers.lateNightEating === 'rarely' }"
|
||||||
|
:aria-pressed="answers.lateNightEating === 'rarely'"
|
||||||
@click="answers.lateNightEating = 'rarely'"
|
@click="answers.lateNightEating = 'rarely'"
|
||||||
>
|
>
|
||||||
<div class="opt-head">
|
<div class="opt-head">
|
||||||
@@ -664,7 +720,7 @@ onMounted(() => {
|
|||||||
<span class="opt-tag">较少夜宵</span>
|
<span class="opt-tag">较少夜宵</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="opt-desc">通常能给晚餐和入睡留出间隔</p>
|
<p class="opt-desc">通常能给晚餐和入睡留出间隔</p>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -675,27 +731,30 @@ onMounted(() => {
|
|||||||
<strong>每日饮水充足度</strong>
|
<strong>每日饮水充足度</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="options-grid cols-3">
|
<div class="options-grid cols-3">
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.dailyWaterIntake === 'abundant' }"
|
:class="{ active: answers.dailyWaterIntake === 'abundant' }"
|
||||||
|
:aria-pressed="answers.dailyWaterIntake === 'abundant'"
|
||||||
@click="answers.dailyWaterIntake = 'abundant'"
|
@click="answers.dailyWaterIntake = 'abundant'"
|
||||||
>
|
>
|
||||||
充沛 (>2000ml)
|
充沛 (>2000ml)
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.dailyWaterIntake === 'moderate' }"
|
:class="{ active: answers.dailyWaterIntake === 'moderate' }"
|
||||||
|
:aria-pressed="answers.dailyWaterIntake === 'moderate'"
|
||||||
@click="answers.dailyWaterIntake = 'moderate'"
|
@click="answers.dailyWaterIntake = 'moderate'"
|
||||||
>
|
>
|
||||||
适中 (1500ml)
|
适中 (1500ml)
|
||||||
</div>
|
</button>
|
||||||
<div
|
<button type="button"
|
||||||
class="option-pill"
|
class="option-pill"
|
||||||
:class="{ active: answers.dailyWaterIntake === 'scarce' }"
|
:class="{ active: answers.dailyWaterIntake === 'scarce' }"
|
||||||
|
:aria-pressed="answers.dailyWaterIntake === 'scarce'"
|
||||||
@click="answers.dailyWaterIntake = 'scarce'"
|
@click="answers.dailyWaterIntake = 'scarce'"
|
||||||
>
|
>
|
||||||
偏少 (经常口渴)
|
偏少 (经常口渴)
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -709,11 +768,13 @@ onMounted(() => {
|
|||||||
size="large"
|
size="large"
|
||||||
color="#0c5045"
|
color="#0c5045"
|
||||||
:loading="submitting"
|
:loading="submitting"
|
||||||
|
:disabled="submitting"
|
||||||
@click="handleSave"
|
@click="handleSave"
|
||||||
>
|
>
|
||||||
保存健康评估
|
保存健康评估
|
||||||
</VanButton>
|
</VanButton>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- 固定底部导航栏 -->
|
<!-- 固定底部导航栏 -->
|
||||||
@@ -765,6 +826,12 @@ onMounted(() => {
|
|||||||
padding: 16px 14px;
|
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 {
|
.card-section {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
@@ -980,6 +1047,7 @@ onMounted(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-pill.active {
|
.option-pill.active {
|
||||||
@@ -996,8 +1064,13 @@ onMounted(() => {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
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 {
|
.option-card.active {
|
||||||
background: #eef7f3;
|
background: #eef7f3;
|
||||||
border-color: #0c5045;
|
border-color: #0c5045;
|
||||||
|
|||||||
+28
-13
@@ -4,26 +4,36 @@ import { useRouter } from 'vue-router'
|
|||||||
import { Button as VanButton, Field as VanField, Form as VanForm, showToast } from 'vant'
|
import { Button as VanButton, Field as VanField, Form as VanForm, showToast } from 'vant'
|
||||||
import { healthApi } from '../services/health'
|
import { healthApi } from '../services/health'
|
||||||
import { saveTokens } from '../services/auth'
|
import { saveTokens } from '../services/auth'
|
||||||
|
import { usePageRequest } from '../composables/usePageRequest'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const mode = ref<'login' | 'register'>('login')
|
const mode = ref<'login' | 'register'>('login')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const form = reactive({ phone: '', password: '', displayName: '' })
|
const form = reactive({ phone: '', password: '', displayName: '' })
|
||||||
const title = computed(() => mode.value === 'login' ? '欢迎回来' : '开启专属健康档案')
|
const title = computed(() => mode.value === 'login' ? '欢迎回来' : '开启专属健康档案')
|
||||||
|
const pageRequest = usePageRequest()
|
||||||
|
|
||||||
async function submit() {
|
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
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const output = mode.value === 'login'
|
const output = requestMode === 'login'
|
||||||
? await healthApi.login({ phone: form.phone, password: form.password })
|
? await healthApi.login({ phone, password: form.password })
|
||||||
: await healthApi.register(form)
|
: await healthApi.register({ phone, password: form.password, displayName })
|
||||||
|
if (!pageRequest.isCurrent(requestId)) return
|
||||||
saveTokens(output.accessToken, output.refreshToken)
|
saveTokens(output.accessToken, output.refreshToken)
|
||||||
showToast({ type: 'success', message: mode.value === 'login' ? '登录成功' : '注册成功' })
|
showToast({ type: 'success', message: requestMode === 'login' ? '登录成功' : '注册成功' })
|
||||||
await router.replace(mode.value === 'login' ? '/dashboard' : '/assessment')
|
await router.replace(requestMode === 'login' ? '/dashboard' : '/tcm-assessment')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : '操作失败')
|
if (pageRequest.isCurrent(requestId)) showToast(error instanceof Error ? error.message : '操作失败')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (pageRequest.isCurrent(requestId)) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -39,7 +49,7 @@ async function submit() {
|
|||||||
<h1>一人一方<br />健康与空间协同</h1>
|
<h1>一人一方<br />健康与空间协同</h1>
|
||||||
<p class="brand-copy">以睡眠为切入点,融合五感空间与动态健康方案,开启日常健康管理。</p>
|
<p class="brand-copy">以睡眠为切入点,融合五感空间与动态健康方案,开启日常健康管理。</p>
|
||||||
<div class="feature-pill-row">
|
<div class="feature-pill-row">
|
||||||
<span>🌿 六维处方</span><span>🌙 五感微环境</span><span>📊 闭环调理</span>
|
<span>体质评估</span><span>舒睡联动</span><span>健康档案</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -48,6 +58,7 @@ async function submit() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:class="{ active: mode === 'login' }"
|
:class="{ active: mode === 'login' }"
|
||||||
|
:aria-pressed="mode === 'login'" :disabled="loading"
|
||||||
@click="mode = 'login'"
|
@click="mode = 'login'"
|
||||||
>
|
>
|
||||||
账号登录
|
账号登录
|
||||||
@@ -55,6 +66,7 @@ async function submit() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:class="{ active: mode === 'register' }"
|
:class="{ active: mode === 'register' }"
|
||||||
|
:aria-pressed="mode === 'register'" :disabled="loading"
|
||||||
@click="mode = 'register'"
|
@click="mode = 'register'"
|
||||||
>
|
>
|
||||||
注册建档
|
注册建档
|
||||||
@@ -62,8 +74,8 @@ async function submit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="auth-heading">
|
<div class="auth-heading">
|
||||||
<h2 style="font-size: 20px; font-weight: 700; color: #142e27; margin-bottom: 4px;">{{ title }}</h2>
|
<h2>{{ title }}</h2>
|
||||||
<p style="color: #72847d; font-size: 13px; margin-bottom: 16px;">
|
<p>
|
||||||
{{ mode === 'login' ? '登录后查看今日健康方案与空间联动' : '第一步,填写信息创建您的专属健康档案' }}
|
{{ mode === 'login' ? '登录后查看今日健康方案与空间联动' : '第一步,填写信息创建您的专属健康档案' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,12 +85,14 @@ async function submit() {
|
|||||||
v-if="mode === 'register'"
|
v-if="mode === 'register'"
|
||||||
v-model="form.displayName"
|
v-model="form.displayName"
|
||||||
label="姓名"
|
label="姓名"
|
||||||
|
:disabled="loading" autocomplete="name" maxlength="30"
|
||||||
placeholder="请输入姓名或称呼"
|
placeholder="请输入姓名或称呼"
|
||||||
:rules="[{ required: true, message: '请输入姓名' }]"
|
:rules="[{ required: true, message: '请输入姓名' }]"
|
||||||
/>
|
/>
|
||||||
<van-field
|
<van-field
|
||||||
v-model="form.phone"
|
v-model="form.phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
|
:disabled="loading" autocomplete="username" inputmode="tel"
|
||||||
label="手机号"
|
label="手机号"
|
||||||
maxlength="11"
|
maxlength="11"
|
||||||
placeholder="请输入 11 位手机号"
|
placeholder="请输入 11 位手机号"
|
||||||
@@ -87,11 +101,12 @@ async function submit() {
|
|||||||
<van-field
|
<van-field
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
type="password"
|
type="password"
|
||||||
|
:disabled="loading" :autocomplete="mode === 'register' ? 'new-password' : 'current-password'"
|
||||||
label="密码"
|
label="密码"
|
||||||
placeholder="至少 8 位密码"
|
:placeholder="mode === 'register' ? '设置至少 8 位密码' : '请输入密码'"
|
||||||
:rules="[{ required: true, message: '请输入至少 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' ? '登 录' : '创建档案并继续' }}
|
{{ mode === 'login' ? '登 录' : '创建档案并继续' }}
|
||||||
</van-button>
|
</van-button>
|
||||||
</van-form>
|
</van-form>
|
||||||
|
|||||||
+87
-143
@@ -1,71 +1,65 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Button as VanButton, showToast } from 'vant'
|
|
||||||
import AppTabBar from '../components/AppTabBar.vue'
|
import AppTabBar from '../components/AppTabBar.vue'
|
||||||
import { clearTokens } from '../services/auth'
|
import { useDeviceScenes } from '../composables/useDeviceScenes'
|
||||||
import {
|
import { healthApi, type Dashboard, type HomeAssistantStatus } from '../services/health'
|
||||||
healthApi,
|
|
||||||
type Dashboard,
|
|
||||||
type HomeAssistantStatus
|
|
||||||
} from '../services/health'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const sceneRunning = ref<'sleep' | 'all-off'>()
|
const loadError = ref('')
|
||||||
const dashboard = ref<Dashboard>()
|
const dashboard = ref<Dashboard>()
|
||||||
const homeAssistant = ref<HomeAssistantStatus>()
|
const homeAssistant = ref<HomeAssistantStatus>()
|
||||||
|
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 greeting = computed(() => {
|
||||||
const hour = new Date().getHours()
|
const hour = new Date().getHours()
|
||||||
if (hour < 11) return '早上好'
|
return hour < 11 ? '早上好' : hour < 18 ? '下午好' : '晚上好'
|
||||||
if (hour < 18) return '下午好'
|
|
||||||
return '晚上好'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const sceneAvailable = computed(() => homeAssistant.value?.sceneDevices?.available ?? 0)
|
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() {
|
async function load() {
|
||||||
|
const sequence = ++loadSequence, statusRequest = ++statusSequence
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
try {
|
try {
|
||||||
const [dashboardResult, homeAssistantResult] = await Promise.all([
|
const [dashboardResult, homeAssistantResult] = await Promise.all([
|
||||||
healthApi.dashboard(),
|
healthApi.dashboard(), healthApi.homeAssistantStatus().catch(() => undefined),
|
||||||
healthApi.homeAssistantStatus().catch(() => undefined)
|
|
||||||
])
|
])
|
||||||
|
if (!active || sequence !== loadSequence) return
|
||||||
|
if (!dashboardResult) throw new Error('健康档案暂时无法读取,请重试')
|
||||||
dashboard.value = dashboardResult
|
dashboard.value = dashboardResult
|
||||||
homeAssistant.value = homeAssistantResult
|
if (statusRequest === statusSequence) homeAssistant.value = homeAssistantResult
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : '加载失败')
|
if (active && sequence === loadSequence) loadError.value = error instanceof Error ? error.message : '加载失败,请重试'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (active && sequence === loadSequence) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function refreshDevices() {
|
||||||
async function executeScene(scene: 'sleep' | 'all-off') {
|
if (!active) return
|
||||||
if (scene === 'sleep' && !dashboard.value?.tcmResult?.hasCompleted) {
|
const sequence = ++statusSequence
|
||||||
showToast('请先完成中医四诊,生成体质倾向')
|
|
||||||
router.push('/tcm-assessment')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sceneRunning.value = scene
|
|
||||||
try {
|
try {
|
||||||
const result = scene === 'sleep'
|
const result = await healthApi.homeAssistantStatus()
|
||||||
? await healthApi.executeSleepScene()
|
if (active && sequence === statusSequence) homeAssistant.value = result
|
||||||
: await healthApi.executeAllOffScene()
|
} catch { /* 保留最后回读,不把请求失败显示成全部关闭。 */ }
|
||||||
showToast({ type: result.success ? 'success' : 'fail', message: result.message })
|
|
||||||
homeAssistant.value = await healthApi.homeAssistantStatus()
|
|
||||||
} catch (error) {
|
|
||||||
showToast(error instanceof Error ? error.message : '场景执行失败')
|
|
||||||
} finally {
|
|
||||||
sceneRunning.value = undefined
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
clearTokens()
|
|
||||||
router.replace('/auth')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
onUnmounted(() => { active = false; loadSequence++; statusSequence++ })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -73,123 +67,73 @@ onMounted(load)
|
|||||||
<header class="dashboard-header">
|
<header class="dashboard-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">EVERGREEN · TODAY</p>
|
<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>
|
<p>{{ new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' }).format(new Date()) }}</p>
|
||||||
</div>
|
</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) || '常' }}
|
{{ dashboard?.user.displayName?.slice(0, 1) || '常' }}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
<section v-if="loading" class="section-card page-load-state" role="status" aria-live="polite">
|
||||||
<section v-if="loading" class="section-card loading-card">正在读取今日健康方案…</section>
|
<span class="page-loading-mark" aria-hidden="true"></span><p>正在读取健康档案与舒睡设备…</p>
|
||||||
|
|
||||||
<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>
|
||||||
|
<section v-else-if="loadError" class="section-card page-error-state" role="alert">
|
||||||
<!-- 未完成评估提示横幅 -->
|
<h2>暂时无法读取首页</h2><p>{{ loadError }}</p>
|
||||||
<section v-if="!dashboard?.hasCompletedAssessment" class="assessment-guide-banner" @click="router.replace('/assessment')">
|
<button class="retry-button" type="button" @click="load">重新加载</button>
|
||||||
<div class="guide-badge">重要提醒</div>
|
|
||||||
<div class="guide-body">
|
|
||||||
<div class="guide-text">
|
|
||||||
<h3>完善个人基本信息与健康评估</h3>
|
|
||||||
<p>尚未记录身体及睡眠质量、运动情况、饮食情况。健康评估仅用于了解基本情况;设备启动参数由中医四诊体质结果生成。</p>
|
|
||||||
</div>
|
|
||||||
<button class="guide-btn" type="button">去评估 ></button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
<template v-else-if="dashboard">
|
||||||
<!-- 智能家居启动参数仅来源于中医四诊体质结果 -->
|
|
||||||
<section class="section-card personalized-startup-card">
|
<section class="section-card personalized-startup-card">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<span>体质专属舒睡启动参数</span>
|
<span>我的舒睡空间</span>
|
||||||
|
<span class="constitution-chip">{{ dashboard.tcmResult?.hasCompleted ? dashboard.tcmResult.constitutionName : '待生成体质' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="startup-card-desc">
|
<p class="startup-card-desc">体质决定基础预设,身体档案与室内环境用于补充调整。</p>
|
||||||
{{ dashboard?.tcmResult?.hasCompleted
|
<p class="scene-availability" :class="{ unavailable: !homeAssistant?.connected || sceneAvailable === 0 }">{{ sceneHint }}</p>
|
||||||
? `依据${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>{{ dashboard?.sceneConfig?.enableSpeakerSleepMode ? '开启睡眠模式' : '关闭睡眠模式' }} · {{ dashboard?.sceneConfig?.speakerVolumePercent || 15 }}%</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>
|
|
||||||
|
|
||||||
<div class="startup-scene-actions">
|
<div class="startup-scene-actions">
|
||||||
<button
|
<button class="startup-action-primary" type="button"
|
||||||
class="startup-action-primary"
|
:disabled="!dashboard.tcmResult?.hasCompleted || !homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
|
||||||
type="button"
|
@click="executeScene('sleep')">{{ sceneRunning === 'sleep' ? '正在开启…' : '一键开启舒睡' }}</button>
|
||||||
:disabled="!dashboard?.tcmResult?.hasCompleted || !homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
|
<button class="startup-action-secondary" type="button" :disabled="sceneRunning === 'all-off'"
|
||||||
@click="executeScene('sleep')"
|
@click="executeScene('all-off')">{{ sceneRunning === 'all-off' ? '正在关闭…' : '一键关闭' }}</button>
|
||||||
>
|
</div>
|
||||||
{{ sceneRunning === 'sleep' ? '正在开启…' : '一键开启' }}
|
<p v-if="sceneRunning || actionFeedback" class="scene-feedback" :class="actionFeedback?.type" role="status" aria-live="polite">
|
||||||
</button>
|
{{ sceneRunning ? sceneRunning === 'sleep' ? '正在开启设备;如需中止,可点击一键关闭。' : '正在关闭设备,请等待回读结果。' : actionFeedback?.message }}
|
||||||
<button
|
</p>
|
||||||
class="startup-action-secondary"
|
<button v-if="!dashboard.tcmResult?.hasCompleted" class="inline-link" type="button" @click="router.push('/tcm-assessment')">去完成中医四诊 ›</button>
|
||||||
type="button"
|
<button class="detail-entry-bar" type="button" @click="router.push('/device-control')">
|
||||||
:disabled="!homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
|
<span class="entry-left"><span class="entry-symbol" aria-hidden="true">▦</span><span>
|
||||||
@click="executeScene('all-off')"
|
<strong>查看舒睡设备</strong><small>设备总览、快捷开关与完整参数</small>
|
||||||
>
|
</span></span><span class="arrow" aria-hidden="true">›</span>
|
||||||
{{ sceneRunning === 'all-off' ? '正在关闭…' : '一键关闭' }}
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
<p class="startup-card-desc">右侧全身轻柔按摩;灯灭后停止按摩。香型需人工更换。</p>
|
||||||
<div class="detail-entry-bar" @click="router.push('/device-control')">
|
<p v-if="dashboard.sceneConfig?.enableUv" class="startup-card-desc">除湿机预设开启 UV 消杀。</p>
|
||||||
<div class="entry-left">
|
<p v-if="comfortReason" class="startup-card-desc">{{ comfortReason }}</p>
|
||||||
<span class="icon">⚙️</span>
|
</details>
|
||||||
<div>
|
|
||||||
<strong>进入设备详情与独立参数调节</strong>
|
|
||||||
<small>显示每个设备在线状态,可单独开关与精细滑块调节</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="arrow">></span>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
<section v-if="!dashboard.hasCompletedAssessment" class="assessment-guide-banner">
|
||||||
<section class="metric-strip">
|
<span class="guide-badge">完善档案</span>
|
||||||
<div><small>睡眠目标</small><strong>7–8<em>h</em></strong></div>
|
<div class="guide-body"><div class="guide-text"><h3>补充身体指标与生活习惯</h3><p>健康评估记录基本情况,不生成另一套设备启动参数。</p></div>
|
||||||
<div><small>活动目标</small><strong>30<em>min</em></strong></div>
|
<button class="guide-btn" type="button" @click="router.push('/assessment')">去评估 ›</button></div>
|
||||||
<div><small>方案版本</small><strong>V{{ dashboard?.currentPlan?.version || 0 }}</strong></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
<section v-if="dashboard.currentPlan" class="hero-card health-advice-card">
|
||||||
<p class="medical-note">健康建议仅用于日常管理演示,不构成医疗诊断或治疗意见。</p>
|
<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>
|
</template>
|
||||||
|
|
||||||
<!-- 固定底部导航栏 -->
|
|
||||||
<AppTabBar />
|
<AppTabBar />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
+47
-1569
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Button as VanButton, showConfirmDialog, showToast } from 'vant'
|
import { Button as VanButton, showConfirmDialog, showToast } from 'vant'
|
||||||
import AppTabBar from '../components/AppTabBar.vue'
|
import AppTabBar from '../components/AppTabBar.vue'
|
||||||
|
import { usePageRequest } from '../composables/usePageRequest'
|
||||||
|
import { ageFromBirthday } from '../services/formValidation'
|
||||||
import { clearTokens } from '../services/auth'
|
import { clearTokens } from '../services/auth'
|
||||||
import { healthApi, type Dashboard, type HealthAssessmentOutput } from '../services/health'
|
import { healthApi, type Dashboard, type HealthAssessmentOutput } from '../services/health'
|
||||||
|
|
||||||
@@ -10,34 +12,42 @@ const router = useRouter()
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const dashboard = ref<Dashboard>()
|
const dashboard = ref<Dashboard>()
|
||||||
const assessment = ref<HealthAssessmentOutput>()
|
const assessment = ref<HealthAssessmentOutput>()
|
||||||
|
const loadError = ref('')
|
||||||
|
const pageRequest = usePageRequest()
|
||||||
|
const logoutBusy = ref(false)
|
||||||
|
let active = true
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
|
const requestId = pageRequest.begin()
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
try {
|
try {
|
||||||
const [dashRes, assessRes] = await Promise.all([
|
const [dashRes, assessRes] = await Promise.all([
|
||||||
healthApi.dashboard(),
|
healthApi.dashboard(),
|
||||||
healthApi.getAssessment().catch(() => undefined)
|
healthApi.getAssessment()
|
||||||
])
|
])
|
||||||
|
if (!pageRequest.isCurrent(requestId)) return
|
||||||
|
if (!dashRes || !assessRes) throw new Error('健康档案暂时无法读取,请重试')
|
||||||
dashboard.value = dashRes
|
dashboard.value = dashRes
|
||||||
assessment.value = assessRes
|
assessment.value = assessRes
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err instanceof Error ? err.message : '加载失败')
|
if (pageRequest.isCurrent(requestId)) loadError.value = err instanceof Error ? err.message : '加载失败,请重试'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (pageRequest.isCurrent(requestId)) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bmiDisplay = computed(() => {
|
const bmiDisplay = computed(() => {
|
||||||
if (dashboard.value?.bmi) {
|
if (typeof dashboard.value?.bmi === 'number' && Number.isFinite(dashboard.value.bmi) && dashboard.value.bmi > 0) {
|
||||||
return {
|
return {
|
||||||
val: dashboard.value.bmi,
|
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 {
|
return {
|
||||||
val: assessment.value.bmi,
|
val: assessment.value.bmi,
|
||||||
status: assessment.value.bmiStatus || '正常'
|
status: assessment.value.bmiStatus || '未提供分类'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -45,12 +55,7 @@ const bmiDisplay = computed(() => {
|
|||||||
|
|
||||||
const ageDisplay = computed(() => {
|
const ageDisplay = computed(() => {
|
||||||
const raw = dashboard.value?.profile?.birthday || assessment.value?.basicInfo.birthday
|
const raw = dashboard.value?.profile?.birthday || assessment.value?.basicInfo.birthday
|
||||||
if (!raw) return undefined
|
return ageFromBirthday(raw)
|
||||||
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
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 健康评估只展示生活习惯画像;体质由中医四诊独立判定。
|
// 健康评估只展示生活习惯画像;体质由中医四诊独立判定。
|
||||||
@@ -64,7 +69,7 @@ const profileTags = computed(() => {
|
|||||||
list.push({ label: '夜间偏热感', desc: '自述夜间较容易感到闷热', type: 'blue' })
|
list.push({ label: '夜间偏热感', desc: '自述夜间较容易感到闷热', type: 'blue' })
|
||||||
} else if (ans.temperaturePreference === 'cold_fear') {
|
} else if (ans.temperaturePreference === 'cold_fear') {
|
||||||
list.push({ label: '夜间偏冷感', desc: '自述睡眠时较容易感到寒冷', type: 'gold' })
|
list.push({ label: '夜间偏冷感', desc: '自述睡眠时较容易感到寒冷', type: 'gold' })
|
||||||
} else {
|
} else if (ans.temperaturePreference === 'normal') {
|
||||||
list.push({ label: '冷热感受适中', desc: '自述夜间温度感受较平稳', type: 'green' })
|
list.push({ label: '冷热感受适中', desc: '自述夜间温度感受较平稳', type: 'green' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,19 +91,23 @@ const profileTags = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
|
if (!active || logoutBusy.value) return
|
||||||
|
logoutBusy.value = true
|
||||||
showConfirmDialog({
|
showConfirmDialog({
|
||||||
title: '确认退出',
|
title: '确认退出',
|
||||||
message: '确定要退出当前账号登录吗?'
|
message: '确定要退出当前账号登录吗?'
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
|
if (!active) return
|
||||||
clearTokens()
|
clearTokens()
|
||||||
showToast('已安全退出')
|
showToast('已安全退出')
|
||||||
router.replace('/auth')
|
router.replace('/auth')
|
||||||
}).catch(() => {})
|
}).catch(() => {}).finally(() => { if (active) logoutBusy.value = false })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
|
onUnmounted(() => { active = false })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -110,7 +119,7 @@ onMounted(() => {
|
|||||||
<span>{{ (dashboard?.user.displayName || '客').slice(0, 1) }}</span>
|
<span>{{ (dashboard?.user.displayName || '客').slice(0, 1) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<h2>{{ dashboard?.user.displayName || '尊贵会员' }}</h2>
|
<h2>{{ dashboard?.user.displayName || '我的健康档案' }}</h2>
|
||||||
<p class="phone-text">{{ dashboard?.user.phone || '健康管理账号' }}</p>
|
<p class="phone-text">{{ dashboard?.user.phone || '健康管理账号' }}</p>
|
||||||
<span class="user-badge">常青树 · 智能健康睡眠会员</span>
|
<span class="user-badge">常青树 · 智能健康睡眠会员</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,17 +127,22 @@ onMounted(() => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="profile-content">
|
<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">
|
<section class="profile-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>身体基线档案</h3>
|
<h3>身体基线档案</h3>
|
||||||
<button class="edit-btn" @click="router.replace('/assessment')">编辑指标</button>
|
<button class="edit-btn" type="button" @click="router.replace('/assessment')">编辑指标</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metrics-grid">
|
<div class="metrics-grid">
|
||||||
<div class="metric-item">
|
<div class="metric-item">
|
||||||
<span class="m-label">性别</span>
|
<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>
|
||||||
<div class="metric-item">
|
<div class="metric-item">
|
||||||
<span class="m-label">身高</span>
|
<span class="m-label">身高</span>
|
||||||
@@ -142,7 +156,7 @@ onMounted(() => {
|
|||||||
<span class="m-label">体重</span>
|
<span class="m-label">体重</span>
|
||||||
<strong class="m-val">{{ dashboard?.profile?.weightKg || assessment?.basicInfo.weightKg || '--' }} <small>kg</small></strong>
|
<strong class="m-val">{{ dashboard?.profile?.weightKg || assessment?.basicInfo.weightKg || '--' }} <small>kg</small></strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-item">
|
<div class="metric-item bmi-metric">
|
||||||
<span class="m-label">BMI 指数</span>
|
<span class="m-label">BMI 指数</span>
|
||||||
<strong class="m-val" :class="{ highlight: bmiDisplay }">
|
<strong class="m-val" :class="{ highlight: bmiDisplay }">
|
||||||
{{ bmiDisplay?.val || '--' }}
|
{{ bmiDisplay?.val || '--' }}
|
||||||
@@ -155,7 +169,7 @@ onMounted(() => {
|
|||||||
<!-- 专属评估画像标签 -->
|
<!-- 专属评估画像标签 -->
|
||||||
<section class="profile-card">
|
<section class="profile-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>健康评估画像与专属策略</h3>
|
<h3>生活习惯画像</h3>
|
||||||
<span class="status-indicator" :class="{ done: assessment?.hasCompletedAssessment }">
|
<span class="status-indicator" :class="{ done: assessment?.hasCompletedAssessment }">
|
||||||
{{ assessment?.hasCompletedAssessment ? '已完成评估' : '未评估' }}
|
{{ assessment?.hasCompletedAssessment ? '已完成评估' : '未评估' }}
|
||||||
</span>
|
</span>
|
||||||
@@ -208,8 +222,8 @@ onMounted(() => {
|
|||||||
<!-- 当前健康方案 -->
|
<!-- 当前健康方案 -->
|
||||||
<section class="profile-card" v-if="dashboard?.currentPlan">
|
<section class="profile-card" v-if="dashboard?.currentPlan">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>当前执行方案</h3>
|
<h3>日常健康建议</h3>
|
||||||
<span class="version-tag">版本 v{{ dashboard.currentPlan.version }}.0</span>
|
<span class="version-tag">版本 v{{ dashboard.currentPlan.version }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="plan-summary-text">{{ dashboard.currentPlan.summary }}</p>
|
<p class="plan-summary-text">{{ dashboard.currentPlan.summary }}</p>
|
||||||
|
|
||||||
@@ -231,7 +245,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 快捷功能入口与操作菜单 -->
|
<!-- 快捷功能入口与操作菜单 -->
|
||||||
<section class="action-card-menu">
|
<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-left">
|
||||||
<div class="menu-icon icon-assess">
|
<div class="menu-icon icon-assess">
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -247,9 +261,9 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="arrow">></span>
|
<span class="arrow">></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-left">
|
||||||
<div class="menu-icon icon-device">
|
<div class="menu-icon icon-device">
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -263,16 +277,17 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="menu-text">
|
<div class="menu-text">
|
||||||
<strong>设备详情与参数调节</strong>
|
<strong>设备详情与参数调节</strong>
|
||||||
<small>查看7台设备状态、独立开关与精细参数设置</small>
|
<small>查看设备状态、快捷开关与完整参数</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="arrow">></span>
|
<span class="arrow">></span>
|
||||||
</div>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 退出登录按钮 -->
|
<!-- 退出登录按钮 -->
|
||||||
<div class="logout-wrap">
|
<div class="logout-wrap">
|
||||||
<button class="logout-btn" @click="handleLogout">
|
<button class="logout-btn" type="button" :disabled="logoutBusy" @click="handleLogout">
|
||||||
退出登录
|
退出登录
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<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 { showFailToast, showSuccessToast } from 'vant'
|
||||||
import AppTabBar from '../components/AppTabBar.vue'
|
import AppTabBar from '../components/AppTabBar.vue'
|
||||||
|
import { usePageRequest } from '../composables/usePageRequest'
|
||||||
import { healthApi, type TcmAssessmentResult } from '../services/health'
|
import { healthApi, type TcmAssessmentResult } from '../services/health'
|
||||||
|
|
||||||
type Question = { key: string; title: string; options: { value: string; label: string }[] }
|
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 result = ref<TcmAssessmentResult>()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const savedSnapshot = ref<string>()
|
||||||
|
const pageRequest = usePageRequest()
|
||||||
const questions = sections.flatMap(section => section.questions)
|
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 {
|
try {
|
||||||
const saved = await healthApi.getTcmAssessment()
|
const saved = await healthApi.getTcmAssessment()
|
||||||
|
if (!pageRequest.isCurrent(requestId)) return
|
||||||
|
if (!saved) throw new Error('四诊记录读取失败,请重试')
|
||||||
if (saved.hasCompleted) {
|
if (saved.hasCompleted) {
|
||||||
result.value = saved
|
result.value = saved
|
||||||
Object.assign(answers, saved.answers)
|
Object.assign(answers, saved.answers)
|
||||||
Object.assign(notes, saved.notes)
|
Object.assign(notes, saved.notes)
|
||||||
|
savedSnapshot.value = formSnapshot()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showFailToast(error instanceof Error ? error.message : '四诊记录加载失败')
|
if (!pageRequest.isCurrent(requestId)) return
|
||||||
|
loadError.value = error instanceof Error ? error.message : '四诊记录加载失败,请重试'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (pageRequest.isCurrent(requestId)) loading.value = false
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
onMounted(loadExisting)
|
||||||
|
|
||||||
async function submit() {
|
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) {
|
if (missing) {
|
||||||
showFailToast(`请先选择“${missing.title}”`)
|
showFailToast(`请先选择“${missing.title}”`)
|
||||||
document.getElementById(`tcm-${missing.key}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
document.getElementById(`tcm-${missing.key}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const requestId = pageRequest.begin()
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
result.value = await healthApi.saveTcmAssessment({
|
const saved = await healthApi.saveTcmAssessment({
|
||||||
answers: { ...answers }, notes: { ...notes }
|
answers: { ...answers }, notes: { ...notes }
|
||||||
})
|
})
|
||||||
|
if (!pageRequest.isCurrent(requestId)) return
|
||||||
|
if (!saved?.hasCompleted) throw new Error('未生成完整体质结果,请重试')
|
||||||
|
result.value = saved
|
||||||
|
savedSnapshot.value = formSnapshot()
|
||||||
showSuccessToast('体质倾向已生成,下次一键启动将使用新参数')
|
showSuccessToast('体质倾向已生成,下次一键启动将使用新参数')
|
||||||
|
await nextTick()
|
||||||
|
if (!pageRequest.isCurrent(requestId)) return
|
||||||
document.getElementById('tcm-result')?.scrollIntoView({ behavior: 'smooth' })
|
document.getElementById('tcm-result')?.scrollIntoView({ behavior: 'smooth' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!pageRequest.isCurrent(requestId)) return
|
||||||
showFailToast(error instanceof Error ? error.message : '生成失败,请稍后重试')
|
showFailToast(error instanceof Error ? error.message : '生成失败,请稍后重试')
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
if (pageRequest.isCurrent(requestId)) saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -121,11 +150,13 @@ async function submit() {
|
|||||||
<p class="eyebrow">EVERGREEN · FOUR DIAGNOSES</p>
|
<p class="eyebrow">EVERGREEN · FOUR DIAGNOSES</p>
|
||||||
<h1>中医四诊</h1>
|
<h1>中医四诊</h1>
|
||||||
<p>填写望、闻、问、切的观察信息,生成九大体质中的演示倾向与家居参数。</p>
|
<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>
|
</header>
|
||||||
|
|
||||||
<section v-if="result?.hasCompleted" id="tcm-result" class="tcm-card result-card">
|
<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>
|
<h2>{{ result.constitutionName }}</h2>
|
||||||
<p v-if="result.secondaryName">次要倾向:{{ result.secondaryName }}</p>
|
<p v-if="result.secondaryName">次要倾向:{{ result.secondaryName }}</p>
|
||||||
<p>{{ result.summary }}</p>
|
<p>{{ result.summary }}</p>
|
||||||
@@ -134,17 +165,24 @@ async function submit() {
|
|||||||
<div><span>音乐风格建议</span><strong>{{ result.musicRecommendation }}</strong></div>
|
<div><span>音乐风格建议</span><strong>{{ result.musicRecommendation }}</strong></div>
|
||||||
<div><span>室温建议</span><strong>{{ result.roomTemperatureRecommendation }}</strong></div>
|
<div><span>室温建议</span><strong>{{ result.roomTemperatureRecommendation }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="capability-note">香型需人工更换,音乐风格暂不能自动选择,室温仅供参考;一键启动只下发当前已支持的设备参数。</p>
|
<p class="capability-note">香型需人工更换,体质音乐可由音箱自动播放,室温仅供参考;一键启动只下发当前已支持的设备参数。</p>
|
||||||
<div class="parameter-preview">
|
<div class="parameter-preview">
|
||||||
<span>一键启动预设</span>
|
<span>一键启动预设</span>
|
||||||
<p>湿度 {{ result.sceneConfig.targetHumidity }}% · 灯光 {{ result.sceneConfig.lightColorTemperatureKelvin }}K</p>
|
<p>湿度 {{ result.sceneConfig.targetHumidity }}% · 灯光 {{ result.sceneConfig.lightColorTemperatureKelvin }}K</p>
|
||||||
<p>香氛{{ result.sceneConfig.enableFragrance ? '开启' : '关闭' }} · 音箱睡眠模式 {{ result.sceneConfig.speakerVolumePercent }}% 音量</p>
|
<p>香氛{{ result.sceneConfig.enableFragrance ? '开启' : '关闭' }} · 体质音乐循环播放,从 50% 每 5 秒降 1% 至停止</p>
|
||||||
<p>床垫加热按重庆冬季天气决定;软硬度按健康档案 BMI 给出可手动修改的演示起点。</p>
|
<p>床垫加热根据当前室温与体质建议区间决定;室温不可用时不自动加热。软硬度按健康档案 BMI 给出演示起点,可手动调整。</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<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">
|
<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 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">
|
<div v-for="question in section.questions" :id="`tcm-${question.key}`" :key="question.key" class="question">
|
||||||
@@ -163,6 +201,7 @@ async function submit() {
|
|||||||
</template>
|
</template>
|
||||||
<label v-if="section.title === '切诊'" class="note-label">切诊备注(选填)<textarea v-model="notes.pulseNote" maxlength="300" placeholder="如有专业测量结果可补充" /></label>
|
<label v-if="section.title === '切诊'" class="note-label">切诊备注(选填)<textarea v-model="notes.pulseNote" maxlength="300" placeholder="如有专业测量结果可补充" /></label>
|
||||||
</section>
|
</section>
|
||||||
|
</fieldset>
|
||||||
<button class="submit-button" type="button" :disabled="saving" @click="submit">
|
<button class="submit-button" type="button" :disabled="saving" @click="submit">
|
||||||
{{ saving ? '正在生成…' : result?.hasCompleted ? '重新评估并更新参数' : '生成我的体质与家居参数' }}
|
{{ saving ? '正在生成…' : result?.hasCompleted ? '重新评估并更新参数' : '生成我的体质与家居参数' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -180,6 +219,11 @@ async function submit() {
|
|||||||
.tcm-header > p:not(.eyebrow) { color: #71837b; font-size: 13px; line-height: 1.65; }
|
.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; }
|
.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-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 { 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 h2 { margin: 0; font-size: 19px; }
|
||||||
.section-heading p { margin: 0; color: #86958d; font-size: 11px; text-align: right; }
|
.section-heading p { margin: 0; color: #86958d; font-size: 11px; text-align: right; }
|
||||||
|
|||||||
Reference in New Issue
Block a user