Compare commits

..
6 Commits
Author SHA1 Message Date
fuenmao 09e8bded7e 1111 2026-11-13 14:03:38 +08:00
fuenmao db0c578890 1111 2026-11-11 16:12:02 +08:00
fuenmao 20467ca479 111 2026-10-09 18:06:21 +08:00
fuenmao 563836f789 1 2026-10-09 16:46:35 +08:00
fuenmao 4925c4c74d 111 2026-10-08 10:16:36 +08:00
fuenmao 65eff42cc8 111 2026-09-28 16:36:47 +08:00
71 changed files with 6366 additions and 2351 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#0c5045" />
<meta name="theme-color" content="#1677ff" />
<title>常青树健康管理</title>
<script>
function hideWxToolbar() {
+6
View File
@@ -4,6 +4,12 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"test:devices": "node scripts/test-device-modules.mjs",
"test:device-pages": "node scripts/test-device-pages.mjs",
"test:navigation": "node scripts/test-navigation.mjs",
"test:assessments": "node scripts/test-assessment-pages.mjs",
"test:pages": "node scripts/test-app-pages.mjs",
"test": "npm run test:devices && npm run test:device-pages && npm run test:navigation && npm run test:assessments && npm run test:pages",
"dev": "vite --host 0.0.0.0",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0"
Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

+349
View File
@@ -0,0 +1,349 @@
// 全站页面的假数据逻辑和 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 = {}, ib43Get) {
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/http.ts') && ib43Get) return { default: { get: ib43Get } }
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', props = {}) {
const componentPath = name.includes('/') ? name : `views/${name}`
const { default: view } = await server.ssrLoadModule(`/src/${componentPath}.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, props) }); 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(), scenePresets: [{ deviceName: '米家除湿机', entityId: 'humidifier.keep', action: '恒湿', detail: '目标 55%', available: true }],
} })
for (const value of ['我的舒睡空间', '一键开启舒睡', '查看舒睡设备', '睡眠数据', '查看体质启动预设', '打开我的健康档案']) assert.ok(html.includes(value), value)
assert.match(html, /<details class="startup-preset-details">/)
assert.match(html, /米家除湿机/)
assert.doesNotMatch(html, /助眠灯光|2700K/)
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('automation card separates the rule switch from physical readback and preserves previous results while stopped', async () => {
const state = { available: true, enabled: false, actionLabel: '关闭窗帘', targetLabel: '美百帘窗帘', records: [
{ id: 'one', actionKey: 'curtain-close', targetLabel: '美百帘窗帘', triggeredAtMs: 1794380000000, result: 'readback-confirmed' },
{ id: 'two', actionKey: 'curtain-close', targetLabel: '美百帘窗帘', triggeredAtMs: 1794381000000, result: 'sent-readback-unconfirmed' },
] }
const props = { bindingId: 'simulation-left' }
const stopped = await render('components/Ib43AutomationCard', { status: state }, '/sleep-data', props)
assert.match(stopped, /已停止/)
assert.match(stopped, /开启联动/)
assert.match(stopped, /美百帘窗帘 · 已确认关闭/)
assert.match(stopped, /已发出关帘指令,未确认关闭/)
const started = await render('components/Ib43AutomationCard', { status: { ...state, enabled: true } }, '/sleep-data', props)
assert.match(started, /停止联动/)
assert.doesNotMatch(started, /开启联动/)
const both = await render('components/Ib43AutomationCard', { status: {
...state,
rules: [
{ actionKey: 'curtain-close', actionLabel: '关闭窗帘', enabled: false, targetLabel: '美百帘窗帘' },
{ actionKey: 'dehumidifier-humidity-preview', actionLabel: '湿度偏高时开启除湿机', enabled: true,
targetLabel: '除湿机', previewOnly: true, conditionLabel: '室内相对湿度超过 55% 时记录开机计划。' },
{ actionKey: 'dehumidifier-humidity-start', actionLabel: '湿度偏高时开启除湿机', enabled: false,
targetLabel: '除湿机', previewOnly: false, conditionLabel: '室内相对湿度超过 55% 且除湿机关机时,发送开机指令。' },
{ actionKey: 'climate-temperature-preview', actionLabel: '按室温判断空调模式', enabled: false,
targetLabel: '空调温控器2', previewOnly: true, conditionLabel: '室温高于 25℃ 时计划制冷,低于 22℃ 时计划制热。' },
{ actionKey: 'climate-heat-start', actionLabel: '室温偏低时开启空调制热', enabled: false,
targetLabel: '空调温控器2', previewOnly: false, conditionLabel: '室温低于 22℃ 且空调关机时,开启制热并设为 22℃。' },
{ actionKey: 'wake-fragrance-quick-release', actionLabel: '让香氛机「唤醒」出香一次', enabled: false,
targetLabel: '香氛机 唤醒', previewOnly: false },
{ actionKey: 'speaker-sleep-music', actionLabel: '用 Xiaomi Sound Pro 播放舒睡音乐', enabled: false,
targetLabel: 'Xiaomi Sound Pro', previewOnly: false },
],
records: [...state.records,
{ id: 'three', actionKey: 'dehumidifier-humidity-preview', targetLabel: '除湿机',
triggeredAtMs: 1794382000000, result: 'preview-would-start' },
{ id: 'four', actionKey: 'dehumidifier-humidity-start', targetLabel: '除湿机',
triggeredAtMs: 1794383000000, result: 'readback-confirmed' },
{ id: 'five', actionKey: 'climate-temperature-preview', targetLabel: '空调温控器2',
triggeredAtMs: 1794384000000, result: 'preview-climate-cool' },
{ id: 'six', actionKey: 'climate-heat-start', targetLabel: '空调温控器2',
triggeredAtMs: 1794385000000, result: 'readback-confirmed' },
{ id: 'seven', actionKey: 'wake-fragrance-quick-release', targetLabel: '香氛机 唤醒',
triggeredAtMs: 1794386000000, result: 'fragrance-release-sent' },
{ id: 'eight', actionKey: 'speaker-sleep-music', targetLabel: 'Xiaomi Sound Pro',
triggeredAtMs: 1794387000000, result: 'speaker-playback-sent' }],
} }, '/sleep-data', props)
for (const text of ['开启联动', '停止预演', '超过 55% 且除湿机关机时', '除湿机 · 已确认开启', '除湿机 · 预演通过,计划开启除湿机',
'按室温判断空调模式(预演)', '高于 25℃ 时计划制冷', '空调温控器2 · 预演通过,计划制冷',
'室温偏低时开启空调制热', '低于 22℃ 且空调关机时', '空调温控器2 · 已确认制热至 22℃',
'香氛机 唤醒 · 已发送一次快速出香指令', 'Xiaomi Sound Pro · 已发送舒睡音乐播放指令'])
assert.ok(both.includes(text), text)
})
const sleepBed = { id: 'left-bed', bedName: '卧室左侧', side: 'left', simulation: true, endedAtMs: null }
const rightBed = { id: 'right-bed', bedName: '卧室右侧', side: 'right', simulation: false, endedAtMs: null }
test('sleep data renders stale readings and missing fields separately, with the same time for UTC strings and history milliseconds', async () => {
const instant = Date.parse('2026-11-11T01:55:21Z')
const readings = {
heartRate: { value: 72, receivedAt: '2026-11-11 01:55:21', freshness: 'stale' },
respiration: { value: null, receivedAt: '2026-11-11 01:55:21', freshness: 'recent' },
snoring: { value: 0, receivedAt: '2026-11-11 01:55:21', freshness: 'stale' },
}
const html = await render('SleepDataView', {
bindingsLoading: false, beds: [sleepBed], bedId: sleepBed.id,
current: { bed: sleepBed, active: true, transport: 'connected', readings },
history: [{ id: '1', receivedAtMs: String(instant), readings }],
}, '/sleep-data')
assert.match(html, /模拟设备/)
assert.match(html, /数据服务已连接/)
assert.match(html, /数据已过期/)
assert.match(html, /暂无有效读数/)
assert.match(html, /暂无上报/)
assert.match(html, /未打鼾/)
assert.doesNotMatch(html, /离床/)
const { formatIb43Time } = await server.ssrLoadModule('/src/services/ib43.ts')
assert.equal(formatIb43Time(readings.heartRate.receivedAt), formatIb43Time(String(instant)))
const empty = await render('SleepDataView', { bindingsLoading: false }, '/sleep-data')
assert.match(empty, /暂无绑定床位/)
assert.doesNotMatch(empty, /最近读数|历史记录/)
})
test('sleep data discards late results from a previous bed and keeps the time range and cursor when paging', async () => {
const oldCurrent = deferred(), oldHistory = deferred(), historyRequests = []
const cursor = '9007199254740993'
const env = environment({}, async (url, options) => {
if (url.endsWith('/bindings')) return [sleepBed, rightBed]
if (url.includes('/left-bed/')) return url.endsWith('/current') ? oldCurrent.promise : oldHistory.promise
if (url.endsWith('/right-bed/current')) return { bed: rightBed, readings: {} }
if (url.endsWith('/right-bed/history')) {
historyRequests.push(options.params)
return options.params.beforeId == null ? { items: [{ id: 'right-1' }], nextBeforeId: cursor } :
{ items: [{ id: 'right-2' }], nextBeforeId: null }
}
throw new Error(`Unexpected request: ${url}`)
})
const page = env.page('SleepDataView')
await page.loadBindings()
assert.equal(page.bedId.value, 'left-bed')
page.bedId.value = 'right-bed'
page.loadBed()
await new Promise(resolve => setImmediate(resolve))
oldCurrent.resolve({ bed: sleepBed }); oldHistory.resolve({ items: [{ id: 'left-1' }] })
await new Promise(resolve => setImmediate(resolve))
assert.equal(page.current.value.bed.id, 'right-bed')
assert.equal(page.history.value[0].id, 'right-1')
await page.loadHistory(true)
assert.equal(historyRequests[1].beforeId, cursor)
assert.equal(historyRequests[1].fromMs, historyRequests[0].fromMs)
assert.equal(historyRequests[1].toMs, historyRequests[0].toMs)
assert.equal(page.history.value[1].id, 'right-2')
assert.equal(page.nextBeforeId.value, undefined)
})
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)
}
})
+253
View File
@@ -0,0 +1,253 @@
// Isolated page logic regression checks. No HTTP, accounts, or real devices.
import assert from 'node:assert/strict'
import { test } from 'node:test'
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import { fileURLToPath } from 'node:url'
import { parse, compileScript } from 'vue/compiler-sfc'
import ts from 'typescript'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src')
const deferred = () => {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const healthAnswers = () => ({
sleepDuration: '7-8', fallingAsleepSpeed: 'normal', temperaturePreference: 'normal',
nightAwakening: 'none', mattressPreference: 'medium', exerciseFrequency: 'daily',
muscleFatigueArea: 'none', activeTimeOfDay: 'morning', mealRegularity: 'regular',
flavorPreference: 'light', lateNightEating: 'rarely', dailyWaterIntake: 'moderate'
})
const tcmRecord = () => ({
hasCompleted: true, constitutionCode: 'balanced', constitutionName: '平和质',
answers: { face: 'rosy', body: 'balanced', spirit: 'energetic', voice: 'normal',
coldHeat: 'neutral', sweating: 'normal', mouth: 'normal', stool: 'normal',
sleep: 'good', emotion: 'steady', allergy: 'none', pulse: 'unknown' },
notes: { tongue: '', odor: '', breath: '', pulseNote: '' }, sceneConfig: {}
})
function environment(overrides = {}) {
const mounted = [], disposed = [], toasts = [], navigations = [], scrolls = [], ticks = []
const vue = {
defineComponent: options => options, ref: value => ({ value }), reactive: value => value,
computed: getter => ({ get value() { return getter() } }),
onMounted: handler => mounted.push(handler), onUnmounted: handler => disposed.push(handler),
nextTick: async () => { ticks.push('tick') }
}
const healthApi = {
getAssessment: async () => ({ hasCompletedAssessment: false }),
saveAssessment: async () => ({ hasCompletedAssessment: true }),
getTcmAssessment: async () => ({ hasCompleted: false }),
saveTcmAssessment: async () => tcmRecord(), ...overrides
}
const mocks = {
vue, 'vue-router': { useRouter: () => ({ replace: location => navigations.push(location) }) },
vant: { Button: {}, showToast: message => toasts.push(message),
showSuccessToast: message => toasts.push(message), showFailToast: message => toasts.push(message) }
}
const cache = new Map()
function load(relative) {
const file = path.resolve(root, /\.(ts|vue)$/.test(relative) ? relative : relative + '.ts')
if (file === path.join(root, 'services/health.ts')) return { healthApi }
if (file.endsWith('AppTabBar.vue')) return { default: {} }
if (cache.has(file)) return cache.get(file).exports
const module = { exports: {} }; cache.set(file, module)
const raw = fs.readFileSync(file, 'utf8')
const source = file.endsWith('.vue')
? compileScript(parse(raw).descriptor, { id: 'test-assessment' }).content
: raw
const output = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
vm.runInNewContext(output, {
exports: module.exports, module,
require: name => mocks[name] ?? load(path.relative(root, path.resolve(path.dirname(file), name))),
document: { getElementById: id => ({ scrollIntoView: options => scrolls.push({ id, options, tickCount: ticks.length }) }) },
Date, Error
}, { filename: file })
return module.exports
}
const page = relative => load('views/' + relative + '.vue').default.setup({}, { expose() {} })
return { load, page, healthApi, toasts, navigations, scrolls, ticks, dispose: () => disposed.forEach(handler => handler()) }
}
test('validation rejects non-finite numbers, fractional ages, stale choices, and impossible birthdays', () => {
const validation = environment().load('services/formValidation')
for (const input of [NaN, Infinity, -Infinity, '70', '', null, undefined]) {
assert.equal(validation.finiteNumberInRange(input, 20, 250), false)
}
assert.equal(validation.finiteNumberInRange(31.5, 1, 120, true), false)
assert.equal(validation.finiteNumberInRange(31, 1, 120, true), true)
assert.equal(validation.finiteNumberInRange(70.5, 20, 250), true)
assert.equal(validation.healthAnswersComplete(healthAnswers()), true)
assert.equal(validation.healthAnswersComplete({ ...healthAnswers(), sleepDuration: 'legacy-choice' }), false)
const today = new Date(2026, 9, 9)
assert.equal(validation.ageFromBirthday('2000-10-10T00:00:00', today), 25)
assert.equal(validation.ageFromBirthday('2000-10-09', today), 26)
assert.equal(validation.ageFromBirthday('2000-02-29', today), 26)
assert.equal(validation.ageFromBirthday('2001-02-29', today), undefined)
assert.equal(validation.ageFromBirthday('2027-01-01', today), undefined)
assert.equal(validation.ageFromBirthday('bad-date', today), undefined)
})
test('age calculation accepts backend timestamps without shifting the calendar birthday', () => {
const validation = environment().load('services/formValidation')
const today = new Date(2026, 9, 9)
for (const birthday of ['2006-01-01', '2006-01-01 00:00:00', '2006-01-01T00:00:00',
'2006-01-01T00:00:00.000+08:00', '2006-01-01T23:00:00Z']) {
assert.equal(validation.ageFromBirthday(birthday, today), 20, birthday)
}
assert.equal(validation.ageFromBirthday('2000-10-10 00:00:00', today), 25)
assert.equal(validation.ageFromBirthday('2000-10-09 00:00:00', today), 26)
assert.equal(validation.ageFromBirthday('2001-02-29 00:00:00', today), undefined)
assert.equal(validation.ageFromBirthday('2027-01-01 00:00:00', today), undefined)
assert.equal(validation.ageFromBirthday(null, today), undefined)
assert.equal(validation.ageFromBirthday('2006-01-01 unexpected', today), undefined)
})
test('assessment loads and resaves the backend birthday without losing age or rewriting the saved date', async () => {
const birthday = '2006-01-01 00:00:00', payloads = []
let stored = { hasCompletedAssessment: true,
basicInfo: { gender: '男', heightCm: 165, weightKg: 60, birthday }, answers: healthAnswers() }
const env = environment({ getAssessment: async () => stored,
saveAssessment: async payload => {
payloads.push(payload)
stored = { ...payload, hasCompletedAssessment: true }
return stored
} })
const page = env.page('AssessmentView'); await page.loadExisting()
const expectedAge = new Date().getFullYear() - 2006
assert.equal(page.age.value, expectedAge)
await page.handleSave()
assert.equal(payloads.length, 1)
assert.equal(payloads[0].basicInfo.birthday, birthday)
await page.loadExisting()
assert.equal(page.age.value, expectedAge)
assert.equal(page.basicInfo.birthday, birthday)
})
test('assessment cannot save while records are loading or after a failed read; retry restores saved information', async () => {
const read = deferred(); let writes = 0
const env = environment({ getAssessment: () => read.promise, saveAssessment: async () => { writes++; return { hasCompletedAssessment: true } } })
const page = env.page('AssessmentView'), pending = page.loadExisting()
await page.handleSave(); assert.equal(writes, 0)
read.reject(new Error('读取失败')); await pending
assert.equal(page.loadError.value, '读取失败')
await page.handleSave(); assert.equal(writes, 0)
env.healthApi.getAssessment = async () => ({ hasCompletedAssessment: true,
basicInfo: { gender: '男', heightCm: 175, weightKg: 68, birthday: '1990-05-21' }, answers: healthAnswers() })
await page.loadExisting()
assert.equal(page.loadError.value, ''); assert.equal(page.hasPreviousAssessment.value, true)
assert.equal(page.basicInfo.weightKg, 68); assert.equal(page.answers.sleepDuration, '7-8')
})
test('assessment save preserves a real birthday, blocks repeat submission, and navigates once', async () => {
const saved = deferred(); const payloads = []
const env = environment({ getAssessment: async () => ({ hasCompletedAssessment: true,
basicInfo: { gender: '女', heightCm: 165, weightKg: 60, birthday: '1990-05-21T00:00:00' }, answers: healthAnswers() }),
saveAssessment: payload => { payloads.push(payload); return saved.promise } })
const page = env.page('AssessmentView'); await page.loadExisting()
const save = page.handleSave(); await page.handleSave()
assert.equal(payloads.length, 1); assert.equal(page.submitting.value, true)
assert.equal(payloads[0].basicInfo.birthday, '1990-05-21T00:00:00')
saved.resolve({ hasCompletedAssessment: true }); await save
assert.deepEqual(env.navigations, ['/dashboard']); assert.equal(page.submitting.value, false)
})
test('assessment does not toast or navigate after leaving during a save', async () => {
const saved = deferred()
const env = environment({ saveAssessment: () => saved.promise })
const page = env.page('AssessmentView'); await page.loadExisting()
Object.assign(page.basicInfo, { gender: '男', heightCm: 175, weightKg: 70 }); page.age.value = 30
Object.assign(page.answers, healthAnswers())
const request = page.handleSave(); env.dispose()
saved.resolve({ hasCompletedAssessment: true }); await request
assert.equal(env.navigations.length, 0); assert.equal(env.toasts.length, 0)
})
test('a failed save does not change the original birthday when the user reverts an age edit', async () => {
const payloads = []
const env = environment({ getAssessment: async () => ({ hasCompletedAssessment: true,
basicInfo: { gender: '女', heightCm: 165, weightKg: 60, birthday: '1990-05-21' }, answers: healthAnswers() }),
saveAssessment: async payload => { payloads.push(payload); throw new Error('保存失败') } })
const page = env.page('AssessmentView'); await page.loadExisting()
const originalAge = page.age.value; page.age.value = originalAge + 1
await page.handleSave()
assert.equal(page.basicInfo.birthday, '1990-05-21')
page.age.value = originalAge; await page.handleSave()
assert.equal(payloads[1].basicInfo.birthday, '1990-05-21')
assert.equal(env.navigations.length, 0)
})
test('four diagnoses show changed answers as outdated until regeneration and scroll only after rendering', async () => {
const saved = deferred(); let writes = 0
const env = environment({ getTcmAssessment: async () => tcmRecord(), saveTcmAssessment: () => { writes++; return saved.promise } })
const page = env.page('TcmAssessmentView'); await page.loadExisting()
assert.equal(page.resultOutdated.value, false)
page.answers.face = 'pale'; assert.equal(page.resultOutdated.value, true)
page.answers.face = 'rosy'; assert.equal(page.resultOutdated.value, false)
page.notes.tongue = '备注'; assert.equal(page.resultOutdated.value, true)
const request = page.submit(); await page.submit()
assert.equal(writes, 1); assert.equal(page.saving.value, true)
saved.resolve(tcmRecord()); await request
assert.equal(page.resultOutdated.value, false); assert.equal(page.saving.value, false)
assert.equal(env.scrolls.at(-1).id, 'tcm-result'); assert.equal(env.scrolls.at(-1).tickCount, 1)
})
test('four diagnoses reject unknown stored choices and prevent saves after a failed read', async () => {
let writes = 0
const env = environment({ getTcmAssessment: async () => { throw new Error('网络异常') },
saveTcmAssessment: async () => { writes++; return tcmRecord() } })
const page = env.page('TcmAssessmentView'); await page.loadExisting(); await page.submit()
assert.equal(writes, 0); assert.equal(page.loadError.value, '网络异常')
env.healthApi.getTcmAssessment = async () => tcmRecord(); await page.loadExisting()
page.answers.face = 'legacy-choice'; await page.submit()
assert.equal(writes, 0); assert.equal(page.answeredCount.value, 11)
assert.equal(env.scrolls.at(-1).id, 'tcm-face')
})
test('both pages discard reads completed after leaving instead of treating them as active data', async () => {
for (const [file, api, completed] of [
['AssessmentView', 'getAssessment', { hasCompletedAssessment: true, basicInfo: { weightKg: 70 }, answers: healthAnswers() }],
['TcmAssessmentView', 'getTcmAssessment', tcmRecord()]
]) {
const read = deferred(), env = environment({ [api]: () => read.promise })
const page = env.page(file), request = page.loadExisting(); env.dispose()
read.resolve(completed); await request
assert.equal(page.loading.value, true)
assert.equal(env.toasts.length, 0)
if (file === 'AssessmentView') assert.equal(page.basicInfo.weightKg, undefined)
else assert.equal(page.result.value, undefined)
}
})
test('four diagnoses do not change results, toast, or scroll after leaving during generation', async () => {
const saved = deferred()
const env = environment({ getTcmAssessment: async () => tcmRecord(), saveTcmAssessment: () => saved.promise })
const page = env.page('TcmAssessmentView'); await page.loadExisting()
const previous = page.result.value; page.answers.face = 'pale'
const request = page.submit(); env.dispose()
saved.resolve({ ...tcmRecord(), constitutionName: '新结果' }); await request
assert.equal(page.result.value, previous)
assert.equal(env.toasts.length, 0); assert.equal(env.scrolls.length, 0)
})
test('health choices are native buttons and both forms lock editing while saving', () => {
const healthSource = fs.readFileSync(path.join(root, 'views/AssessmentView.vue'), 'utf8')
const tcmSource = fs.readFileSync(path.join(root, 'views/TcmAssessmentView.vue'), 'utf8')
assert.equal(/<div\s+class="option-(pill|card)"/.test(healthSource), false)
const healthChoiceCount = (healthSource.match(/class="option-(pill|card)"/g) ?? []).length
assert.ok(healthChoiceCount > 30)
assert.equal((healthSource.match(/:aria-pressed=/g) ?? []).length, healthChoiceCount)
assert.match(healthSource, /<fieldset[^>]*:disabled="submitting"[^>]*:inert="submitting \|\| undefined"/)
assert.match(tcmSource, /<fieldset[^>]*:disabled="saving"[^>]*:inert="saving \|\| undefined"/)
})
test('page request guard also ignores superseded results', () => {
const env = environment(), guard = env.load('composables/usePageRequest').usePageRequest()
const first = guard.begin(), second = guard.begin()
assert.equal(guard.isCurrent(first), false); assert.equal(guard.isCurrent(second), true)
env.dispose(); assert.equal(guard.isCurrent(second), false)
})
+659
View File
@@ -0,0 +1,659 @@
// 无真实网络/家具的回归检查;使用现有 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 }),
volume: control('on', { volume_level: .56 }), uv: control('on') })
assert.equal(params.lightBrightness, 0); assert.equal(params.lightColorTemp, 5000)
assert.equal(params.speakerVolume, 56)
assert.equal(params.dehumidifierUv, true)
syncDeviceParameters(params, { uv: control('unavailable', {}, false), volume: control('on', { volume_level: 'NaN' }) })
assert.equal(params.dehumidifierUv, 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('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({ uv: control('off') }),
setParameter: async () => ({ success: false, message: '失败' }) })
const hook = env.load('composables/useDeviceControl').useDeviceControl()
await hook.loadStatus(); hook.localParams.dehumidifierUv = true
env.healthApi.homeAssistantStatus = async () => { throw new Error('离线') }
assert.equal(await hook.applyParam('dehumidifier', 'uv', true), false)
assert.equal(hook.localParams.dehumidifierUv, 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)
})
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 primary = standardLight('light.primary'); primary.moduleKey = 'lighting'
const strip = standardLight('light.strip'); strip.moduleKey = 'sleep-light-strip'
const env = environment({ lightTransitionStatus: async () => current,
homeAssistantStatus: async () => ({ connected: true, devices: [primary, strip] }) })
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('an inventory without configured scene lights clears the previous fade and stops light requests', async () => {
const primary = standardLight('light.primary'); primary.moduleKey = 'lighting'
let devices = [primary], lightReads = 0
const env = environment({
homeAssistantStatus: async () => ({ connected: true, devices }),
lightTransitionStatus: async () => { lightReads++; return { running: true, brightnessPercent: 50, mode: 'sleep' } },
})
const hook = env.load('composables/useDeviceControl').useDeviceControl()
await hook.loadStatus()
assert.equal(lightReads, 1)
assert.equal(env.intervals.size, 1)
devices = []
await hook.loadStatus()
assert.equal(lightReads, 1)
assert.equal(hook.lightTransition.value, undefined)
assert.equal(env.intervals.size, 0)
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 does not infer retired mattress controls', () => {
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()
})
+409
View File
@@ -0,0 +1,409 @@
// 渲染真实 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 { 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('environment', '演示环境检测仪', 'environment', 'EnvironmentSensor'),
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(),
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,
...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('retired mattress detail link has no controls', async () => {
const { html } = await render('/device-control/mattress')
assert.ok(html.includes('未找到这个设备'))
for (const value of ['左侧加热档位', '按摩力度', '左肩软硬度']) 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('wake and sleep fragrance details share controls and show their own readings', async () => {
const sleepDuration = reading('number.sleep_duration', 'number', '3')
const wakeDuration = reading('number.wake_duration', 'number', '5')
const wakeButton = reading('button.wake_release', 'button', 'unknown')
const sleep = { ...devices[2], entities: [...devices[2].entities, sleepDuration] }
const wake = device('wake', '香氛机 唤醒', 'wake-fragrance', 'Fragrance',
[reading('switch.wake', 'switch', 'on'), wakeDuration, wakeButton], { primaryControlEntityId: 'switch.wake' })
const haStatus = ref({ connected: true, devices: [sleep, wake], controlParameters: {
fragrance_duration: sleepDuration, wake_fragrance_duration: wakeDuration, wake_fragrance_quick_release: wakeButton,
} })
const sleepHtml = (await render('/device-control/fragrance', { haStatus })).html
const wakeHtml = (await render('/device-control/wake', { haStatus })).html
for (const label of ['单次出香时长', '自动出香间隔', '香薰氛围灯', '人来增香', '感应夜灯', '无人时自动关灯', '立即出香']) {
assert.ok(sleepHtml.includes(label), label)
assert.ok(wakeHtml.includes(label), label)
}
assert.match(sleepHtml, />3 秒</)
assert.doesNotMatch(sleepHtml, />5 秒</)
assert.match(wakeHtml, />5 秒</)
assert.doesNotMatch(wakeHtml, />3 秒</)
assert.match(wakeHtml, /快速出香/)
assert.doesNotMatch(wakeHtml, /查看全部能力/)
})
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/environment', { haStatus: ref(snapshot) })
assert.ok(html.includes('设备回读显示离线'))
assert.ok(html.includes('温度'))
const disconnected = await render('/device-control/environment', { haStatus: ref({ ...snapshot, connected: false, message: '连接失败' }) })
assert.ok(disconnected.html.includes('暂时无法读取设备'))
assert.ok(!disconnected.html.includes('未找到这个设备'))
const loading = await render('/device-control/environment', { 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[2]]) {
const snapshot = { connected: true, devices: [{ ...item, online: false }], controlParameters: {} }
const { html } = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot) })
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)?.[0]
assert.ok(card)
for (const button of card.match(/<button\b[^>]*>/g) ?? []) assert.ok(button.includes('disabled'), button)
for (const select of card.match(/<select\b[^>]*>/g) ?? []) assert.ok(select.includes('disabled'), select)
for (const slider of card.match(/class="van-slider(?: |\")[^"\n]*"/g) ?? []) assert.ok(slider.includes('disabled'), slider)
assert.ok(!card.includes('<strong class="p-val">2700K</strong>'))
assert.ok(!card.includes('<strong class="p-val">20 分钟</strong>'))
assert.ok(!card.includes('<strong class="p-val">0 档</strong>'))
}
})
test('environment detail uses only that device metrics and hides offline or malformed readings', async () => {
const item = device('environment', '自己的环境检测仪', 'environment', 'EnvironmentSensor', [],
{ metrics: { temperature: '26.2', humidity: 'bad', co2: '900' } })
const snapshot = { connected: true, devices: [item], environment: { temperature: 99, humidity: 99 }, controlParameters: {} }
const { html } = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot) })
assert.ok(html.includes('26.2')); assert.ok(html.includes('900')); assert.ok(!html.includes('<strong>99'))
item.online = false
const offline = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot) })
assert.ok(!offline.html.includes('26.2')); assert.ok(!offline.html.includes('900'))
})
test('independent audio gateway still supports HA-offline playback while HA-only sleep mode is disabled', async () => {
const item = { ...devices[3], online: false, entities: devices[3].entities.map(value => ({ ...value, available: false })) }
const snapshot = { connected: true, devices: [item], controlParameters: {} }
const { html } = await render(deviceDetailLocation(item.deviceId), { haStatus: ref(snapshot),
musicTransition: ref({ running: true, hasVolume: true, volumePercent: 0 }) })
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)?.[0]
assert.ok(card.includes('0% 静音'))
const playback = card.match(/<button\b[^>]*>[\s\S]*?播放测试音乐[\s\S]*?<\/button>/)?.[0]
assert.ok(playback); assert.ok(!playback.match(/^<button\b[^>]*disabled/))
assert.match(card, /<button class="mini-toggle" disabled/)
})
+140
View File
@@ -0,0 +1,140 @@
// Isolated navigation/authentication checks: no server or real device requests.
import assert from 'node:assert/strict'
import { test } from 'node:test'
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src')
function environment(pathname = '/dashboard') {
const storage = new Map(), redirects = [], cache = new Map()
const handlers = {}
const http = {
interceptors: {
request: { use: callback => { handlers.request = callback } },
response: { use: (success, failure) => { handlers.success = success; handlers.failure = failure } },
},
}
const window = { location: { pathname, replace: url => redirects.push(url) } }
const mocks = {
axios: { default: { create: () => http } },
'vue-router': {
createWebHistory: () => ({}),
createRouter: options => ({
options,
beforeEach: callback => { handlers.guard = callback },
afterEach: callback => { handlers.after = callback },
}),
},
}
function load(relative) {
const file = path.resolve(root, relative)
if (cache.has(file)) return cache.get(file).exports
const module = { exports: {} }; cache.set(file, module)
const source = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
}).outputText
vm.runInNewContext(source, {
exports: module.exports, module, window,
localStorage: {
getItem: key => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, value),
removeItem: key => storage.delete(key),
},
require: name => mocks[name] ?? (name.endsWith('.vue') ? { default: {} }
: load(path.relative(root, path.resolve(path.dirname(file), `${name}.ts`)))),
}, { filename: file })
return module.exports
}
const auth = load('services/auth.ts')
load('services/http.ts')
return { auth, handlers, redirects, storage, load }
}
function request(env) {
return env.handlers.request({ headers: {} })
}
test('a current HTTP 401 clears both tokens and redirects only once', async () => {
const env = environment()
env.auth.saveTokens('session-one', 'refresh-one')
const config = request(env)
const fail = () => env.handlers.failure({ config, response: { status: 401 }, message: 'Unauthorized' })
await assert.rejects(fail(), /Unauthorized/)
await assert.rejects(fail(), /Unauthorized/)
assert.equal(env.auth.hasToken(), false)
assert.equal(env.storage.size, 0)
assert.deepEqual(env.redirects, ['/auth'])
})
test('an HTTP 200 with an unauthorized envelope also expires the current session', async () => {
const env = environment()
env.auth.saveTokens('session-one', 'refresh-one')
const config = request(env)
await assert.rejects(env.handlers.success({ config, data: { code: 401, type: 'error', result: null } }), /登录已过期/)
assert.equal(env.auth.hasToken(), false)
assert.deepEqual(env.redirects, ['/auth'])
})
test('a late HTTP/envelope 401 from the previous login cannot remove a newer session', async () => {
const env = environment()
env.auth.saveTokens('old-session', 'old-refresh')
const oldConfig = request(env)
env.auth.saveTokens('new-session', 'new-refresh')
await assert.rejects(env.handlers.failure({ config: oldConfig, response: { status: 401 }, message: 'Expired' }), /Expired/)
await assert.rejects(env.handlers.success({ config: oldConfig, data: { code: 401, message: 'Expired' } }), /Expired/)
assert.equal(env.auth.getToken(), 'new-session')
assert.equal(env.storage.size, 2)
assert.deepEqual(env.redirects, [])
})
test('an anonymous request that returns 401 cannot log out a later authenticated session', async () => {
const env = environment('/auth')
const anonymousConfig = request(env)
assert.equal(anonymousConfig.headers.Authorization, undefined)
env.auth.saveTokens('new-session', 'new-refresh')
await assert.rejects(env.handlers.failure({ config: anonymousConfig, response: { status: 401 }, message: 'Invalid password' }))
assert.equal(env.auth.getToken(), 'new-session')
assert.deepEqual(env.redirects, [])
})
test('failed credentials on the login page do not reload that page', async () => {
const env = environment('/auth')
await assert.rejects(env.handlers.failure({ config: request(env), response: { status: 401 }, message: 'Invalid password' }))
assert.deepEqual(env.redirects, [])
})
test('normal API results still unwrap and business failures are still rejected', async () => {
const env = environment()
env.auth.saveTokens('session-one', 'refresh-one')
assert.equal(request(env).headers.Authorization, 'Bearer session-one')
const result = { success: false, message: 'device unavailable' }
assert.equal(env.handlers.success({ data: { code: 200, type: 'success', result } }), result)
await assert.rejects(env.handlers.success({ data: { type: 'error', result: null, message: 'failed' } }), /failed/)
assert.equal(env.auth.hasToken(), true)
})
test('route guards protect pages while allowing login, with a fallback for unknown URLs', () => {
const env = environment(), router = env.load('router.ts').default
assert.equal(env.handlers.guard({ meta: {} }), '/auth')
assert.equal(env.handlers.guard({ meta: { guest: true } }), undefined)
env.auth.saveTokens('session-one', 'refresh-one')
assert.equal(env.handlers.guard({ meta: {} }), undefined)
assert.equal(env.handlers.guard({ meta: { guest: true } }), '/dashboard')
assert.equal(router.options.routes.at(-1).path, '/:pathMatch(.*)*')
assert.equal(router.options.routes.at(-1).redirect, '/dashboard')
const devices = router.options.routes.find(route => route.path === '/device-control')
assert.equal(devices.children.find(route => route.name === 'device-detail').path, ':deviceId')
})
test('new pages open at the top and browser back preserves its saved scroll position', () => {
const env = environment(), router = env.load('router.ts').default
const saved = { left: 0, top: 512 }
assert.equal(router.options.scrollBehavior({}, {}, saved), saved)
const fresh = router.options.scrollBehavior({}, {}, null)
assert.equal(fresh.left, 0)
assert.equal(fresh.top, 0)
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

+20 -13
View File
@@ -27,10 +27,11 @@ function navigate(target: string) {
</script>
<template>
<nav class="app-tab-bar">
<div
<nav class="app-tab-bar" aria-label="主导航">
<button type="button"
class="tab-item"
:class="{ active: currentTab === 'home' }"
:aria-current="currentTab === 'home' ? 'page' : undefined"
@click="navigate('home')"
>
<div class="tab-icon">
@@ -40,11 +41,12 @@ function navigate(target: string) {
</svg>
</div>
<span class="tab-label">首页</span>
</div>
</button>
<div
<button type="button"
class="tab-item"
:class="{ active: currentTab === 'tcm' }"
:aria-current="currentTab === 'tcm' ? 'page' : undefined"
@click="navigate('tcm')"
>
<div class="tab-icon">
@@ -56,11 +58,12 @@ function navigate(target: string) {
</svg>
</div>
<span class="tab-label">中医四诊</span>
</div>
</button>
<div
<button type="button"
class="tab-item"
:class="{ active: currentTab === 'assessment' }"
:aria-current="currentTab === 'assessment' ? 'page' : undefined"
@click="navigate('assessment')"
>
<div class="tab-icon">
@@ -73,11 +76,12 @@ function navigate(target: string) {
</svg>
</div>
<span class="tab-label">健康评估</span>
</div>
</button>
<div
<button type="button"
class="tab-item"
:class="{ active: currentTab === 'profile' }"
:aria-current="currentTab === 'profile' ? 'page' : undefined"
@click="navigate('profile')"
>
<div class="tab-icon">
@@ -87,7 +91,7 @@ function navigate(target: string) {
</svg>
</div>
<span class="tab-label">我的</span>
</div>
</button>
</nav>
</template>
@@ -105,24 +109,27 @@ function navigate(target: string) {
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(18, 63, 55, 0.08);
border-top: 1px solid #eef2f6;
display: flex;
justify-content: space-around;
align-items: flex-start;
padding-top: 6px;
padding-bottom: env(safe-area-inset-bottom, 0px);
z-index: 999;
box-shadow: 0 -4px 16px rgba(18, 63, 55, 0.04);
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.04);
}
.tab-item {
border: 0;
background: transparent;
font: inherit;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
height: 44px;
color: #7d8e87;
color: #94a3b8;
cursor: pointer;
transition: color 0.2s ease;
user-select: none;
@@ -145,7 +152,7 @@ function navigate(target: string) {
}
.tab-item.active {
color: #0c5045;
color: #0284c7;
}
.tab-item.active .tab-label {
+182
View File
@@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { usePageRequest } from '../composables/usePageRequest'
import { formatIb43Time, ib43Api, type Ib43Automation, type Ib43AutomationRecord, type Ib43AutomationRule } from '../services/ib43'
const props = defineProps<{ bindingId: string }>()
const status = ref<Ib43Automation>()
const loading = ref(false)
const updatingKey = ref<string | null>(null)
const updating = computed(() => updatingKey.value !== null)
const loadError = ref('')
const actionError = ref('')
const requests = usePageRequest()
let timer: ReturnType<typeof setInterval>
const rules = computed<Ib43AutomationRule[]>(() => {
if (!status.value) return []
if (status.value.rules) return status.value.rules
const { available, enabled, actionLabel, targetLabel } = status.value
return available ? [{ actionKey: '', enabled, actionLabel, targetLabel, previewOnly: false }] : []
})
const resultLabels: Record<string, string> = {
pending: '尚未记录结果',
'readback-confirmed': '已回读确认',
'already-closed': '窗帘已关闭,跳过',
'already-closing': '窗帘正在关闭,跳过',
'already-running': '动作已在运行,跳过',
'state-not-open': '当前状态不适合关帘,跳过',
'sent-readback-unconfirmed': '已发出指令,回读未确认',
'target-not-configured': '目标设备未配置',
'cancelled-unconfirmed': '已取消或超时,结果未确认',
'failed-unconfirmed': '动作未确认',
expired: '事件已过期,跳过',
'preview-would-start': '预演通过,计划开启除湿机',
'preview-humidity-normal': '预演跳过,湿度未超过阈值',
'preview-humidity-unavailable': '预演跳过,湿度读数缺失或不可用',
'preview-humidity-stale': '预演跳过,湿度读数过期或时间无效',
'preview-humidity-invalid': '预演跳过,湿度读数或单位无效',
'humidity-normal': '湿度未超过阈值,跳过',
'humidity-unavailable': '湿度读数缺失或不可用,跳过',
'humidity-stale': '湿度读数过期或时间无效,跳过',
'humidity-invalid': '湿度读数或单位无效,跳过',
'dehumidifier-unavailable': '除湿机不可用,跳过',
'dehumidifier-already-on': '除湿机已开启,跳过',
'dehumidifier-state-not-off': '无法确认除湿机已关机,跳过',
'preview-climate-cool': '预演通过,计划制冷',
'preview-climate-heat': '预演通过,计划制热',
'preview-temperature-within-range': '预演跳过,室温在设定区间内',
'preview-temperature-unavailable': '预演跳过,室温读数缺失或不可用',
'preview-temperature-stale': '预演跳过,室温读数过期或时间无效',
'preview-temperature-invalid': '预演跳过,室温读数或单位无效',
'temperature-not-low': '室温未低于 22℃,跳过',
'temperature-unavailable': '室温读数缺失或不可用,跳过',
'temperature-stale': '室温读数过期或时间无效,跳过',
'temperature-invalid': '室温读数或单位无效,跳过',
'climate-unavailable': '空调不可用,跳过',
'climate-not-off': '空调当前未关机,跳过',
'climate-heat-unsupported': '空调不支持制热或 22℃ 目标温度,跳过',
'climate-heat-mode-unconfirmed': '已发出制热指令,未确认制热模式',
'climate-heat-state-changed': '制热状态已改变或不可用,跳过设温',
'climate-heat-target-unconfirmed': '已确认制热,未确认维持制热并设为 22℃',
'fragrance-release-sent': '已发送一次快速出香指令',
'fragrance-release-unavailable': '快速出香按钮不可用,跳过',
'speaker-music-not-configured': '舒睡曲目或音箱网关未配置,跳过',
'speaker-state-unavailable': '音箱状态不可用,跳过',
'speaker-not-idle': '音箱当前非空闲,跳过',
'speaker-playback-sent': '已发送舒睡音乐播放指令',
}
function resultLabel(row: Ib43AutomationRecord) {
if (row.actionKey === 'curtain-close' && row.result === 'readback-confirmed') return '已确认关闭'
if (row.actionKey === 'curtain-close' && row.result === 'sent-readback-unconfirmed') return '已发出关帘指令,未确认关闭'
if (row.actionKey === 'dehumidifier-humidity-start' && row.result === 'readback-confirmed') return '已确认开启'
if (row.actionKey === 'dehumidifier-humidity-start' && row.result === 'sent-readback-unconfirmed') return '已发出开机指令,未确认开启'
if (row.actionKey === 'climate-heat-start' && row.result === 'readback-confirmed') return '已确认制热至 22℃'
return resultLabels[row.result] || '结果未知'
}
async function load() {
if (updating.value || loading.value) return
const request = requests.begin()
loading.value = true
try {
const result = await ib43Api.automation(props.bindingId)
if (!requests.isCurrent(request)) return
status.value = result
loadError.value = ''
} catch (error) {
if (requests.isCurrent(request)) loadError.value = error instanceof Error ? error.message : '暂时无法读取联动状态'
} finally {
if (requests.isCurrent(request)) loading.value = false
}
}
async function toggle(rule: Ib43AutomationRule) {
const enabled = !rule.enabled
if (!status.value || updating.value || (enabled && loadError.value)) return
const request = requests.begin()
updatingKey.value = rule.actionKey
loading.value = false
actionError.value = ''
try {
if (enabled) await ib43Api.startAutomation(props.bindingId, rule.actionKey || undefined)
else await ib43Api.stopAutomation(props.bindingId, rule.actionKey || undefined)
if (!requests.isCurrent(request)) return
const previous = status.value
status.value = previous.rules
? { ...previous, rules: previous.rules.map(item => item.actionKey === rule.actionKey ? { ...item, enabled } : item) }
: { ...previous, enabled }
} catch (error) {
if (requests.isCurrent(request)) actionError.value = error instanceof Error ? error.message : '更新联动状态失败'
} finally {
if (requests.isCurrent(request)) {
updatingKey.value = null
void load()
}
}
}
onMounted(() => {
void load()
timer = setInterval(() => {
if (!document.hidden && (rules.value.some(rule => rule.enabled) || status.value?.records.some(row =>
row.result === 'pending' && Date.now() - Number(row.triggeredAtMs) < 90000))) void load()
}, 5000)
})
onUnmounted(() => clearInterval(timer))
</script>
<template>
<section class="section-card automation-card" aria-labelledby="ib43-automation-title">
<div class="automation-heading">
<h2 id="ib43-automation-title">家具联动</h2>
<button class="automation-button" type="button" :disabled="loading || updating" @click="load">刷新记录</button>
</div>
<p v-if="!status && loading" class="automation-muted" role="status">正在读取联动状态…</p>
<p v-if="loadError" class="automation-error" role="alert">{{ loadError }}</p>
<template v-if="status">
<p v-if="!rules.length" class="automation-muted">该床位当前未启用可管理的家具规则。</p>
<div v-for="rule in rules" :key="rule.actionKey" class="automation-rule">
<div class="automation-heading">
<strong>上床后{{ rule.actionLabel }}{{ rule.previewOnly ? '(预演)' : '' }}</strong>
<span class="automation-state" :class="{ enabled: rule.enabled }">{{ rule.enabled ? '已开启' : '已停止' }}</span>
</div>
<p v-if="rule.previewOnly">预演只读取数据并记录结果。{{ rule.conditionLabel }}</p>
<p v-else-if="rule.conditionLabel">{{ rule.conditionLabel }}</p>
<p v-else>模拟床位从离床变为在床时,对{{ rule.targetLabel }}执行此动作。</p>
<button class="automation-button" :class="{ primary: !rule.enabled }" type="button"
:disabled="updating || (!rule.enabled && !!loadError)" @click="toggle(rule)">
{{ updatingKey === rule.actionKey ? '正在更新…' : rule.previewOnly
? (rule.enabled ? '停止预演' : '开启预演') : (rule.enabled ? '停止联动' : '开启联动') }}
</button>
</div>
<p v-if="actionError" class="automation-error" role="alert">{{ actionError }}</p>
<h3>最近执行记录</h3>
<p v-if="status.records.length === 0" class="automation-muted">暂无执行记录。</p>
<ol v-else class="automation-records">
<li v-for="row in status.records" :key="row.id">
<span>{{ row.targetLabel }} · {{ resultLabel(row) }}</span>
<time>{{ formatIb43Time(row.triggeredAtMs) }}</time>
</li>
</ol>
</template>
</section>
</template>
<style scoped>
.automation-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
h2 { margin: 0; font-size: 17px; }
h3 { margin: 16px 0 8px; font-size: 14px; }
.automation-button { min-height: 44px; border: 1px solid #bae6fd; border-radius: 12px; background: #f0f9ff; color: #0284c7; padding: 8px 12px; font: inherit; font-size: 13px; }
.automation-button.primary { color: #fff; background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%); border-color: transparent; box-shadow: 0 4px 14px rgba(2, 132, 199, 0.25); }
.automation-button:disabled { opacity: .55; }
.automation-rule { margin-top: 12px; padding: 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 13px; }
.automation-rule p, .automation-muted, .automation-records time { color: var(--muted-text); font-size: 13px; }
.automation-state { flex-shrink: 0; border-radius: 8px; padding: 4px 8px; background: #f1f5f9; color: #64748b; font-size: 12px; }
.automation-state.enabled { background: #f0f9ff; color: #0284c7; }
.automation-error { color: #8b432f; font-size: 13px; overflow-wrap: anywhere; }
.automation-records { list-style: none; padding: 0; margin: 0; }
.automation-records li { display: grid; gap: 5px; border-top: 1px solid #e2e8f0; padding: 10px 0; font-size: 14px; overflow-wrap: anywhere; }
</style>
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed, toRefs } from 'vue'
import { Button as VanButton, Slider as VanSlider } from 'vant'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
import { useDeviceCardState } from '../../devices/useDeviceCardState'
import { primaryDeviceEntity } from '../../devices/deviceSummary'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { device: speakerDevice } = toRefs(props)
const { localParams, operationBusy, switchingDeviceId, applyParam, controlSpeaker, musicTransition, loading } = useDeviceControlContext()
const state = useDeviceCardState(() => props.device)
// MiAirX 独立于 HA 实体在线状态,但仍需已确认属于该音箱的主控实体。
const gatewayAvailable = computed(() => primaryDeviceEntity(props.device)?.domain === 'media_player')
</script>
<template>
<section class="device-card" v-if="speakerDevice">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">睡眠音频</span>
<h3>{{ speakerDevice.name }}</h3>
<span class="status-dot" :class="{ online: speakerDevice.online }">
{{ speakerDevice.online ? 'HA 实体在线' : 'HA 实体离线' }}
</span>
</div>
</div>
<div class="speaker-actions">
<VanButton
size="small"
round
type="primary"
color="#0c5045"
:loading="switchingDeviceId === speakerDevice.deviceId"
:disabled="operationBusy || !gatewayAvailable"
@click="controlSpeaker(speakerDevice, true)"
>播放测试音乐</VanButton>
<VanButton
size="small"
round
:disabled="operationBusy || !gatewayAvailable"
@click="controlSpeaker(speakerDevice, false)"
>停止播放</VanButton>
</div>
<small class="p-tip">手动按钮播放测试音频;一键开启按体质选曲循环播放,音量自动渐弱至停止</small>
<div class="params-body">
<!-- 音量滑块 -->
<div class="param-row">
<div class="param-header">
<span class="p-title">音箱音量(MiAirX 已下发)</span>
<strong class="p-val">{{ musicTransition?.hasVolume ? `${localParams.speakerVolume}%` : state.display('volume', localParams.speakerVolume, '%') }}</strong>
</div>
<VanSlider
v-model="localParams.speakerVolume"
:min="0"
:max="100"
:disabled="operationBusy || !gatewayAvailable"
active-color="#0c5045"
@change="applyParam(speakerDevice.deviceId, 'volume', localParams.speakerVolume)"
/>
<div class="slider-scale">
<span>0% 静音</span>
<span>50% 适中聆听</span>
<span>100% 满格音量</span>
</div>
<small class="p-tip">{{ musicTransition?.running ? '自动渐弱中,每 5 秒更新一次显示' : '手动调整音量会停止本次自动渐弱' }}</small>
</div>
<div class="param-row inline-row">
<div>
<div class="p-title">音箱睡眠模式</div>
<small class="p-tip">睡眠模式由 Home Assistant 单独控制</small>
</div>
<button
class="mini-toggle"
:class="{ active: state.readable('speaker_sleep_mode') && localParams.speakerSleepMode }"
:disabled="operationBusy || !state.available('speaker_sleep_mode')"
@click="localParams.speakerSleepMode = !localParams.speakerSleepMode; applyParam(speakerDevice.deviceId, 'speaker_sleep_mode', localParams.speakerSleepMode)"
>
{{ state.display('speaker_sleep_mode', localParams.speakerSleepMode ? '已开启' : '已关闭') }}
</button>
</div>
</div>
</section>
</template>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Button as VanButton, Slider as VanSlider } from 'vant'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
import { useDeviceCardState } from '../../devices/useDeviceCardState'
import { climateCapabilities, climateModeLabels } from '../../devices/climate/capabilities'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { localParams, operationBusy, switchingDeviceId, isDeviceTurnedOn, toggleDevice, applyParam } = useDeviceControlContext()
const state = useDeviceCardState(() => props.device)
const capability = computed(() => climateCapabilities(props.device))
const temperatureValue = computed({
get: () => localParams.climateTemperature ?? capability.value.targetTemperature ?? 0,
set: (value: number) => { localParams.climateTemperature = value },
})
const temperatureReadable = computed(() => capability.value.available && state.readable('climate_temperature') && capability.value.targetTemperature !== undefined)
function setMode(mode: string) {
if (operationBusy.value || !capability.value.available || !state.available('climate_mode') || !capability.value.modes.includes(mode)) return
void applyParam(props.device.deviceId, 'climate_mode', mode)
}
function setTemperature() {
const range = capability.value.range, value = temperatureValue.value
if (operationBusy.value || !capability.value.canSetTemperature || !state.available('climate_temperature') || !range || !Number.isFinite(value) || value < range.min || value > range.max) return
const steps = (value - range.min) / range.step
if (Math.abs(steps - Math.round(steps)) > 0.000001) return
void applyParam(props.device.deviceId, 'climate_temperature', value)
}
function setFan(mode: string) {
if (operationBusy.value || !capability.value.canSetFan || !state.available('climate_fan_mode') || !capability.value.fans.includes(mode)) return
void applyParam(props.device.deviceId, 'climate_fan_mode', mode)
}
</script>
<template>
<section class="device-card">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">室温调节</span>
<h3>{{ device.name }}</h3>
<span class="status-dot" :class="{ online: capability.available }">{{ capability.available ? '空调可用' : '空调当前不可用' }}</span>
</div>
<VanButton size="small" round :type="isDeviceTurnedOn(device) ? 'primary' : 'default'"
:loading="switchingDeviceId === device.deviceId" :disabled="operationBusy || !capability.canToggle"
@click="toggleDevice(device)">
{{ capability.available ? isDeviceTurnedOn(device) ? '已开启' : '已关闭' : '暂不可用' }}
</VanButton>
</div>
<div class="params-body">
<div class="sensor-grid climate-readings">
<div class="sensor-box"><span class="s-label">当前室温</span><strong>{{ capability.available && capability.currentTemperature !== undefined ? `${capability.currentTemperature}${capability.temperatureUnit}` : '--' }}</strong></div>
<div class="sensor-box"><span class="s-label">目标温度</span><strong>{{ temperatureReadable ? `${capability.targetTemperature}${capability.temperatureUnit}` : '--' }}</strong></div>
</div>
<div class="param-row">
<div class="param-header"><span class="p-title">空调运行模式</span><strong class="p-val">{{ capability.available && state.readable('climate_mode') ? climateModeLabels[capability.entity!.state] : '--' }}</strong></div>
<div v-if="capability.modes.length" class="btn-group device-mode-buttons">
<button v-for="mode in capability.modes" :key="mode" type="button"
:class="{ active: capability.available && state.readable('climate_mode') && capability.entity?.state === mode }"
:aria-pressed="capability.available && state.readable('climate_mode') && capability.entity?.state === mode"
:disabled="operationBusy || !capability.available || !state.available('climate_mode')" @click="setMode(mode)">{{ climateModeLabels[mode] }}</button>
</div>
<p v-else class="scale-note">运行模式暂未回读,请刷新后再试。</p>
</div>
<div v-if="capability.supports(1)" class="param-row">
<div class="param-header"><span class="p-title">空调目标温度</span><strong class="p-val">{{ temperatureReadable ? `${capability.targetTemperature}${capability.temperatureUnit}` : '--' }}</strong></div>
<template v-if="capability.range && temperatureReadable">
<VanSlider v-model="temperatureValue" :min="capability.range.min" :max="capability.range.max" :step="capability.range.step"
:disabled="operationBusy || !capability.canSetTemperature || !state.available('climate_temperature')" active-color="#0c5045" @change="setTemperature" />
<div class="slider-scale"><span>{{ capability.range.min }}{{ capability.temperatureUnit }}</span><span>每档 {{ capability.range.step }}{{ capability.temperatureUnit }}</span><span>{{ capability.range.max }}{{ capability.temperatureUnit }}</span></div>
<p v-if="capability.temperatureUnit === '℉'" class="scale-note">当前回读为华氏温度,此版仅支持摄氏温度调节。</p>
</template>
<p v-else class="scale-note">目标温度、温度单位或调节范围暂未回读,不显示默认温度。</p>
</div>
<div v-if="capability.supports(8) && capability.fans.length" class="param-row">
<div class="param-header"><span class="p-title">空调风速</span><strong class="p-val">{{ capability.available && state.readable('climate_fan_mode') ? capability.fanMode || '--' : '--' }}</strong></div>
<div class="btn-group device-mode-buttons">
<button v-for="fan in capability.fans" :key="fan" type="button"
:class="{ active: capability.available && state.readable('climate_fan_mode') && capability.fanMode === fan }"
:aria-pressed="capability.available && state.readable('climate_fan_mode') && capability.fanMode === fan"
:disabled="operationBusy || !capability.canSetFan || !state.available('climate_fan_mode')" @click="setFan(fan)">{{ fan }}</button>
</div>
</div>
<p class="scale-note">仅显示设备实际支持的模式与范围;空调一键场景按室内温度建议执行。</p>
</div>
</section>
</template>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Slider as VanSlider } from 'vant'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
import { useDeviceCardState } from '../../devices/useDeviceCardState'
import { curtainCapabilities, curtainStateLabels, type CurtainAction } from '../../devices/curtain/capabilities'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { localParams, operationBusy, applyParam } = useDeviceControlContext()
const state = useDeviceCardState(() => props.device)
const capability = computed(() => curtainCapabilities(props.device))
const actions: { value: CurtainAction; label: string }[] = [{ value: 'open', label: '打开' }, { value: 'stop', label: '停止' }, { value: 'close', label: '关闭' }]
const positionReadable = computed(() => capability.value.available && state.readable('curtain_position') && capability.value.position !== undefined)
const positionValue = computed({ get: () => localParams.curtainPosition ?? capability.value.position ?? 0,
set: (value: number) => { localParams.curtainPosition = value } })
function setAction(action: CurtainAction) {
if (operationBusy.value || !capability.value.canAction(action) || !state.available('curtain_action')) return
void applyParam(props.device.deviceId, 'curtain_action', action)
}
function setPosition() {
const value = positionValue.value
if (operationBusy.value || !capability.value.canSetPosition || !state.available('curtain_position') || !Number.isInteger(value) || value < 0 || value > 100) return
void applyParam(props.device.deviceId, 'curtain_position', value)
}
</script>
<template>
<section class="device-card">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">窗帘控制</span><h3>{{ device.name }}</h3>
<span class="status-dot" :class="{ online: capability.available }">{{ capability.available ? curtainStateLabels[capability.entity!.state] : '窗帘当前不可用' }}</span>
</div>
</div>
<div class="params-body">
<div class="param-row">
<span class="p-title">窗帘开合</span>
<div class="btn-group device-mode-buttons curtain-actions">
<button v-for="action in actions" :key="action.value" type="button"
:disabled="operationBusy || !capability.canAction(action.value) || !state.available('curtain_action')"
@click="setAction(action.value)">{{ action.label }}</button>
</div>
</div>
<div v-if="capability.supports(4)" class="param-row">
<div class="param-header"><span class="p-title">窗帘开度</span><strong class="p-val">{{ positionReadable ? `${capability.position}%` : '--' }}</strong></div>
<template v-if="positionReadable">
<VanSlider v-model="positionValue" :min="0" :max="100" :step="1"
:disabled="operationBusy || !capability.canSetPosition || !state.available('curtain_position')" active-color="#0c5045" @change="setPosition" />
<div class="slider-scale"><span>0% 完全关闭</span><span>100% 完全打开</span></div>
</template>
<p v-else class="scale-note">开度暂未回读,不显示默认位置。设备恢复可用后可使用打开、停止、关闭。</p>
</div>
<p v-if="!capability.available" class="scale-note">Home Assistant 未能回读窗帘主控状态。请先确认米家中可操作,再检查集成连接;这里不会把未知状态当作已关闭。</p>
<p class="scale-note">此版先提供单独控制,暂不随一键场景自动开合。不包含反向、校准或清除行程操作。</p>
</div>
</section>
</template>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { toRefs } from 'vue'
import { Button as VanButton, Slider as VanSlider } from 'vant'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
import { useDeviceCardState } from '../../devices/useDeviceCardState'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { device: dehumidifierDevice } = toRefs(props)
const { localParams, operationBusy, switchingDeviceId, isDeviceTurnedOn, toggleDevice, applyParam, loading } = useDeviceControlContext()
const state = useDeviceCardState(() => props.device)
</script>
<template>
<section class="device-card" v-if="dehumidifierDevice">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">恒湿净化</span>
<h3>{{ dehumidifierDevice.name }}</h3>
<span class="status-dot" :class="{ online: dehumidifierDevice.online }">
{{ dehumidifierDevice.online ? '设备在线' : '设备离线' }}
</span>
</div>
<VanButton
size="small"
round
:type="isDeviceTurnedOn(dehumidifierDevice) ? 'primary' : 'default'"
:color="isDeviceTurnedOn(dehumidifierDevice) ? '#0c5045' : ''"
:loading="switchingDeviceId === dehumidifierDevice.deviceId"
:disabled="operationBusy || !state.powerAvailable()"
@click="toggleDevice(dehumidifierDevice)"
>
{{ state.powerLabel('已开启', '已关闭') }}
</VanButton>
</div>
<div class="params-body">
<!-- 目标湿度滑块 -->
<div class="param-row">
<div class="param-header">
<span class="p-title">恒湿目标湿度</span>
<strong class="p-val">{{ state.display('humidity', localParams.dehumidifierHumidity, '%') }}</strong>
</div>
<VanSlider
v-model="localParams.dehumidifierHumidity"
:min="40"
:max="70"
:step="1"
:disabled="operationBusy || !state.available('humidity')"
active-color="#0c5045"
@change="applyParam(dehumidifierDevice.deviceId, 'humidity', localParams.dehumidifierHumidity)"
/>
<div class="slider-scale">
<span>40% 干爽</span>
<span>55% 舒适</span>
<span>70% 润泽</span>
</div>
</div>
<!-- 运行模式切换 -->
<div class="param-row">
<span class="p-title">除湿运行模式</span>
<div class="btn-group">
<button
v-for="mode in ['睡眠', '智能']"
:key="mode"
:class="{ active: state.readable('dehumidifier_mode') && localParams.dehumidifierMode === mode }"
:disabled="operationBusy || !state.available('dehumidifier_mode')"
@click="localParams.dehumidifierMode = mode; applyParam(dehumidifierDevice.deviceId, 'dehumidifier_mode', mode)"
>{{ mode }}</button>
</div>
</div>
<!-- 风速切换 -->
<div class="param-row">
<span class="p-title">除湿机风速档位</span>
<div class="btn-group">
<button
v-for="fan in ['1挡', '2挡', '3挡']"
:key="fan"
:class="{ active: state.readable('fan_level') && localParams.dehumidifierFan === fan }"
:disabled="operationBusy || !state.available('fan_level')"
@click="localParams.dehumidifierFan = fan; applyParam(dehumidifierDevice.deviceId, 'fan_level', fan)"
>
{{ fan }}
</button>
</div>
</div>
<!-- UV 杀菌开关 -->
<div class="param-row inline-row">
<div>
<div class="p-title">UV 深度紫外消杀</div>
<small class="p-tip">夜间杀菌净化,除湿同时清新空气</small>
</div>
<button
class="mini-toggle"
:class="{ active: state.readable('uv') && localParams.dehumidifierUv }"
:disabled="operationBusy || !state.available('uv')"
@click="localParams.dehumidifierUv = !localParams.dehumidifierUv; applyParam(dehumidifierDevice.deviceId, 'uv', localParams.dehumidifierUv)"
>
{{ state.display('uv', localParams.dehumidifierUv ? '已开启' : '已关闭') }}
</button>
</div>
</div>
</section>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { DeviceIconKind } from '../../devices/deviceSummary'
defineProps<{ kind: DeviceIconKind }>()
</script>
<template>
<svg class="device-glyph" viewBox="0 0 80 80" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<g v-if="kind === 'dehumidifier'">
<rect x="22" y="9" width="36" height="60" rx="9" fill="#edf3f0" />
<path d="M28 22h24M28 28h24M28 34h24" opacity=".45" />
<path d="M40 43c-3 4-6 7-6 10a6 6 0 0 0 12 0c0-3-3-6-6-10Z" fill="#bfd7cd" />
<path d="M29 70v3m22-3v3" />
</g>
<g v-else-if="kind === 'lighting'">
<circle cx="40" cy="35" r="20" fill="#f9edcf" stroke="#a98446" />
<circle cx="40" cy="35" r="10" stroke="#a98446" /><path d="M33 56h14M35 61h10M37 66h6" stroke="#a98446" />
<path d="M40 5v5M12 35h5M63 35h5M20 14l4 4m32-4-4 4" stroke="#d1b476" />
</g>
<g v-else-if="kind === 'audio'">
<rect x="22" y="11" width="36" height="58" rx="12" fill="#263e3a" />
<path d="M27 20h26" stroke="#7aafc2" stroke-width="3" />
<circle cx="40" cy="38" r="8" stroke="#819b92" /><circle cx="40" cy="57" r="5" stroke="#819b92" />
</g>
<g v-else-if="kind === 'fragrance'">
<path d="M29 25h22l6 39c0 7-34 7-34 0l6-39Z" fill="#f6f3eb" /><ellipse cx="40" cy="25" rx="11" ry="4" fill="#e3d9c3" />
<path d="M37 18c-6-5 6-8 0-13m8 13c-6-5 6-8 0-13" stroke="#91b5a7" /><path d="M29 59h22" stroke="#bdcfc6" />
</g>
<g v-else-if="kind === 'mattress'">
<path d="m11 44 27-23 32 20-28 25-31-22Z" fill="#d5e2dc" /><path d="M11 44v9l31 21 28-25v-8M42 66v8" />
<path d="m23 35 31 20m-22-27 31 20" stroke="#a3bdb0" />
</g>
<g v-else-if="kind === 'environment' || kind === 'sleep-monitor'">
<rect x="12" y="20" width="56" height="41" rx="9" fill="#e4ede8" /><rect x="18" y="26" width="44" height="28" rx="4" fill="#173e35" />
<path d="m22 43 7-3 5 7 7-13 6 6h10" stroke="#a8d4bf" /><path d="M27 66h26" />
</g>
<g v-else-if="kind === 'scale'">
<rect x="14" y="14" width="52" height="52" rx="13" fill="#edf2ee" /><rect x="30" y="23" width="20" height="10" rx="3" fill="#b3cbbf" />
<circle cx="25" cy="46" r="5" stroke="#b3cbbf" /><circle cx="55" cy="46" r="5" stroke="#b3cbbf" />
</g>
<g v-else-if="kind === 'climate'">
<rect x="8" y="20" width="64" height="30" rx="7" fill="#edf2ee" /><path d="M14 40h52m-52 5h52M27 55v9m13-9v14m13-14v9" /><circle cx="60" cy="28" r="2" fill="#87b29e" stroke="none" />
</g>
<g v-else-if="kind === 'curtain'">
<path d="M10 15h60M15 16v50l20-6V16m30 0v50l-20-6V16" fill="#d9e6df" /><path d="M23 18v44m9-44v41m17-41v41m8-41v44" stroke="#a5c0b1" />
</g>
<g v-else-if="kind === 'outlet'">
<rect x="17" y="12" width="46" height="56" rx="12" fill="#e6eee9" />
<circle cx="40" cy="36" r="14" /><path d="M33 31v7m14-7v7m-7 3v5" />
<circle cx="40" cy="59" r="2" fill="#87b29e" stroke="none" />
</g>
<g v-else>
<rect x="17" y="15" width="46" height="50" rx="11" fill="#e6eee9" /><circle cx="40" cy="40" r="11" /><path d="M40 34v12m-6-6h12" />
</g>
</svg>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { useDeviceControlContext } from '../../devices/controlContext'
const { actionFeedback, operationBusy, currentOperationText, lastUpdatedAt } = useDeviceControlContext()
</script>
<template>
<div class="operation-status" :class="actionFeedback?.type" role="status" aria-live="polite">
<span class="operation-message">
<i v-if="operationBusy" class="operation-spinner" aria-hidden="true"></i>
<i v-else-if="actionFeedback?.type === 'success'" class="operation-result" aria-hidden="true">✓</i>
<i v-else-if="actionFeedback?.type === 'fail'" class="operation-result" aria-hidden="true">!</i>
{{ currentOperationText || actionFeedback?.message || '设备状态会自动更新' }}
</span>
<small v-if="lastUpdatedAt">{{ lastUpdatedAt }}</small>
</div>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import type { DeviceSummary } from '../../devices/deviceSummary'
import DeviceGlyph from './DeviceGlyph.vue'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice; summary: DeviceSummary; busy: boolean; switching: boolean }>()
defineEmits<{ open: []; power: [] }>()
const powerLabel = computed(() => props.switching ? '操作中' : props.summary.powerOn ? props.summary.audioControl ? '停止播放' : '关闭设备'
: props.summary.audioControl ? '开始播放' : '开启设备')
</script>
<template>
<article class="device-overview-tile" :class="{ offline: !device.online, powered: summary.powerOn === true }">
<button class="device-tile-open" type="button" :aria-label="`查看${device.name}详情`" @click="$emit('open')">
<span class="device-tile-category">{{ summary.categoryLabel }}</span>
<DeviceGlyph :kind="summary.icon" />
<strong class="device-tile-name">{{ device.name }}</strong>
<span class="device-tile-status"><i :class="{ online: device.online }"></i>{{ summary.statusText }}</span>
<span class="device-tile-metric">{{ summary.metricText }}</span>
<span class="device-tile-footer">{{ device.areaName || '设备详情' }}<span aria-hidden="true">›</span></span>
</button>
<button v-if="summary.canToggle" class="device-tile-power" type="button" :class="{ on: summary.powerOn }"
:disabled="busy || switching" :aria-label="`${device.name}:${powerLabel}`" :title="powerLabel"
:aria-pressed="summary.powerOn === true" @click.stop="$emit('power')">
<span v-if="switching" class="operation-spinner" aria-hidden="true"></span>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M12 3v9M6.5 5.7a8 8 0 1 0 11 0" />
</svg>
</button>
</article>
</template>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { toRefs } from 'vue'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
import { numberValue } from '../../devices/parameterState'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { device: sensorDevice } = toRefs(props)
const { operationBusy, loadStatus, loading } = useDeviceControlContext()
const metric = (key: string) => props.device.online ? numberValue(props.device.metrics?.[key]) ?? '--' : '--'
</script>
<template>
<section class="device-card" v-if="sensorDevice">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">环境质量监控</span>
<h3>{{ sensorDevice.name }}</h3>
<span class="status-dot" :class="{ online: sensorDevice.online }">
{{ sensorDevice.online ? '实时采集正常' : '设备离线' }}
</span>
</div>
<button class="text-refresh" :disabled="operationBusy" @click="loadStatus(true)">
{{ loading ? '刷新中…' : '手动刷新指标' }}
</button>
</div>
<div class="sensor-grid">
<div class="sensor-box">
<span class="s-label">温度</span>
<strong>{{ metric('temperature') }} <small>°C</small></strong>
</div>
<div class="sensor-box">
<span class="s-label">湿度</span>
<strong>{{ metric('humidity') }} <small>%</small></strong>
</div>
<div class="sensor-box">
<span class="s-label">PM2.5</span>
<strong>{{ metric('pm25') }} <small>μg/m³</small></strong>
</div>
<div class="sensor-box">
<span class="s-label">CO2 浓度</span>
<strong>{{ metric('co2') }} <small>ppm</small></strong>
</div>
<div class="sensor-box">
<span class="s-label">甲醛 HCHO</span>
<strong>{{ metric('hcho') }} <small>mg/m³</small></strong>
</div>
<div class="sensor-box">
<span class="s-label">TVOC</span>
<strong>{{ metric('tvoc') }} <small>mg/m³</small></strong>
</div>
</div>
</section>
</template>
+167
View File
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { reactive, toRefs, watchEffect } 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 { defaults, labels, sync } from '../../devices/fragrance/parameters'
import type { ParameterSnapshot } from '../../devices/parameterState'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { device: fragranceDevice } = toRefs(props)
const { haStatus, operationBusy, switchingDeviceId, isDeviceTurnedOn, toggleDevice,
applyParam: setParameter, settingParam } = useDeviceControlContext()
const localParams = reactive({ ...defaults })
const parameterKey = (key: string) => props.device.moduleKey === 'wake-fragrance' ? `wake_${key}` : key
const card = useDeviceCardState(() => props.device)
const state = {
...card,
available: (key: string) => card.available(parameterKey(key)),
readable: (key: string) => card.readable(parameterKey(key)),
display: (key: string, value: unknown, suffix = '') => card.display(parameterKey(key), value, suffix),
}
function syncReadings() {
const controls: ParameterSnapshot = {}
for (const key of Object.keys(labels)) {
const reading = haStatus.value?.controlParameters?.[parameterKey(key)]
if (reading && state.available(key)) controls[key] = reading
}
Object.assign(localParams, defaults)
sync(localParams, controls)
}
watchEffect(syncReadings)
async function applyParam(key: string, value: unknown) {
await setParameter(props.device.deviceId, parameterKey(key), value)
syncReadings()
}
</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('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('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="applyParam('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="applyParam('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="applyParam('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="applyParam('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}_${parameterKey('fragrance_quick_release')}`"
:disabled="operationBusy || !state.available('fragrance_quick_release')"
@click="applyParam('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>
+32
View File
@@ -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>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Button as VanButton } from 'vant'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
import { outletCapabilities } from '../../devices/outlet/capabilities'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { operationBusy, switchingDeviceId, toggleDevice } = useDeviceControlContext()
const capability = computed(() => outletCapabilities(props.device))
</script>
<template>
<section class="device-card">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">智能插座</span>
<h3>{{ device.name }}</h3>
<span class="status-dot" :class="{ online: capability.available }">{{ capability.available ? '插座可用' : '插座主开关当前不可用' }}</span>
</div>
<VanButton size="small" round :type="capability.powerOn ? 'primary' : 'default'"
:loading="switchingDeviceId === device.deviceId" :disabled="operationBusy || !capability.available"
@click="toggleDevice(device)">
{{ capability.available ? capability.powerOn ? '关闭电源' : '开启电源' : '暂不可用' }}
</VanButton>
</div>
<div class="params-body">
<div class="param-row inline-row">
<span class="p-title">插座电源状态</span>
<strong class="p-val">{{ capability.powerOn === null ? '--' : capability.powerOn ? '已通电' : '已断电' }}</strong>
</div>
<div v-if="capability.energyEntity" class="param-row inline-row">
<span class="p-title">累计用电</span>
<strong class="p-val">{{ capability.energy === undefined ? '--' : `${capability.energy} ${capability.energyEntity.unit}` }}</strong>
</div>
<p class="scale-note">仅单独控制插座电源,暂不参与一键舒睡或一键关闭。内部服务及保护设置不在此调整。</p>
</div>
</section>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { toRefs } from 'vue'
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { useDeviceControlContext } from '../../devices/controlContext'
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
const { device: scaleDevice } = toRefs(props)
</script>
<template>
<section class="device-card" v-if="scaleDevice">
<div class="device-head">
<div class="dev-info">
<span class="category-tag">健康身体成分</span>
<h3>{{ scaleDevice.name }}</h3>
<span class="status-dot" :class="{ online: scaleDevice.online }">
{{ scaleDevice.online ? '已绑定就绪' : '设备离线' }}
</span>
</div>
</div>
<p class="scale-note">
体脂秤数据是否自动同步取决于设备接入能力;当前 Demo 按健康档案中保存的身高、体重计算 BMI。
</p>
</section>
</template>
@@ -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>
+309
View File
@@ -0,0 +1,309 @@
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 TcmAssessmentResult } from '../services/health'
import { createDeviceParameterState, parameterLabels, syncDeviceParameters } from '../devices/parameterRegistry'
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 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 localParams = createDeviceParameterState()
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,
})
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) {
tcmResult.value = dashboard.tcmResult
}
const controls = status.controlParameters ?? {}
syncDeviceParameters(localParams, controls)
const hasSceneLights = Boolean(status.devices?.some(device => device.category === 'Lighting' && device.moduleKey))
await Promise.all([syncLightTransitionStatus(hasSceneLights).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] || '设备参数'
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)
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, sceneRunning, settingParam, switchingDeviceId,
lastUpdatedAt, actionFeedback, lightTransition, musicTransition, lightActionBusy, localParams, operationBusy,
currentOperationText,
loadStatus, controlLightTransition, isDeviceTurnedOn, isDeviceControlAvailable, isParameterAvailable, hasParameterReading, toggleDevice, controlSpeaker,
applyParam, executeScene, handleBack }
}
+70
View File
@@ -0,0 +1,70 @@
import { onUnmounted, ref } from 'vue'
import { showFailToast, showLoadingToast, showSuccessToast } from 'vant'
import { healthApi } from '../services/health'
type SceneOptions = {
hasConstitution: () => boolean
needsConstitution: () => void
refresh: () => Promise<void>
refreshLater?: () => Promise<void>
feedback?: (type: 'success' | 'fail', message: string) => void
}
/** 首页和设备页共用:取消旧请求结果、全关接管、延迟回读和退出清理。 */
export function useDeviceScenes(options: SceneOptions) {
const sceneRunning = ref<'sleep' | 'all-off'>()
let active = true, sequence = 0
let toast: ReturnType<typeof showLoadingToast> | undefined
const timers: ReturnType<typeof setTimeout>[] = []
function clearRefreshTimers() {
for (const timer of timers) clearTimeout(timer)
timers.length = 0
}
function scheduleSceneStatusRefresh() {
clearRefreshTimers()
if (!active) return
for (const delay of [2000, 5000, 10000]) timers.push(setTimeout(() => {
if (active && !sceneRunning.value) void (options.refreshLater ?? options.refresh)().catch(() => undefined)
}, delay))
}
function feedback(type: 'success' | 'fail', message: string) {
options.feedback?.(type, message)
if (type === 'fail') showFailToast(message)
else showSuccessToast(message)
}
async function executeScene(scene: 'sleep' | 'all-off') {
if (!active || sceneRunning.value === 'all-off' || (scene === 'sleep' && sceneRunning.value)) return
if (scene === 'sleep' && !options.hasConstitution()) {
showFailToast('请先完成中医四诊,生成体质倾向'); options.needsConstitution(); return
}
const request = ++sequence
const current = () => active && request === sequence
clearRefreshTimers(); toast?.close()
sceneRunning.value = scene
const loadingToast = showLoadingToast({
message: scene === 'sleep' ? '正在启动舒睡场景…' : '正在关闭场景设备…',
// 其他按钮自行禁用,但不能用全屏点击锁挡住一键关闭。
forbidClick: false, duration: 0,
})
toast = loadingToast
try {
const result = scene === 'sleep' ? await healthApi.executeSleepScene() : await healthApi.executeAllOffScene()
if (!current()) return
await options.refresh().catch(() => undefined)
if (!current()) return
feedback(result.success && result.status !== 'Partial' ? 'success' : 'fail', result.message || '场景执行失败')
} catch (error) {
if (!current()) return
await options.refresh().catch(() => undefined)
if (!current()) return
feedback('fail', error instanceof Error ? error.message : '场景执行失败')
} finally {
if (request === sequence) {
loadingToast.close(); toast = undefined; sceneRunning.value = undefined
scheduleSceneStatusRefresh()
}
}
}
onUnmounted(() => { active = false; sequence++; toast?.close(); clearRefreshTimers() })
return { sceneRunning, executeScene, scheduleSceneStatusRefresh }
}
+65
View File
@@ -0,0 +1,65 @@
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(enabled = true) {
const sequence = ++lightSequence
if (!enabled) {
lightTransition.value = undefined
if (lightTimer) clearInterval(lightTimer)
lightTimer = undefined
return
}
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 }
}
+18
View File
@@ -0,0 +1,18 @@
import { onUnmounted } from 'vue'
// Pages may leave while a request is pending. Only the latest live request may
// update their state, show a toast, or navigate after it settles.
export function usePageRequest() {
let sequence = 0
let active = true
onUnmounted(() => {
active = false
sequence++
})
return {
begin: () => ++sequence,
isCurrent: (requestId: number) => active && requestId === sequence
}
}
+9
View File
@@ -0,0 +1,9 @@
import { boolValue, clamp, numberValue, type ParameterSnapshot } from '../parameterState'
export const defaults = { speakerVolume: 50, speakerSleepMode: false }
export const labels = { volume: '音箱音量', speaker_sleep_mode: '音箱睡眠模式' }
export function sync(state: typeof defaults, controls: ParameterSnapshot) {
const volume = numberValue(controls.volume?.attributes?.volume_level)
if (controls.volume?.available && volume !== undefined) state.speakerVolume = clamp(Math.round(volume * 100), 0, 100)
const sleep = boolValue(controls.speaker_sleep_mode)
if (sleep !== undefined) state.speakerSleepMode = sleep
}
+31
View File
@@ -0,0 +1,31 @@
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 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, 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
}
+33
View File
@@ -0,0 +1,33 @@
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { numberValue } from '../parameterState'
export const climateModeLabels: Record<string, string> = {
off: '关闭', cool: '制冷', heat: '制热', fan_only: '送风', dry: '除湿', auto: '自动', heat_cool: '冷暖自动',
}
export function climateCapabilities(device: HomeAssistantDiscoveredDevice) {
const entity = device.entities.find(item => item.entityId === device.primaryControlEntityId && item.domain === 'climate')
const attributes = entity?.attributes ?? {}
const reportedUnit = attributes.temperature_unit ?? attributes.unit_of_measurement
const temperatureUnit = ['°C', '℃'].includes(String(reportedUnit)) ? '℃'
: ['°F', '℉'].includes(String(reportedUnit)) ? '℉' : undefined
const modes = Array.isArray(attributes.hvac_modes)
? attributes.hvac_modes.filter((value): value is string => typeof value === 'string' && Object.prototype.hasOwnProperty.call(climateModeLabels, value)) : []
const fans = Array.isArray(attributes.fan_modes)
? attributes.fan_modes.filter((value): value is string => typeof value === 'string' && Boolean(value.trim())) : []
const features = numberValue(attributes.supported_features)
const supports = (flag: number) => features !== undefined && Number.isInteger(features) && features >= 0 && (features & flag) === flag
const available = device.moduleKey === 'climate' && device.online && entity?.available === true && modes.includes(entity.state)
const min = numberValue(attributes.min_temp), max = numberValue(attributes.max_temp), step = numberValue(attributes.target_temp_step)
const range = min !== undefined && max !== undefined && max > min && step !== undefined && step > 0 && step <= max - min
? { min, max, step } : undefined
const temperature = numberValue(attributes.temperature)
const targetTemperature = temperatureUnit && temperature !== undefined && range && temperature >= range.min && temperature <= range.max ? temperature : undefined
const currentTemperature = temperatureUnit ? numberValue(attributes.current_temperature) : undefined
const fanMode = typeof attributes.fan_mode === 'string' && fans.includes(attributes.fan_mode) ? attributes.fan_mode : undefined
const canToggle = Boolean(available && (entity?.state === 'off'
? supports(256) && modes.some(mode => mode !== 'off') : supports(128) && modes.includes('off')))
return { entity, modes: [...new Set(modes)], fans: [...new Set(fans)], supports, available, range,
targetTemperature, currentTemperature, temperatureUnit, fanMode, canToggle,
canSetTemperature: Boolean(available && temperatureUnit === '℃' && supports(1) && range && targetTemperature !== undefined),
canSetFan: Boolean(available && supports(8) && fans.length > 0) }
}
+14
View File
@@ -0,0 +1,14 @@
import { numberValue, type ParameterSnapshot } from '../parameterState'
export const defaults = { climateTemperature: undefined as number | undefined, climateMode: '', climateFanMode: '' }
export const labels = { climate_mode: '空调运行模式', climate_temperature: '空调目标温度', climate_fan_mode: '空调风速' }
export function sync(state: typeof defaults, controls: ParameterSnapshot) {
const temperature = controls.climate_temperature
if (temperature?.available) {
const value = numberValue(temperature.attributes?.temperature)
if (value !== undefined) state.climateTemperature = value
}
if (controls.climate_mode?.available && !['unknown', 'unavailable', ''].includes(controls.climate_mode.state))
state.climateMode = controls.climate_mode.state
const fan = controls.climate_fan_mode
if (fan?.available && typeof fan.attributes?.fan_mode === 'string') state.climateFanMode = fan.attributes.fan_mode
}
+9
View File
@@ -0,0 +1,9 @@
import { inject, type InjectionKey } from 'vue'
import type { useDeviceControl } from '../composables/useDeviceControl'
export type DeviceControlContext = ReturnType<typeof useDeviceControl>
export const deviceControlKey: InjectionKey<DeviceControlContext> = Symbol('device-control')
export function useDeviceControlContext(): DeviceControlContext {
const context = inject(deviceControlKey)
if (!context) throw new Error('设备控制组件缺少上下文')
return context
}
+16
View File
@@ -0,0 +1,16 @@
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { numberValue } from '../parameterState'
export type CurtainAction = 'open' | 'stop' | 'close'
export const curtainStateLabels: Record<string, string> = { open: '已打开', closed: '已关闭', opening: '正在打开', closing: '正在关闭' }
export function curtainCapabilities(device: HomeAssistantDiscoveredDevice) {
const entity = device.entities.find(item => item.entityId === device.primaryControlEntityId && item.domain === 'cover')
const features = numberValue(entity?.attributes?.supported_features)
const supports = (flag: number) => features !== undefined && Number.isInteger(features) && features >= 0 && (features & flag) === flag
const available = device.moduleKey === 'curtain' && device.online && entity?.available === true && Object.prototype.hasOwnProperty.call(curtainStateLabels, entity.state)
const value = numberValue(entity?.attributes?.current_position)
const position = value !== undefined && value >= 0 && value <= 100 ? value : undefined
const canAction = (action: CurtainAction) => Boolean(available && supports({ open: 1, close: 2, stop: 8 }[action]))
return { entity, supports, available, position, canAction,
canSetPosition: Boolean(available && supports(4) && position !== undefined) }
}
+10
View File
@@ -0,0 +1,10 @@
import { numberValue, type ParameterSnapshot } from '../parameterState'
export const defaults = { curtainPosition: undefined as number | undefined }
export const labels = { curtain_action: '窗帘开合', curtain_position: '窗帘开度' }
export function sync(state: typeof defaults, controls: ParameterSnapshot) {
const position = controls.curtain_position
if (position?.available) {
const value = numberValue(position.attributes?.current_position)
if (value !== undefined && value >= 0 && value <= 100) state.curtainPosition = value
}
}
+15
View File
@@ -0,0 +1,15 @@
import { boolValue, clamp, numberValue, type ParameterSnapshot } from '../parameterState'
export const defaults = { dehumidifierHumidity: 55, dehumidifierMode: '睡眠', dehumidifierFan: '1挡', dehumidifierUv: false }
export const labels = { humidity: '目标湿度', dehumidifier_mode: '除湿模式', fan_level: '风速档位', uv: 'UV 杀菌' }
export function sync(state: typeof defaults, controls: ParameterSnapshot) {
const humidifier = controls.humidity
if (humidifier?.available) {
const humidity = numberValue(humidifier.attributes?.humidity)
if (humidity !== undefined) state.dehumidifierHumidity = clamp(humidity, 40, 70)
if (typeof humidifier.attributes?.mode === 'string') state.dehumidifierMode = humidifier.attributes.mode
}
if (controls.fan_level?.available && ['1挡', '2挡', '3挡'].includes(controls.fan_level.state.replace('档', '挡')))
state.dehumidifierFan = controls.fan_level.state.replace('档', '挡')
const uv = boolValue(controls.uv)
if (uv !== undefined) state.dehumidifierUv = uv
}
+16
View File
@@ -0,0 +1,16 @@
import type { HomeAssistantDiscoveredDevice } from '../services/health'
const legacyCategories: Record<string, string> = {
EnvironmentControl: 'dehumidifier', Lighting: 'lighting', Media: 'audio',
Fragrance: 'fragrance', EnvironmentSensor: 'environment',
}
/** 新接口未绑定模块时不借用另一台同类设备的参数;仅兼容旧接口没有 moduleKey 的情况。 */
export function deviceModuleKey(device: HomeAssistantDiscoveredDevice): string {
if (device.moduleKey === 'wake-fragrance') return 'fragrance'
return device.moduleKey === undefined ? legacyCategories[device.category] ?? '' : device.moduleKey
}
export function deviceDetailLocation(deviceId: string) {
return { name: 'device-detail', params: { deviceId } }
}
+131
View File
@@ -0,0 +1,131 @@
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 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: '香薰与夜灯独立控制' }
},
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 }
}
+9
View File
@@ -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
}
+17
View File
@@ -0,0 +1,17 @@
import { boolValue, clamp, numberValue, type ParameterSnapshot } from '../parameterState'
export const defaults = { fragranceDuration: 2, fragranceInterval: 20, fragranceLight: false,
fragranceAutoRelease: false, fragranceAutoLight: false, fragranceLightAutoOff: false }
export const labels = { fragrance_duration: '出香时长', fragrance_interval: '出香间隔', fragrance_light: '香薰氛围灯',
fragrance_quick_release: '快速出香', fragrance_auto_release: '人来增香', fragrance_auto_light: '感应夜灯',
fragrance_light_auto_off: '氛围灯自动关闭' }
export function sync(state: typeof defaults, controls: ParameterSnapshot) {
const duration = numberValue(controls.fragrance_duration?.state)
if (controls.fragrance_duration?.available && duration !== undefined) state.fragranceDuration = clamp(duration, 2, 6)
const interval = numberValue(controls.fragrance_interval?.state)
if (controls.fragrance_interval?.available && interval !== undefined) state.fragranceInterval = clamp(interval, 10, 20)
for (const [parameter, property] of [['fragrance_light', 'fragranceLight'], ['fragrance_auto_release', 'fragranceAutoRelease'],
['fragrance_auto_light', 'fragranceAutoLight'], ['fragrance_light_auto_off', 'fragranceLightAutoOff']] as const) {
const value = boolValue(controls[parameter])
if (value !== undefined) state[property] = value
}
}
+27
View File
@@ -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 }
}
+14
View File
@@ -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)
}
+16
View File
@@ -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))
}
+50
View File
@@ -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 }
}
+15
View File
@@ -0,0 +1,15 @@
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
import { numberValue } from '../parameterState'
/** 主开关由后端明确绑定,不从同设备的多个 switch 中猜测;电计量只读标准能耗传感器。 */
export function outletCapabilities(device: HomeAssistantDiscoveredDevice) {
const entity = device.entities.find(item => item.entityId === device.primaryControlEntityId && item.domain === 'switch')
const available = device.moduleKey === 'outlet' && device.online && entity?.available === true &&
(entity.state === 'on' || entity.state === 'off')
const powerOn = available ? entity!.state === 'on' : null
const energyEntity = device.entities.find(item => item.domain === 'sensor' && item.attributes?.device_class === 'energy' &&
(item.unit === 'kWh' || item.unit === 'Wh'))
const value = device.online && energyEntity?.available ? numberValue(energyEntity.state) : undefined
const energy = value !== undefined && value >= 0 ? value : undefined
return { available: Boolean(available), powerOn, energyEntity, energy }
}
+23
View File
@@ -0,0 +1,23 @@
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 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,
...Object.fromEntries(Object.entries(fragrance.labels).map(([key, label]) => [`wake_${key}`, label])),
...climate.labels, ...curtain.labels
}
export function createDeviceParameterState() {
return reactive({ ...dehumidifier.defaults, ...lighting.defaults, ...audio.defaults,
...climate.defaults, ...curtain.defaults })
}
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)
climate.sync(state, controls); curtain.sync(state, controls)
}
+33
View File
@@ -0,0 +1,33 @@
import type { HomeAssistantDiscoveredDevice, HomeAssistantParameterState } from '../services/health'
export type ParameterSnapshot = Record<string, HomeAssistantParameterState>
export function parameterIsAvailable(controls: ParameterSnapshot | undefined, parameter: string, device?: HomeAssistantDiscoveredDevice): boolean {
const reading = controls?.[parameter]
if (reading?.available !== true) return false
return !device || (device.online && device.entities.some(entity => entity.entityId === reading.entityId && entity.available))
}
export function parameterHasReading(controls: ParameterSnapshot | undefined, parameter: string, device?: HomeAssistantDiscoveredDevice): boolean {
if (!parameterIsAvailable(controls, parameter, device)) return false
const reading = controls![parameter]
if (['unknown', 'unavailable', ''].includes(reading.state)) return false
const numericAttribute: Record<string, string> = {
brightness: 'brightness', color_temp: 'color_temp_kelvin', humidity: 'humidity', volume: 'volume_level',
climate_temperature: 'temperature', curtain_position: 'current_position',
}
if (parameter === 'brightness' && reading.state === 'off') return true
if (numericAttribute[parameter]) return numberValue(reading.attributes?.[numericAttribute[parameter]]) !== undefined
if (parameter === 'dehumidifier_mode') return typeof reading.attributes?.mode === 'string' && Boolean(reading.attributes.mode)
if (parameter === 'climate_fan_mode') return typeof reading.attributes?.fan_mode === 'string' && Boolean(reading.attributes.fan_mode)
return true
}
export const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
export function numberValue(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value !== 'string' || !value.trim()) return undefined
const number = Number(value)
return Number.isFinite(number) ? number : undefined
}
export function boolValue(state: HomeAssistantParameterState | undefined): boolean | undefined {
if (!state?.available) return undefined
return state.state === 'on' ? true : state.state === 'off' ? false : undefined
}
+13
View File
@@ -0,0 +1,13 @@
import type { HomeAssistantDiscoveredDevice } from '../services/health'
import { useDeviceControlContext } from './controlContext'
/** 专属详情必须检查当前设备的参数实体,不能用在线设备数或默认值冒充读数。 */
export function useDeviceCardState(device: () => HomeAssistantDiscoveredDevice) {
const context = useDeviceControlContext()
const available = (parameter: string) => context.isParameterAvailable(parameter, device())
const readable = (parameter: string) => context.hasParameterReading(parameter, device())
const display = (parameter: string, value: unknown, suffix = '') => readable(parameter) ? `${value}${suffix}` : '--'
const powerAvailable = () => context.isDeviceControlAvailable(device())
const powerLabel = (on: string, off: string) => powerAvailable() ? context.isDeviceTurnedOn(device()) ? on : off : '暂不可用'
return { available, readable, display, powerAvailable, powerLabel }
}
+1
View File
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import 'vant/lib/index.css'
import './styles.css'
import './styles/page-polish.css'
import App from './App.vue'
import router from './router'
+10 -1
View File
@@ -5,19 +5,28 @@ import AssessmentView from './views/AssessmentView.vue'
import TcmAssessmentView from './views/TcmAssessmentView.vue'
import ProfileView from './views/ProfileView.vue'
import DeviceControlView from './views/DeviceControlView.vue'
import DeviceControlLayout from './views/DeviceControlLayout.vue'
import DeviceDetailView from './views/DeviceDetailView.vue'
import SleepDataView from './views/SleepDataView.vue'
import { hasToken } from './services/auth'
const router = createRouter({
history: createWebHistory(),
scrollBehavior: (_to, _from, savedPosition) => savedPosition ?? { left: 0, top: 0 },
routes: [
{ path: '/', redirect: '/dashboard' },
{ path: '/auth', component: AuthView, meta: { guest: true } },
{ path: '/onboarding', redirect: '/assessment' },
{ path: '/dashboard', component: DashboardView },
{ path: '/sleep-data', component: SleepDataView },
{ path: '/assessment', component: AssessmentView },
{ path: '/tcm-assessment', component: TcmAssessmentView },
{ path: '/profile', component: ProfileView },
{ path: '/device-control', component: DeviceControlView }
{ path: '/device-control', component: DeviceControlLayout, children: [
{ path: '', name: 'device-overview', component: DeviceControlView },
{ path: ':deviceId', name: 'device-detail', component: DeviceDetailView },
] },
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }
]
})
+42
View File
@@ -0,0 +1,42 @@
import type { HealthAssessmentAnswers } from './health'
export function finiteNumberInRange(value: unknown, minimum: number, maximum: number, integer = false): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= minimum && value <= maximum && (!integer || Number.isInteger(value))
}
// Birthdays are calendar dates, not instants. Keep the displayed day unchanged
// even when the API serializes a DateTime or the browser has a UTC offset.
// Admin.NET returns yyyy-MM-dd HH:mm:ss; also accept date-only and ISO timestamps.
export function ageFromBirthday(value: unknown, today = new Date()): number | undefined {
if (typeof value !== 'string') return undefined
const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)?$/.exec(value)
if (!match) return undefined
const [, yearText, monthText, dayText] = match
const year = Number(yearText), month = Number(monthText), day = Number(dayText)
const birthday = new Date(year, month - 1, day)
if (birthday.getFullYear() !== year || birthday.getMonth() !== month - 1 || birthday.getDate() !== day) return undefined
const currentDay = new Date(today.getFullYear(), today.getMonth(), today.getDate())
if (birthday > currentDay) return undefined
const beforeBirthday = today.getMonth() < month - 1 || (today.getMonth() === month - 1 && today.getDate() < day)
return today.getFullYear() - year - (beforeBirthday ? 1 : 0)
}
const healthAnswerOptions: Record<keyof HealthAssessmentAnswers, readonly string[]> = {
sleepDuration: ['<6', '6-7', '7-8', '>8'],
fallingAsleepSpeed: ['hard', 'normal', 'fast'],
temperaturePreference: ['heat_fear', 'cold_fear', 'normal'],
nightAwakening: ['frequent', 'light', 'none'],
mattressPreference: ['firm', 'medium', 'soft'],
exerciseFrequency: ['none', '1-2', '3-5', 'daily'],
muscleFatigueArea: ['back_neck', 'waist_back', 'legs', 'none'],
activeTimeOfDay: ['morning', 'evening', 'night', 'irregular'],
mealRegularity: ['regular', 'skip_breakfast', 'irregular'],
flavorPreference: ['light', 'moderate', 'heavy'],
lateNightEating: ['often', 'sometimes', 'rarely'],
dailyWaterIntake: ['abundant', 'moderate', 'scarce']
}
export function healthAnswersComplete(answers: HealthAssessmentAnswers): boolean {
return (Object.keys(healthAnswerOptions) as (keyof HealthAssessmentAnswers)[])
.every(key => healthAnswerOptions[key].includes(answers[key]))
}
+35 -20
View File
@@ -47,11 +47,6 @@ export type PersonalizedSceneConfig = {
enableUv: boolean
lightBrightnessPercent: number
lightColorTemperatureKelvin: number
mattressTargetTemp: number
mattressLeftFirmness: number
mattressRightFirmness: number
mattressHeatingLevel: number
mattressMode: string
enableFragrance: boolean
fragranceLevel: number
enableSpeakerSleepMode: boolean
@@ -97,20 +92,23 @@ export type Dashboard = {
bmiStatus?: string
sceneConfig?: PersonalizedSceneConfig
tcmResult?: TcmAssessmentResult
mattressComfort?: MattressComfort
environmentAdvice?: EnvironmentAdvice
}
export type MattressComfort = {
bmi?: number | null
firmness?: string | null
heatingLevel: number
isWinter: boolean
outdoorTemperatureCelsius?: number | null
feelsLikeCelsius?: number | null
weatherAvailable: boolean
weatherObservedAt?: string | null
weatherReason: string
firmnessReason: string
export type EnvironmentAdvice = {
indoorTemperatureCelsius?: number | null
indoorTemperatureAvailable: boolean
indoorTemperatureObservedAt?: string | null
climateAdvice?: ClimateAdvice
}
export type ClimateAdvice = {
mode: 'unavailable' | 'off' | 'heat' | 'cool'
recommendedMinimumCelsius: number
recommendedMaximumCelsius: number
targetTemperatureCelsius?: number | null
pendingIntegration: boolean
reason: string
}
export type TcmAssessmentInput = {
@@ -143,6 +141,9 @@ export type HealthDeviceCategory =
| 'Lighting'
| 'Media'
| 'Other'
| 'Climate'
| 'Curtain'
| 'SleepMonitoring'
export type HomeAssistantEntity = {
entityId: string
@@ -155,6 +156,7 @@ export type HomeAssistantEntity = {
}
export type HomeAssistantDiscoveredDevice = {
moduleKey?: string
deviceId: string
name: string
manufacturer: string
@@ -163,6 +165,7 @@ export type HomeAssistantDiscoveredDevice = {
category: HealthDeviceCategory | string
online: boolean
primaryControlEntityId?: string | null
defaultLightEffect?: string | null
entities: HomeAssistantEntity[]
metrics?: Record<string, unknown>
}
@@ -211,12 +214,21 @@ export type HomeAssistantDeviceSwitchOutput = {
}
export type HomeAssistantLightTransition = {
entityId?: string
success: boolean
running: boolean
mode: 'none' | 'sleep' | 'wake'
brightnessPercent: number
targetPercent: number
message: string
lights?: HomeAssistantLightTransition[]
}
export type TcmMusicTransition = {
running: boolean
hasVolume: boolean
volumePercent: number
message: string
}
export type HomeAssistantStatus = {
@@ -246,6 +258,7 @@ export type HomeAssistantSceneAction = {
}
export type HomeAssistantSceneResult = {
climateAdvice?: ClimateAdvice | null
sceneCode: string
sceneName: string
success: boolean
@@ -285,6 +298,8 @@ export const healthApi = {
http.get<never, HomeAssistantStatus>('/api/health/home-assistant/status'),
lightTransitionStatus: () =>
http.get<never, HomeAssistantLightTransition>('/api/health/home-assistant/lights/transition'),
musicTransitionStatus: () =>
http.get<never, TcmMusicTransition>('/api/health/home-assistant/music/transition'),
startSleepLight: () =>
http.post<never, HomeAssistantLightTransition>('/api/health/home-assistant/lights/sleep'),
startWakeLight: () =>
@@ -294,9 +309,9 @@ export const healthApi = {
executeSleepScene: () =>
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/sleep', undefined, { timeout: 90000 }),
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 }) =>
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) =>
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
View File
@@ -2,12 +2,26 @@ import axios from 'axios'
import { clearTokens, getToken } from './auth'
type UnifiedResult<T> = {
code?: number
type?: string
message?: string
result?: T
errors?: unknown
}
let redirectingToLogin = false
function handleUnauthorized(requestAuthorization?: unknown) {
// A response from the previous session must not clear a newly signed-in session.
const currentToken = getToken()
if (currentToken && requestAuthorization !== `Bearer ${currentToken}`) return
clearTokens()
if (typeof window !== 'undefined' && window.location.pathname !== '/auth' && !redirectingToLogin) {
redirectingToLogin = true
window.location.replace('/auth')
}
}
const http = axios.create({
baseURL: '/',
timeout: 15000
@@ -22,6 +36,12 @@ http.interceptors.request.use((config) => {
http.interceptors.response.use(
((response: any) => {
const body = response.data as UnifiedResult<unknown>
if (body && typeof body === 'object') {
if (body.code === 401) {
handleUnauthorized(response.config?.headers?.Authorization)
return Promise.reject(new Error(body.message || '登录已过期,请重新登录'))
}
}
if (body && typeof body === 'object' && 'result' in body) {
if (body.type && body.type !== 'success') {
return Promise.reject(new Error(body.message || '请求失败'))
@@ -31,7 +51,9 @@ http.interceptors.response.use(
return body
}) as any,
(error) => {
if (error.response?.status === 401) clearTokens()
if (error.response?.status === 401) {
handleUnauthorized(error.config?.headers?.Authorization ?? error.response?.config?.headers?.Authorization)
}
const body = error.response?.data as UnifiedResult<unknown> | undefined
const message = body?.message || error.message || '网络请求失败'
return Promise.reject(new Error(message))
+96
View File
@@ -0,0 +1,96 @@
import http from './http'
export const ib43Metrics = [
{ key: 'heartRate', label: '心率' },
{ key: 'respiration', label: '呼吸' },
{ key: 'presence', label: '在床' },
{ key: 'snoring', label: '打鼾' },
] as const
export type Ib43Metric = typeof ib43Metrics[number]['key']
export type Ib43Bed = {
id: string
bedName: string
side: 'left' | 'right'
simulation: boolean
startedAtMs: number | string
endedAtMs?: number | string | null
}
type Ib43Reading = { value: number | null; receivedAt?: string | null }
export type Ib43Current = {
bed: Ib43Bed
transport: string
active: boolean
readings: Partial<Record<Ib43Metric, Ib43Reading & { freshness: 'recent' | 'stale' | 'unknown' }>>
}
export type Ib43HistoryItem = {
id: number | string
receivedAtMs: number | string
readings: Partial<Record<Ib43Metric, Ib43Reading>>
}
type Ib43History = { items: Ib43HistoryItem[]; nextBeforeId?: number | string | null }
export type Ib43HistoryQuery = { fromMs: number; toMs: number; beforeId?: number | string; take: number }
export type Ib43AutomationRecord = {
id: string
actionKey: string
targetLabel: string
triggeredAtMs: number | string
completedAtMs?: number | string | null
result: string
}
export type Ib43AutomationRule = {
actionKey: string
enabled: boolean
actionLabel: string
targetLabel: string
previewOnly: boolean
conditionLabel?: string | null
}
export type Ib43Automation = {
available: boolean
enabled: boolean
actionLabel: string
targetLabel: string
records: Ib43AutomationRecord[]
rules?: Ib43AutomationRule[]
}
const bindingsUrl = '/api/health/ib43/bindings'
const bedUrl = (id: string) => `${bindingsUrl}/${encodeURIComponent(id)}`
export const ib43Api = {
bindings: () => http.get<never, Ib43Bed[]>(bindingsUrl),
current: (id: string) => http.get<never, Ib43Current>(`${bedUrl(id)}/current`),
history: (id: string, params: Ib43HistoryQuery) => http.get<never, Ib43History>(`${bedUrl(id)}/history`, { params }),
automation: (id: string) => http.get<never, Ib43Automation>(`${bedUrl(id)}/automation`),
startAutomation: (id: string, actionKey?: string) =>
http.post<never, void>(`${bedUrl(id)}/automation/start`, undefined, { params: { actionKey } }),
stopAutomation: (id: string, actionKey?: string) =>
http.post<never, void>(`${bedUrl(id)}/automation/stop`, undefined, { params: { actionKey } }),
}
export function displayIb43Value(metric: Ib43Metric, value?: number | null) {
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
if (metric === 'presence') return value === 1 ? '在床' : value === 0 ? '离床' : '—'
if (metric === 'snoring') return value === 1 ? '打鼾' : value === 0 ? '未打鼾' : '—'
return String(value)
}
const timeFormat = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
})
export function formatIb43Time(value?: number | string | null) {
if (value == null || value === '') return ''
// 最近读数的 UTC 时间经过 API 序列化后可能没有偏移;历史使用 Unix 毫秒。
const milliseconds = typeof value === 'number' || /^\d+$/.test(value) ? Number(value) : Date.parse(
/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?$/.test(value) ? `${value.replace(' ', 'T')}Z` : value,
)
return Number.isFinite(new Date(milliseconds).getTime()) ? timeFormat.format(milliseconds) : ''
}
+326 -160
View File
@@ -1,13 +1,23 @@
:root {
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: #173a34;
background: #f2f0e9;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: #0f172a;
background: #f8fafc;
font-synthesis: none;
--green: #0c5045;
--deep: #123f37;
--cream: #f8f4e8;
--gold: #d6a657;
--line: rgba(18, 63, 55, 0.12);
--primary: #0284c7;
--primary-light: #f0f9ff;
--primary-border: #bae6fd;
--primary-dark: #0369a1;
--green: #0284c7;
--deep: #0f172a;
--text-main: #0f172a;
--text-body: #334155;
--text-muted: #64748b;
--cream: #f8fafc;
--gold: #0284c7;
--line: #e2e8f0;
--card-bg: #ffffff;
--card-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.05), 0 2px 6px -1px rgba(15, 23, 42, 0.02);
--card-radius: 16px;
}
* { box-sizing: border-box; }
@@ -16,7 +26,7 @@ html, body {
padding: 0;
min-width: 320px;
min-height: 100vh;
background: #e8e6df;
background: #f1f5f9;
overflow-x: hidden;
-webkit-text-size-adjust: 100%;
}
@@ -25,8 +35,8 @@ html, body {
margin: 0 auto;
min-height: 100vh;
position: relative;
background: #f2f0e9;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.08);
background: linear-gradient(180deg, #bfe3fc 0%, #e0f2fe 22%, #f0f9ff 48%, #f8fafc 100%);
box-shadow: 0 0 32px rgba(15, 23, 42, 0.06);
overflow-x: hidden;
}
button, input, textarea { font: inherit; }
@@ -34,98 +44,246 @@ button { cursor: pointer; }
.eyebrow {
margin: 0 0 8px;
color: #b68b48;
color: #0284c7;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
}
.auth-page { min-height: 100vh; background: var(--cream); }
.brand-panel {
padding: 46px 24px 46px;
color: #fff;
.auth-page {
min-height: 100vh;
background:
radial-gradient(circle at 82% 12%, rgba(236, 202, 137, .28), transparent 28%),
linear-gradient(145deg, #0b5548, #123d37 74%);
border-radius: 0 0 36px 36px;
radial-gradient(circle at 85% 10%, rgba(178, 225, 255, 0.45) 0%, rgba(224, 242, 254, 0.25) 35%, transparent 70%),
linear-gradient(180deg, #bfe3fc 0%, #e0f2fe 25%, #f8fafc 55%, #f1f5f9 100%);
padding: 20px 16px 40px;
}
.brand-panel {
max-width: 440px;
margin: 0 auto;
padding: 16px 4px 20px;
color: #0f172a;
background: transparent;
border-radius: 0;
}
.brand-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 14px 5px 6px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 24px;
padding: 4px 12px 4px 4px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(191, 219, 254, 0.8);
border-radius: 20px;
font-size: 12px;
margin-bottom: 20px;
font-weight: 600;
color: #075985;
margin-bottom: 14px;
box-shadow: 0 2px 8px rgba(2, 132, 199, 0.06);
backdrop-filter: blur(8px);
}
.brand-badge-icon {
width: 24px;
height: 24px;
background: var(--gold);
width: 22px;
height: 22px;
background: #0284c7;
color: #fff;
border-radius: 50%;
display: grid;
place-items: center;
font-family: serif;
font-weight: 700;
font-size: 13px;
font-size: 11px;
}
.brand-mark {
display: grid; place-items: center; width: 48px; height: 48px; margin-bottom: 28px;
border: 1px solid rgba(255,255,255,.42); border-radius: 50%; font-family: serif; font-size: 24px;
display: grid; place-items: center; width: 48px; height: 48px; margin-bottom: 24px;
border: 1px solid rgba(2, 132, 199, 0.3); border-radius: 50%; font-family: serif; font-size: 24px; color: #0284c7;
}
.brand-panel .eyebrow {
margin: 0 0 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
color: #0284c7;
}
.brand-panel h1 {
max-width: 560px;
margin: 0;
font-size: clamp(26px, 7vw, 32px);
line-height: 1.25;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.02em;
}
.brand-copy {
max-width: 480px;
margin: 10px 0 16px;
color: #475569;
line-height: 1.6;
font-size: 13px;
}
.feature-row, .feature-pill-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.feature-row span, .feature-pill-row span {
padding: 5px 12px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(191, 219, 254, 0.75);
border-radius: 18px;
font-size: 12px;
font-weight: 600;
color: #0284c7;
box-shadow: 0 2px 6px rgba(2, 132, 199, 0.04);
backdrop-filter: blur(6px);
}
.brand-panel h1 { max-width: 560px; margin: 0; font-family: "Songti SC", serif; font-size: clamp(32px, 8vw, 54px); line-height: 1.2; font-weight: 500; }
.brand-copy { max-width: 480px; margin: 14px 0 16px; color: rgba(255,255,255,.78); line-height: 1.6; }
.feature-row, .feature-pill-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.feature-row span, .feature-pill-row span { padding: 6px 12px; background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.16); border-radius: 20px; font-size: 12px; }
.auth-card { position: relative; width: calc(100% - 32px); max-width: 440px; margin: -16px auto 32px; padding: 24px 20px; background: #fff; border-radius: 24px; box-shadow: 0 14px 40px rgba(24,59,52,.12); }
.auth-card {
position: relative;
width: 100%;
max-width: 440px;
margin: 0 auto 32px;
padding: 24px 20px 28px;
background: #ffffff;
border-radius: 22px;
border: 1px solid rgba(226, 232, 240, 0.85);
box-shadow: 0 12px 32px -4px rgba(15, 23, 42, 0.06), 0 4px 12px -2px rgba(15, 23, 42, 0.02);
}
.auth-tab-switch {
display: flex;
background: #f1efea;
background: #f1f5f9;
border-radius: 12px;
padding: 3px;
padding: 4px;
margin-bottom: 20px;
}
.auth-tab-switch button {
flex: 1;
padding: 9px 0;
padding: 10px 0;
border: 0;
font-size: 14px;
font-weight: 600;
border-radius: 9px;
color: #72847d;
color: #64748b;
background: transparent;
cursor: pointer;
transition: all 0.2s ease;
}
.auth-tab-switch button.active {
background: #ffffff;
color: var(--green);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
color: #0284c7;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
}
.auth-heading h2 {
margin: 0;
font-size: 22px;
font-weight: 700;
color: #0f172a;
}
.auth-heading > p:last-child {
margin: 6px 0 20px;
color: #64748b;
font-size: 13px;
}
.auth-card .van-field {
position: relative;
margin-bottom: 16px;
padding: 12px 14px;
background: #f8fafc;
border-radius: 12px;
border: 1px solid #e2e8f0;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease, margin-bottom 0.2s ease;
min-height: 48px;
}
.auth-card .van-field:focus-within {
background: #ffffff;
border-color: #0284c7;
box-shadow: 0 0 0 3px rgba(2, 132, 199, 0.12);
}
.auth-card .van-field__value {
position: static;
overflow: visible;
}
.auth-card .van-field__label {
color: #334155;
font-weight: 600;
width: 4.8em;
margin-right: 8px;
}
.auth-card .van-field__control {
color: #0f172a;
font-weight: 500;
font-size: 15px;
}
.auth-card .van-field__error-message {
position: absolute;
top: calc(100% + 4px);
left: 12px;
font-size: 12px;
line-height: 1.4;
color: #ef4444;
font-weight: 500;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
z-index: 2;
}
.auth-card .van-field__error-message::before {
content: "⚠";
font-size: 11px;
}
.auth-card .van-field:has(.van-field__error-message),
.auth-card .van-field--error {
margin-bottom: 26px !important;
border-color: #ef4444 !important;
background-color: #fffafa !important;
}
.auth-card .van-field:has(.van-field__error-message):focus-within,
.auth-card .van-field--error:focus-within {
border-color: #ef4444 !important;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12) !important;
}
.auth-card .van-button {
margin-top: 20px;
height: 48px;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%) !important;
border: none !important;
border-radius: 14px !important;
box-shadow: 0 8px 20px -3px rgba(2, 132, 199, 0.35);
color: #ffffff !important;
transition: transform 0.1s;
}
.auth-card .van-button:active {
transform: scale(0.985);
}
.text-button {
display: block;
margin: 18px auto 0;
border: 0;
background: transparent;
color: #0284c7;
font-size: 14px;
}
.privacy-note {
margin: 20px 0 0;
color: #94a3b8;
font-size: 11px;
line-height: 1.6;
text-align: center;
}
.auth-heading h2 { margin: 0; font-family: "Songti SC", serif; font-size: 24px; }
.auth-heading > p:last-child { margin: 8px 0 20px; color: #70817d; font-size: 13px; }
.auth-card .van-field { margin-bottom: 12px; padding: 14px 12px; background: #f7f7f3; border-radius: 12px; }
.auth-card .van-button { margin-top: 18px; height: 48px; font-size: 15px; font-weight: 600; }
.text-button { display: block; margin: 18px auto 0; border: 0; background: transparent; color: var(--green); font-size: 14px; }
.privacy-note { margin: 18px 0 0; color: #9a9f9c; font-size: 11px; line-height: 1.6; text-align: center; }
.page-shell { min-height: 100vh; padding: 28px 16px 110px; background: #f3f1ea; }
.page-header, .dashboard-header { max-width: 760px; margin: 0 auto 24px; padding: 12px 4px; }
.page-header h1, .dashboard-header h1 { margin: 0; font-family: "Songti SC", serif; font-size: 30px; font-weight: 600; }
.page-header > p:last-child, .dashboard-header > div > p:last-child { color: #73817d; line-height: 1.6; }
.page-shell { min-height: 100vh; padding: 20px var(--page-gutter, 16px) calc(88px + env(safe-area-inset-bottom, 0px)); background: linear-gradient(180deg, #bfe3fc 0%, #e0f2fe 22%, #f0f9ff 48%, #f8fafc 100%); }
.page-header, .dashboard-header { max-width: 760px; margin: 0 auto 20px; padding: 12px 4px; }
.page-header h1, .dashboard-header h1 { margin: 0; font-size: 28px; font-weight: 700; color: #0f172a; }
.page-header > p:last-child, .dashboard-header > div > p:last-child { color: #64748b; line-height: 1.6; }
.section-card, .hero-card, .metric-strip { max-width: 760px; margin: 0 auto 16px; padding: 20px; background: #fff; border: 1px solid rgba(18,63,55,.06); border-radius: 20px; box-shadow: 0 8px 24px rgba(31,66,58,.05); }
.section-title { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.section-title > span { font-size: 18px; font-weight: 700; }
.section-title small { color: #82908c; }
.section-card, .hero-card, .metric-strip { max-width: 760px; margin: 0 auto 16px; padding: 20px; background: #fff; border: 1px solid rgba(2, 132, 199, 0.06); border-radius: 18px; box-shadow: 0 4px 20px rgba(15, 23, 42, 0.04); }
.section-title { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.section-title > span { font-size: 17px; font-weight: 700; color: #0f172a; }
.section-title small { color: #64748b; }
.section-title button {
border: 0;
background: rgba(12, 80, 69, 0.08);
background: rgba(2, 132, 199, 0.08);
color: var(--green);
padding: 4px 10px;
border-radius: 12px;
@@ -141,13 +299,13 @@ button { cursor: pointer; }
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid var(--line);
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #f8f8f4;
background: #f8fafc;
color: var(--deep);
transition: .2s;
}
.module-card.selected { color: #fff; background: var(--green); transform: translateY(-2px); box-shadow: 0 12px 24px rgba(12,80,69,.18); }
.module-card.selected { color: #fff; background: var(--green); transform: translateY(-2px); box-shadow: 0 12px 24px rgba(2, 132, 199, 0.2); }
.module-icon { display: grid; place-items: center; width: 38px; height: 38px; margin-bottom: 10px; border: 1px solid currentColor; border-radius: 50%; font-size: 20px; flex-shrink: 0; }
.module-card strong { display: block; font-size: 13px; font-weight: 700; margin-bottom: 4px; }
.module-card small { display: flex; align-items: center; justify-content: center; min-height: 28px; margin-top: 2px; opacity: .7; line-height: 1.35; font-size: 11px; }
@@ -162,7 +320,7 @@ button { cursor: pointer; }
font-style: normal;
font-weight: 600;
white-space: nowrap;
background: rgba(18, 63, 55, 0.08);
background: #f1f5f9;
color: var(--deep);
}
.module-card.selected i {
@@ -172,42 +330,43 @@ button { cursor: pointer; }
.form-card .van-field { padding: 12px 0; border-bottom: 1px solid #eee; }
.form-card .van-field:last-of-type { border-bottom: 0; }
.two-column { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
.sticky-action { position: fixed; z-index: 10; left: 0; right: 0; bottom: 0; padding: 14px 20px max(14px, env(safe-area-inset-bottom)); background: rgba(248,246,240,.94); backdrop-filter: blur(14px); border-top: 1px solid var(--line); }
.sticky-action .van-button { max-width: 720px; height: 50px; margin: 0 auto; font-size: 15px; font-weight: 600; box-shadow: 0 6px 18px rgba(12,80,69,.2); }
.sticky-action { position: fixed; z-index: 10; left: 0; right: 0; bottom: 0; padding: 14px 20px max(14px, env(safe-area-inset-bottom)); background: rgba(255, 255, 255, 0.94); backdrop-filter: blur(14px); border-top: 1px solid rgba(2, 132, 199, 0.08); }
.sticky-action .van-button { max-width: 720px; height: 50px; margin: 0 auto; font-size: 15px; font-weight: 600; box-shadow: 0 6px 18px rgba(2, 132, 199, 0.2); }
.dashboard-header { display: flex; align-items: center; justify-content: space-between; }
.avatar-button { width: 46px; height: 46px; color: #fff; border: 0; border-radius: 50%; background: var(--green); }
.hero-card { padding: 26px; color: #fff; background: linear-gradient(140deg, #0c5549, #163f38); }
.hero-card h2 { margin: 6px 0 12px; font-family: "Songti SC", serif; font-size: 28px; }
.hero-card > p:not(.eyebrow) { color: rgba(255,255,255,.72); line-height: 1.7; }
.avatar-button { width: 44px; height: 44px; color: #0284c7; border: 1.5px solid rgba(2, 132, 199, 0.2); border-radius: 50%; background: #f0f9ff; font-weight: 700; box-shadow: 0 2px 8px rgba(2, 132, 199, 0.08); }
.hero-card { padding: 20px 18px; color: #0f172a; background: #ffffff; border: 1px solid #eef2f6; border-radius: 16px; box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.05); }
.hero-card h2 { margin: 6px 0 10px; font-size: 20px; font-weight: 700; color: #0f172a; }
.hero-card > p:not(.eyebrow) { color: #64748b; line-height: 1.65; font-size: 13px; }
.tag-row { display: flex; gap: 8px; margin: 18px 0; }
.tag-row span { padding: 6px 12px; border-radius: 20px; background: rgba(255,255,255,.12); font-size: 12px; }
.tag-row span { padding: 6px 12px; border-radius: 20px; background: rgba(255,255,255,.16); font-size: 12px; }
.metric-strip { display: grid; grid-template-columns: repeat(3, 1fr); padding: 16px 8px; }
.metric-strip div { padding: 4px 14px; border-right: 1px solid var(--line); }
.metric-strip div:last-child { border-right: 0; }
.metric-strip small, .metric-strip strong { display: block; }
.metric-strip small { color: #80908b; font-size: 11px; }
.metric-strip small { color: #64748b; font-size: 11px; }
.metric-strip strong { margin-top: 7px; font-size: 21px; }
.metric-strip em { margin-left: 3px; font-size: 11px; font-style: normal; font-weight: 500; }
.plan-item { display: grid; grid-template-columns: 42px 1fr; gap: 12px; padding: 16px 0; border-top: 1px solid #edf0ec; }
.plan-item { display: grid; grid-template-columns: 42px 1fr; gap: 12px; padding: 16px 0; border-top: 1px solid #f1f5f9; }
.plan-item:first-of-type { border-top: 0; }
.plan-index { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 50%; color: var(--green); background: #e9f1ed; }
.plan-index { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 50%; color: var(--green); background: #f0f9ff; }
.plan-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.plan-heading span { color: #b1813f; font-size: 12px; text-align: right; }
.plan-item p, .scene-row p { margin: 7px 0 0; color: #75847f; font-size: 13px; line-height: 1.6; }
.status-dot { color: #a27639 !important; text-align: right; }
.status-dot.online { color: #1c806a !important; }
.plan-heading span { color: #0284c7; font-size: 12px; text-align: right; }
.plan-item p, .scene-row p { margin: 7px 0 0; color: #64748b; font-size: 13px; line-height: 1.6; }
.status-dot { color: #f59e0b !important; text-align: right; }
.status-dot.online { color: #10b981 !important; }
.device-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 12px; }
/* Health Space Enhancement */
.ha-summary-bar {
display: flex;
align-items: center;
justify-content: space-around;
background: #fbfbf9;
background: #ffffff;
border-radius: 12px;
padding: 10px 12px;
margin-bottom: 14px;
border: 1px solid rgba(18, 63, 55, 0.06);
border: 1px solid rgba(2, 132, 199, 0.08);
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.03);
}
.summary-item {
text-align: center;
@@ -221,18 +380,19 @@ button { cursor: pointer; }
top: 20%;
height: 60%;
width: 1px;
background: rgba(18, 63, 55, 0.08);
background: rgba(226, 232, 240, 0.8);
}
.summary-item span { display: block; font-size: 11px; color: #82908c; }
.summary-item strong { display: block; font-size: 16px; color: var(--green); margin-top: 2px; }
.summary-item em { font-size: 12px; font-style: normal; color: #82908c; font-weight: normal; }
.smart-subcard {
background: #fafaf8;
border: 1px solid rgba(18, 63, 55, 0.06);
background: #ffffff;
border: 1px solid rgba(2, 132, 199, 0.06);
border-radius: 14px;
padding: 13px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.02);
}
.smart-subcard:last-child { margin-bottom: 0; }
.subcard-header {
@@ -265,10 +425,10 @@ button { cursor: pointer; }
font-size: 11px;
font-weight: 500;
color: var(--green);
background: #edf7f3;
background: #f0f9ff;
padding: 3px 9px;
border-radius: 12px;
border: 1px solid rgba(12, 80, 69, 0.15);
border: 1px solid rgba(2, 132, 199, 0.2);
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
@@ -282,7 +442,7 @@ button { cursor: pointer; }
font-size: 11px;
font-weight: 500;
color: var(--green);
background: #edf7f3;
background: #f0f9ff;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
@@ -296,7 +456,7 @@ button { cursor: pointer; }
white-space: nowrap;
flex-shrink: 0;
}
.device-mini-status.green { background: #edf7f3; color: var(--green); }
.device-mini-status.green { background: #f0f9ff; color: var(--green); }
.device-mini-status.yellow { background: #fef7ec; color: #b8860b; }
.device-mini-status.gray { background: #f1f3f2; color: #82908c; }
@@ -305,7 +465,7 @@ button { cursor: pointer; }
font-weight: 500;
padding: 3px 9px;
border-radius: 10px;
border: 1px solid rgba(18, 63, 55, 0.15);
border: 1px solid rgba(2, 132, 199, 0.2);
background: #fff;
color: var(--green);
cursor: pointer;
@@ -314,7 +474,7 @@ button { cursor: pointer; }
transition: all 0.2s;
}
.sensor-mini-sync-btn:active {
background: #edf7f3;
background: #f0f9ff;
}
.sensor-mini-sync-btn:disabled {
opacity: 0.5;
@@ -329,8 +489,8 @@ button { cursor: pointer; }
padding: 12px 14px;
border-radius: 12px;
background: #fff;
border: 1px solid rgba(18, 63, 55, 0.06);
box-shadow: 0 1px 2px rgba(18, 63, 55, 0.02);
border: 1px solid rgba(2, 132, 199, 0.06);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.03);
}
.scene-info {
flex: 1;
@@ -342,7 +502,7 @@ button { cursor: pointer; }
}
.scene-info p {
margin: 3px 0 0;
color: #75847f;
color: #64748b;
font-size: 12px;
line-height: 1.5;
white-space: nowrap;
@@ -358,17 +518,17 @@ button { cursor: pointer; }
white-space: nowrap;
flex-shrink: 0;
padding: 7px 15px;
border: 1px solid rgba(12, 80, 69, 0.2);
border: 1px solid rgba(2, 132, 199, 0.2);
border-radius: 16px;
color: var(--green);
background: #edf7f3;
background: #f0f9ff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: .2s;
}
.scene-row button:active { background: var(--green); color: #fff; }
.scene-row button.secondary { color: #6e604f; border-color: rgba(110,96,79,.2); background: #f6f3ee; }
.scene-row button.secondary { color: #475569; border-color: #cbd5e1; background: #f8fafc; }
.scene-row button:disabled { cursor: not-allowed; opacity: .45; }
.scene-device-list {
@@ -383,11 +543,11 @@ button { cursor: pointer; }
gap: 6px;
padding: 6px 9px;
background: #fff;
border: 1px solid rgba(18, 63, 55, 0.06);
border: 1px solid rgba(2, 132, 199, 0.06);
border-radius: 10px;
min-width: 0;
box-sizing: border-box;
box-shadow: 0 1px 2px rgba(18, 63, 55, 0.02);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.02);
}
.scene-device-chip .dot {
width: 6px;
@@ -422,9 +582,9 @@ button { cursor: pointer; }
transition: all 0.2s;
}
.chip-toggle-btn.on {
background: #0c5045;
background: var(--primary);
color: #ffffff;
border-color: #0c5045;
border-color: var(--primary);
}
.chip-toggle-btn.disabled {
opacity: 0.45;
@@ -438,7 +598,7 @@ button { cursor: pointer; }
.scene-presets-panel {
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed rgba(18, 63, 55, 0.12);
border-top: 1px dashed rgba(2, 132, 199, 0.12);
}
.preset-panel-header {
display: flex;
@@ -462,13 +622,13 @@ button { cursor: pointer; }
}
.preset-card-item {
background: #ffffff;
border: 1px solid rgba(18, 63, 55, 0.06);
border: 1px solid rgba(2, 132, 199, 0.06);
border-radius: 10px;
padding: 8px 10px;
box-shadow: 0 1px 2px rgba(18, 63, 55, 0.02);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.02);
}
.preset-card-item.unavailable {
background: #f7f8f7;
background: #f8fafc;
opacity: 0.72;
}
.preset-card-top {
@@ -495,21 +655,21 @@ button { cursor: pointer; }
font-weight: 500;
padding: 1px 6px;
border-radius: 6px;
background: #edf7f3;
color: var(--green);
background: #f0f9ff;
color: var(--primary);
white-space: nowrap;
flex-shrink: 0;
}
.preset-detail {
margin: 3px 0 0 20px;
font-size: 11px;
color: #657470;
color: #64748b;
line-height: 1.4;
}
.preset-availability {
margin: 4px 0 0 20px;
font-size: 10px;
color: var(--green);
color: var(--primary);
}
.preset-availability.unavailable {
color: #9a6a38;
@@ -731,13 +891,13 @@ button { cursor: pointer; }
transition: all 0.2s;
}
.device-switch-pill.on {
background: #edf7f3;
border-color: rgba(16, 185, 129, 0.4);
color: var(--green);
background: #f0f9ff;
border-color: rgba(2, 132, 199, 0.4);
color: var(--primary);
}
.device-switch-pill.on .switch-knob {
background: #10b981;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
background: #0284c7;
box-shadow: 0 0 0 2px rgba(2, 132, 199, 0.2);
}
.device-switch-pill.disabled {
opacity: 0.45;
@@ -749,15 +909,15 @@ button { cursor: pointer; }
font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
border: 1px solid rgba(18, 63, 55, 0.15);
border: 1px solid rgba(2, 132, 199, 0.2);
background: #fff;
color: var(--green);
color: var(--primary);
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.sensor-sync-pill:active {
background: #edf7f3;
background: #f0f9ff;
}
.sensor-sync-pill:disabled {
opacity: 0.5;
@@ -771,16 +931,16 @@ button { cursor: pointer; }
border-radius: 10px;
white-space: nowrap;
}
.status-pill.green { background: #edf7f3; color: var(--green); }
.status-pill.green { background: #f0f9ff; color: var(--primary); }
.status-pill.yellow { background: #fef7ec; color: #b8860b; }
.status-pill.gray { background: #f1f3f2; color: #82908c; }
.entity-count { font-size: 10px; color: #9aa4a1; }
.medical-note { max-width: 760px; margin: 24px auto; color: #969e9b; font-size: 11px; text-align: center; }
.loading-card { color: #73817d; text-align: center; }
.medical-note { max-width: 760px; margin: 24px auto; color: #94a3b8; font-size: 11px; text-align: center; }
.loading-card { color: #64748b; text-align: center; }
@media (max-width: 520px) {
.brand-panel { padding: 42px 20px 42px; }
.auth-card { margin-top: -16px; }
.brand-panel { padding: 16px 4px 20px; }
.auth-card { margin-top: 0; }
.plan-heading { display: block; }
.plan-heading span { display: block; margin-top: 5px; text-align: left; }
.device-grid { grid-template-columns: 1fr; }
@@ -790,30 +950,32 @@ button { cursor: pointer; }
}
.dashboard-page {
padding: 16px 14px 84px !important;
padding: 20px var(--page-gutter, 16px) calc(88px + env(safe-area-inset-bottom, 0px));
background: linear-gradient(180deg, #bfe3fc 0%, #e0f2fe 22%, #f0f9ff 48%, #f8fafc 100%);
}
.dashboard-header {
margin: 0 auto 12px !important;
padding: 4px 4px 0 !important;
margin: 0 auto 20px;
padding: 4px 4px 0;
}
.dashboard-header h1 {
margin: 2px 0 3px !important;
font-size: 26px !important;
margin: 2px 0 6px;
font-size: 24px;
}
.dashboard-header > div > p:last-child {
margin: 0 !important;
font-size: 13px !important;
line-height: 1.4 !important;
margin: 0;
font-size: 12px;
line-height: 1.5;
}
/* 评估未填提醒卡片 */
.assessment-guide-banner {
background: linear-gradient(135deg, #b37318, #92580c);
color: #fff;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
color: #0f172a;
border-radius: 18px;
padding: 16px;
margin-bottom: 14px;
box-shadow: 0 6px 18px rgba(179, 115, 24, 0.22);
border: 1px solid rgba(2, 132, 199, 0.16);
box-shadow: 0 4px 14px rgba(2, 132, 199, 0.06);
cursor: pointer;
position: relative;
overflow: hidden;
@@ -825,18 +987,19 @@ button { cursor: pointer; }
bottom: -20px;
width: 100px;
height: 100px;
background: radial-gradient(circle, rgba(255,255,255,0.2) 0%, transparent 70%);
background: radial-gradient(circle, rgba(2, 132, 199, 0.08) 0%, transparent 70%);
border-radius: 50%;
}
.guide-badge {
display: inline-block;
background: rgba(255, 255, 255, 0.22);
color: #fff;
background: #ffffff;
color: #0284c7;
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
margin-bottom: 8px;
border: 1px solid rgba(2, 132, 199, 0.18);
}
.guide-body {
display: flex;
@@ -848,11 +1011,12 @@ button { cursor: pointer; }
margin: 0 0 4px;
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.guide-text p {
margin: 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.88);
color: #64748b;
line-height: 1.4;
}
.guide-btn {
@@ -860,22 +1024,22 @@ button { cursor: pointer; }
padding: 8px 14px;
border-radius: 16px;
border: 0;
background: #ffffff;
color: #92580c;
background: #0284c7;
color: #ffffff;
font-size: 12px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
box-shadow: 0 2px 8px rgba(2, 132, 199, 0.25);
}
/* 个性化舒睡启动参数卡片 */
.personalized-startup-card {
background: #ffffff;
border: 1px solid rgba(12, 80, 69, 0.1);
border: 1px solid rgba(2, 132, 199, 0.08);
border-radius: 20px;
padding: 18px 16px;
margin-bottom: 14px;
box-shadow: 0 4px 16px rgba(18, 63, 55, 0.04);
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.04);
}
.personalized-startup-card .section-title {
display: flex;
@@ -885,13 +1049,13 @@ button { cursor: pointer; }
.personalized-startup-card .section-title > span {
font-size: 17px;
font-weight: 700;
color: #143d36;
color: #0f172a;
letter-spacing: 0.01em;
}
.startup-card-desc {
margin: 0 0 14px;
font-size: 12px;
color: #72847e;
color: #64748b;
line-height: 1.55;
}
.startup-params-tags {
@@ -901,20 +1065,20 @@ button { cursor: pointer; }
margin-bottom: 14px;
}
.startup-param-badge {
background: #f7f6f2;
border: 1px solid rgba(18, 63, 55, 0.08);
background: #f8fafc;
border: 1px solid rgba(2, 132, 199, 0.08);
border-radius: 10px;
padding: 8px 10px;
}
.startup-param-badge .lbl {
display: block;
font-size: 10px;
color: #849690;
color: #64748b;
margin-bottom: 2px;
}
.startup-param-badge strong {
font-size: 12px;
color: #173d36;
color: #0f172a;
}
.startup-scene-actions {
display: grid;
@@ -934,21 +1098,23 @@ button { cursor: pointer; }
opacity: 0.45;
}
.startup-action-primary {
border: 1px solid #0c5045;
background: #0c5045;
border: none;
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%);
color: #ffffff;
box-shadow: 0 6px 16px -2px rgba(2, 132, 199, 0.35);
border-radius: 14px;
}
.startup-action-secondary {
border: 1px solid rgba(12, 80, 69, 0.2);
background: #f7f6f2;
color: #335b53;
border: 1px solid #e2e8f0;
background: #f8fafc;
color: #475569;
}
.detail-entry-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: #f8f7f2;
border: 1px solid rgba(18, 63, 55, 0.08);
background: #f8fafc;
border: 1px solid rgba(2, 132, 199, 0.06);
border-radius: 12px;
padding: 10px 12px;
margin-top: 12px;
@@ -956,7 +1122,7 @@ button { cursor: pointer; }
transition: all 0.2s;
}
.detail-entry-bar:active {
background: #f0eee7;
background: #f1f5f9;
}
.entry-left {
display: flex;
@@ -969,15 +1135,15 @@ button { cursor: pointer; }
.entry-left strong {
display: block;
font-size: 12px;
color: #173d36;
color: #0f172a;
}
.entry-left small {
display: block;
font-size: 11px;
color: #82928c;
color: #64748b;
}
.detail-entry-bar .arrow {
color: #a0ada7;
color: #94a3b8;
font-size: 14px;
}
@@ -986,8 +1152,8 @@ button { cursor: pointer; }
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #edf6f3, #e5f0ec);
border: 1px solid rgba(12, 80, 69, 0.16);
background: linear-gradient(135deg, #e0f2fe, #f0f9ff);
border: 1px solid rgba(2, 132, 199, 0.16);
border-radius: 14px;
padding: 12px 14px;
margin-top: 14px;
@@ -995,7 +1161,7 @@ button { cursor: pointer; }
transition: all 0.2s;
}
.all-control-banner:active {
background: #dbeef7;
background: #d4edff;
}
.ac-text {
display: flex;
@@ -1008,17 +1174,17 @@ button { cursor: pointer; }
.ac-text strong {
display: block;
font-size: 13px;
color: #0c5045;
color: #0284c7;
font-weight: 700;
}
.ac-text small {
display: block;
font-size: 11px;
color: #55756d;
color: #0284c7;
margin-top: 2px;
}
.ac-arrow {
font-size: 16px;
color: #0c5045;
color: #0284c7;
font-weight: 700;
}
+553
View File
@@ -0,0 +1,553 @@
.control-page {
background: linear-gradient(180deg, #bfe3fc 0%, #e0f2fe 22%, #f0f9ff 48%, #f8fafc 100%);
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(2, 132, 199, 0.08);
}
.control-page .back-btn {
display: flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: #0284c7;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.control-page .top-nav h2 {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.control-page .refresh-btn {
border: 0;
background: transparent;
color: #0284c7;
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: #ffffff;
color: #0f172a;
border-radius: 20px;
padding: 18px 16px;
margin-bottom: 14px;
border: 1px solid rgba(2, 132, 199, 0.08);
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.04);
}
.control-page .summary-info {
display: flex;
justify-content: space-around;
padding-bottom: 14px;
border-bottom: 1px solid #f1f5f9;
}
.control-page .stat-item {
text-align: center;
}
.control-page .stat-item .num {
display: block;
font-size: 22px;
font-weight: 700;
font-family: inherit;
color: #0f172a;
}
.control-page .stat-item .num.online {
color: #0284c7;
}
.control-page .stat-item .label {
font-size: 11px;
color: #64748b;
}
.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: #0284c7;
color: #fff;
border: 1px solid #0284c7;
box-shadow: 0 4px 12px rgba(2, 132, 199, 0.25);
}
.control-page .sleep-btn:active {
background: #0958d9;
}
.control-page .off-btn {
background: #f8fafc;
color: #475569;
border: 1px solid #e2e8f0;
}
.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(2, 132, 199, 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(2, 132, 199, 0.22);
background: #f0f9ff;
color: #0284c7;
}
.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(2, 132, 199, 0.18);
border-top-color: #0284c7;
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(2, 132, 199, 0.06);
box-shadow: 0 4px 16px rgba(2, 132, 199, 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(2, 132, 199, 0.06);
}
.control-page .speaker-actions {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.control-page .category-tag {
font-size: 10px;
color: #0284c7;
background: #f0f9ff;
padding: 2px 6px;
border-radius: 4px;
font-weight: 700;
}
.control-page .dev-info h3 {
margin: 4px 0 2px;
font-size: 15px;
color: #0f172a;
font-weight: 700;
}
.control-page .status-dot {
font-size: 11px;
color: #8c9e97;
}
.control-page .status-dot.online {
color: #0284c7;
font-weight: 600;
}
.control-page .text-refresh {
border: 0;
background: transparent;
color: #0284c7;
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: #0284c7;
font-weight: 700;
line-height: 1.2;
}
.control-page .p-tip {
font-size: 11px;
color: #64748b;
}
.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: #f8fafc;
border: 1px solid rgba(2, 132, 199, 0.1);
border-radius: 8px;
font-size: 12px;
color: #334155;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.control-page .btn-group button.active {
background: #0284c7;
color: #fff;
border-color: #0284c7;
font-weight: 700;
}
.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(2, 132, 199, 0.15);
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.control-page .mini-toggle.active {
background: #0284c7;
color: #fff;
border-color: #0284c7;
}
/* 传感器网格 */
.control-page .sensor-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.control-page .sensor-box {
background: #f8fafc;
border-radius: 12px;
padding: 10px 8px;
text-align: center;
}
.control-page .s-label {
display: block;
font-size: 11px;
color: #64748b;
margin-bottom: 4px;
}
.control-page .sensor-box strong {
font-size: 14px;
color: #0f172a;
}
.control-page .sensor-box small {
font-size: 10px;
color: #8c9e97;
}
.control-page .scale-note {
margin: 0;
font-size: 12px;
color: #64748b;
line-height: 1.6;
}
.control-page .tcm-guide-button {
width: 100%;
margin-top: 10px;
border: 1px solid rgba(2, 132, 199, 0.2);
border-radius: 12px;
background: #f0f9ff;
color: #0284c7;
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: #0f172a; 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: #64748b; font-size: 14px; cursor: pointer; }
.control-page .device-room-tabs button.active { color: #0284c7; font-weight: 700; border-bottom-color: #0284c7; }
.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(2, 132, 199, .06); border-radius: 20px; overflow: hidden; box-shadow: 0 3px 16px rgba(2, 132, 199, .025); }
.control-page .device-overview-tile.powered { border-color: rgba(2, 132, 199, 0.3); }
.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: #0f172a; 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: #0f172a; overflow-wrap: anywhere; }
.control-page .device-tile-status { display: flex; align-items: center; gap: 5px; margin-top: 7px; color: #64748b; 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: #10b981; }
.control-page .device-tile-metric { margin-top: 3px; color: #64748b; 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: #64748b; 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: #f8fafc; color: #849187; cursor: pointer; }
.control-page .device-tile-power.on { background: #f0f9ff; color: #0284c7; }
.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 #0284c7; outline-offset: -3px; }
.control-page .device-tile-skeleton { height: 252px; border-radius: 20px; background: #f9faf6; border: 1px solid rgba(2, 132, 199, .04); }
.control-page .device-empty-state { padding: 28px 20px; border-radius: 20px; background: #fff; text-align: center; color: #64748b; font-size: 13px; line-height: 1.7; }
.control-page .device-empty-state strong { display: block; color: #0f172a; 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: #0f172a; }
.control-page .device-information p { display: flex; justify-content: space-between; gap: 16px; margin: 10px 0 0; font-size: 12px; color: #64748b; }
.control-page .device-information p span:last-child { color: #0f172a; 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; }
}
+191
View File
@@ -0,0 +1,191 @@
/* Demo 的共用视觉层:现代科技蓝 / 纯白轻卡片,不改动设备参数或评分规则。 */
:root {
--page-gutter: 16px;
--surface-radius: 20px;
--surface-shadow: 0 4px 20px rgba(15, 23, 42, .04);
--muted-text: #64748b;
}
button, summary { -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
button:focus-visible, summary:focus-visible, a:focus-visible {
outline: 2px solid #0284c7;
outline-offset: 3px;
}
input:focus-visible, textarea:focus-visible, select:focus-visible {
outline: 2px solid rgba(2, 132, 199, .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, .dashboard-page, .assessment-page, .tcm-page, .profile-page) {
padding: 20px var(--page-gutter, 16px) calc(88px + env(safe-area-inset-bottom, 0px));
background: linear-gradient(180deg, #bfe3fc 0%, #e0f2fe 22%, #f0f9ff 48%, #f8fafc 100%);
min-height: 100vh;
box-sizing: border-box;
}
#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; color: #0284c7; }
#app .avatar-button { flex-shrink: 0; font-weight: 700; box-shadow: 0 2px 8px rgba(2, 132, 199, .1); background: #f0f9ff; color: #0284c7; border: 1.5px solid rgba(2, 132, 199, 0.2); }
#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: #f0f9ff; padding: 5px 10px; border-radius: 20px; color: #0284c7; 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: #f1f5f9; border-radius: 12px; color: #475569; font-size: 12px; line-height: 1.6; }
.scene-availability.unavailable { background: #fff7ed; color: #c2410c; }
#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: #f1f5f9; color: #475569; overflow-wrap: anywhere; }
.scene-feedback.fail { color: #dc2626; background: #fef2f2; }
.scene-feedback.success { color: #0284c7; background: #f0f9ff; }
.inline-link { border: 0; background: transparent; color: #0284c7; 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: #f8fafc; border: 1px solid rgba(2, 132, 199, 0.06); 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: #f0f9ff; color: #0284c7; border-radius: 10px; font-size: 23px; }
.startup-preset-details { border-top: 1px solid #e2e8f0; 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: #64748b; font-size: 12px; cursor: pointer; }
.startup-preset-details summary::-webkit-details-marker { display: none; }
.startup-preset-details summary::after { content: '+'; color: #94a3b8; font-size: 18px; }
.startup-preset-details[open] summary::after { content: '−'; }
#app .startup-param-badge { border: 0; padding: 12px; background: #f8fafc; }
#app .startup-param-badge .lbl { font-size: 11px; margin-bottom: 5px; color: #64748b; }
#app .startup-param-badge strong { line-height: 1.6; font-weight: 600; color: #0f172a; }
#app .assessment-guide-banner { background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); color: #0f172a; box-shadow: 0 4px 14px rgba(2, 132, 199, 0.08); border: 1px solid rgba(2, 132, 199, 0.16); padding: 16px; }
#app .guide-badge { background: #ffffff; color: #0284c7; border: 1px solid rgba(2, 132, 199, 0.2); }
#app .guide-text p { color: #64748b; font-size: 12px; line-height: 1.6; }
#app .guide-text h3 { font-size: 14px; color: #0f172a; }
#app .guide-btn { min-height: 42px; padding: 8px 14px; flex-shrink: 0; box-shadow: 0 2px 8px rgba(2, 132, 199, 0.25); color: #fff; background: #0284c7; }
#app .health-advice-card { padding: 20px 18px; border-radius: 20px; background: #ffffff; border: 1px solid rgba(2, 132, 199, 0.08); box-shadow: var(--surface-shadow); }
#app .health-advice-card .eyebrow { display: inline-block; background: #f0f9ff; color: #0284c7; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; letter-spacing: .04em; margin-bottom: 8px; }
#app .health-advice-card h2 { font-size: 20px; line-height: 1.4; color: #0f172a; margin: 4px 0 8px; }
#app .health-advice-card > p:not(.eyebrow) { font-size: 13px; line-height: 1.65; margin-bottom: 12px; color: #64748b; }
.health-advice-link { background: transparent; border: 0; color: #0284c7; font-size: 13px; font-weight: 600; min-height: 36px; padding: 0; display: inline-flex; align-items: center; }
#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: #94a3b8; 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: #0f172a; font-size: 16px; }
.page-loading-mark { display: block; width: 28px; height: 28px; margin: 0 auto 14px; border: 2px solid #bae6fd; border-top-color: #0284c7; border-radius: 50%; animation: page-spin .9s linear infinite; }
.retry-button { border: 1px solid rgba(2, 132, 199, 0.2); border-radius: 12px; background: #f0f9ff; color: #0284c7; 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: 0; padding-right: 0; }
#app .profile-header { padding: 4px 4px 20px; border-radius: 0; box-shadow: none; background: transparent; }
#app .avatar-box { width: 56px; height: 56px; flex-shrink: 0; font-size: 24px; background: #f0f9ff; color: #0284c7; border: 2px solid rgba(2, 132, 199, 0.2); box-shadow: 0 2px 10px rgba(2, 132, 199, 0.12); font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif; }
#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; color: #0f172a; }
#app .card-head :is(.status-indicator, .version-tag, .edit-btn) { flex-shrink: 0; }
#app .edit-btn { min-height: 36px; padding: 4px 0 4px 8px; color: #0284c7; }
#app .metric-item { padding: 12px 6px; min-width: 0; font-variant-numeric: tabular-nums; background: #f8fafc; }
#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; color: #64748b; }
#app .menu-item { width: 100%; background: #fff; border: 0; border-bottom: 1px solid #f1f5f9; text-align: left; font: inherit; padding: 15px 16px; gap: 12px; }
#app .menu-item:last-child { border-bottom: 0; }
#app .menu-item:active { background: #f8fafc; }
#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 strong { color: #0f172a; }
#app .menu-text small { font-size: 12px; color: #64748b; }
#app .logout-wrap { margin-top: 20px; }
#app .logout-btn { min-height: 48px; box-shadow: none; }
#app .assessment-header { padding: 4px 4px 16px; background: transparent; box-shadow: none; border-radius: 0; }
#app .assessment-header h1 { font-size: 25px; margin-bottom: 12px; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif; }
#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: 20px var(--page-gutter, 16px) calc(88px + env(safe-area-inset-bottom, 0px)); background: linear-gradient(180deg, #bfe3fc 0%, #e0f2fe 22%, #f0f9ff 48%, #f8fafc 100%); }
#app .tcm-header { padding: 4px 4px 16px; background: transparent; }
#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: #f8fafc; }
#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: #e2e8f0; accent-color: #0284c7; }
.assessment-progress::-webkit-progress-bar { background: #e2e8f0; border-radius: 4px; }
.assessment-progress::-webkit-progress-value { background: #0284c7; border-radius: 4px; }
.assessment-progress::-moz-progress-bar { background: #0284c7; border-radius: 4px; }
#app .auth-page { padding-bottom: env(safe-area-inset-bottom, 0px); }
#app .brand-panel { padding: 16px 4px 20px; border-radius: 0; background: transparent; }
#app .brand-panel h1 { font-size: 28px; line-height: 1.25; color: #0f172a; }
#app .brand-copy { font-size: 13px; line-height: 1.6; color: #475569; }
#app .feature-pill-row span { padding: 5px 12px; font-size: 12px; font-weight: 600; color: #0284c7; background: rgba(255, 255, 255, 0.85); border: 1px solid rgba(191, 219, 254, 0.75); }
#app .auth-card { padding: 24px 20px 28px; box-shadow: 0 12px 32px -4px rgba(15, 23, 42, 0.06), 0 4px 12px -2px rgba(15, 23, 42, 0.02); border: 1px solid rgba(226, 232, 240, 0.85); border-radius: 22px; }
#app .auth-heading h2 { font-size: 22px; line-height: 1.4; color: #0f172a; font-weight: 700; }
#app .auth-heading p { font-size: 13px; line-height: 1.6; color: #64748b; }
#app .auth-card .van-field { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 12px; }
#app .auth-card .van-field:focus-within { background: #ffffff; border-color: #0284c7; box-shadow: 0 0 0 3px rgba(2, 132, 199, 0.12); }
#app .auth-card .van-field__control { font-size: 15px; color: #0f172a; }
#app .auth-card .van-field__label { color: #334155; font-weight: 600; }
#app .auth-card .van-field__error-message { color: #ef4444; font-size: 12px; }
#app .auth-tab-switch button { min-height: 42px; }
#app .auth-tab-switch button.active { color: #0284c7; background: #ffffff; }
#app .auth-tab-switch button:disabled { opacity: .55; }
#app .privacy-note { color: #94a3b8; }
#app .app-tab-bar { box-shadow: 0 -2px 16px rgba(15, 23, 42, .04); }
#app .tab-item { margin: 0 4px; border-radius: 12px; }
#app .tab-item.active { background: transparent; }
#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) { 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: #64748b; }
#app .control-page .device-tile-metric { font-size: 11px; color: #64748b; }
#app .control-page .device-tile-footer { color: #94a3b8; }
#app .control-page .device-information { border: 1px solid rgba(2, 132, 199, .05); }
.device-detail-hint { margin: 0 0 14px; padding: 0 3px; font-size: 12px; color: #64748b; 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;
}
}
File diff suppressed because it is too large Load Diff
+250 -33
View File
@@ -4,42 +4,54 @@ import { useRouter } from 'vue-router'
import { Button as VanButton, Field as VanField, Form as VanForm, showToast } from 'vant'
import { healthApi } from '../services/health'
import { saveTokens } from '../services/auth'
import { usePageRequest } from '../composables/usePageRequest'
const router = useRouter()
const mode = ref<'login' | 'register'>('login')
const loading = ref(false)
const form = reactive({ phone: '', password: '', displayName: '' })
const title = computed(() => mode.value === 'login' ? '欢迎回来' : '开启专属健康档案')
const pageRequest = usePageRequest()
async function submit() {
if (loading.value) return
const requestMode = mode.value
const phone = form.phone.trim(), displayName = form.displayName.trim()
if (!/^1[3-9]\d{9}$/.test(phone)) { showToast('请输入正确的手机号'); return }
if (!form.password || (requestMode === 'register' && form.password.length < 8)) { showToast(requestMode === 'register' ? '注册密码至少 8 位' : '请输入密码'); return }
if (requestMode === 'register' && !displayName) { showToast('请输入姓名或称呼'); return }
const requestId = pageRequest.begin()
loading.value = true
try {
const output = mode.value === 'login'
? await healthApi.login({ phone: form.phone, password: form.password })
: await healthApi.register(form)
const output = requestMode === 'login'
? await healthApi.login({ phone, password: form.password })
: await healthApi.register({ phone, password: form.password, displayName })
if (!pageRequest.isCurrent(requestId)) return
saveTokens(output.accessToken, output.refreshToken)
showToast({ type: 'success', message: mode.value === 'login' ? '登录成功' : '注册成功' })
await router.replace(mode.value === 'login' ? '/dashboard' : '/assessment')
showToast({ type: 'success', message: requestMode === 'login' ? '登录成功' : '注册成功' })
await router.replace(requestMode === 'login' ? '/dashboard' : '/tcm-assessment')
} catch (error) {
showToast(error instanceof Error ? error.message : '操作失败')
if (pageRequest.isCurrent(requestId)) showToast(error instanceof Error ? error.message : '操作失败')
} finally {
loading.value = false
if (pageRequest.isCurrent(requestId)) loading.value = false
}
}
function onFailed(errorInfo: { errors?: Array<{ message?: string }> }) {
const first = errorInfo?.errors?.[0]?.message
if (first) showToast(first)
}
</script>
<template>
<main class="auth-page">
<section class="brand-panel">
<div class="brand-badge">
<span class="brand-badge-icon">常</span>
<span>一人一方 · 康养管理</span>
</div>
<p class="eyebrow">EVERGREEN HEALTH</p>
<h1>一人一方<br />健康与空间协同</h1>
<p class="brand-copy">以睡眠为切入点,融合五感空间与动态健康方案,开启日常健康管理。</p>
<div class="feature-pill-row">
<span>🌿 六维处方</span><span>🌙 五感微环境</span><span>📊 闭环调理</span>
<div class="banner-card-wrap">
<img
src="/header-banner.jpg"
alt="一人一方 · 康养管理 健康与空间协同"
class="banner-card-img"
/>
</div>
</section>
@@ -48,6 +60,7 @@ async function submit() {
<button
type="button"
:class="{ active: mode === 'login' }"
:aria-pressed="mode === 'login'" :disabled="loading"
@click="mode = 'login'"
>
账号登录
@@ -55,44 +68,72 @@ async function submit() {
<button
type="button"
:class="{ active: mode === 'register' }"
:aria-pressed="mode === 'register'" :disabled="loading"
@click="mode = 'register'"
>
注册建档
</button>
</div>
<div class="auth-heading">
<h2 style="font-size: 20px; font-weight: 700; color: #142e27; margin-bottom: 4px;">{{ title }}</h2>
<p style="color: #72847d; font-size: 13px; margin-bottom: 16px;">
{{ mode === 'login' ? '登录后查看今日健康方案与空间联动' : '第一步,填写信息创建您的专属健康档案' }}
<p class="auth-hint">
{{ mode === 'login' ? '登录后查看今日健康方案与智能空间联动' : '第一步,填写基本信息创建您的专属健康档案' }}
</p>
</div>
<van-form @submit="submit">
<van-form @submit="submit" @failed="onFailed">
<van-field
v-if="mode === 'register'"
v-model="form.displayName"
label="姓名"
placeholder="请输入姓名或称呼"
:disabled="loading" autocomplete="name" maxlength="30"
placeholder="请输入您的姓名或称呼"
:rules="[{ required: true, message: '请输入姓名' }]"
/>
>
<template #left-icon>
<span class="field-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
</template>
</van-field>
<van-field
v-model="form.phone"
type="tel"
label="手机号"
:disabled="loading" autocomplete="username" inputmode="tel"
maxlength="11"
placeholder="请输入 11 位手机号"
:rules="[{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' }]"
/>
>
<template #left-icon>
<span class="field-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</svg>
</span>
</template>
</van-field>
<van-field
v-model="form.password"
type="password"
label="密码"
placeholder="至少 8 位密码"
:rules="[{ required: true, message: '请输入至少 8 位密码' }]"
/>
<van-button block round color="#0c5045" native-type="submit" :loading="loading">
{{ mode === 'login' ? '登 录' : '创建档案并继续' }}
:disabled="loading" :autocomplete="mode === 'register' ? 'new-password' : 'current-password'"
:placeholder="mode === 'register' ? '设置至少 8 位登录密码' : '请输入登录密码'"
:rules="[{ required: true, message: '请输入密码' }, { validator: (value: string) => mode === 'login' || value.length >= 8, message: '注册密码至少 8 位' }]"
>
<template #left-icon>
<span class="field-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</span>
</template>
</van-field>
<van-button block round color="#0284c7" native-type="submit" :loading="loading" :disabled="loading">
{{ mode === 'login' ? '立即登录' : '创建档案并继续' }}
</van-button>
</van-form>
@@ -100,3 +141,179 @@ async function submit() {
</section>
</main>
</template>
<style scoped>
.auth-page {
min-height: 100vh;
background:
radial-gradient(circle at 85% 10%, rgba(178, 225, 255, 0.45) 0%, rgba(224, 242, 254, 0.25) 35%, transparent 70%),
linear-gradient(180deg, #bfe3fc 0%, #e0f2fe 25%, #f8fafc 55%, #f1f5f9 100%);
padding: 16px 16px 40px;
}
.brand-panel {
max-width: 440px;
margin: 0 auto 16px;
padding: 0;
}
/* 顶部完整 3D 科技健康守护卡片 */
.banner-card-wrap {
width: 100%;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 10px 28px -4px rgba(2, 132, 199, 0.15), 0 2px 8px rgba(15, 23, 42, 0.04);
background: transparent;
border: 1px solid rgba(186, 230, 253, 0.85);
display: block;
}
.banner-card-img {
width: 100%;
height: auto;
display: block;
object-fit: cover;
transition: transform 0.2s ease;
}
/* 登录白卡片 */
.auth-card {
position: relative;
width: 100%;
max-width: 440px;
margin: 0 auto 32px;
padding: 22px 20px 26px;
background: #ffffff;
border-radius: 24px;
border: 1px solid rgba(226, 232, 240, 0.85);
box-shadow: 0 12px 36px -4px rgba(15, 23, 42, 0.06), 0 4px 12px -2px rgba(15, 23, 42, 0.02);
}
.auth-tab-switch {
display: flex;
background: #f1f5f9;
border-radius: 12px;
padding: 4px;
margin-bottom: 14px;
}
.auth-tab-switch button {
flex: 1;
padding: 10px 0;
border: 0;
font-size: 14px;
font-weight: 600;
border-radius: 9px;
color: #64748b;
background: transparent;
cursor: pointer;
transition: all 0.2s ease;
}
.auth-tab-switch button.active {
background: #ffffff;
color: #0284c7;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
}
.auth-hint {
margin: 0 0 18px;
font-size: 12.5px;
color: #64748b;
line-height: 1.5;
text-align: center;
}
/* 现代化通栏输入框与左侧图标 */
:deep(.van-field) {
position: relative;
margin-bottom: 16px;
padding: 13px 14px;
background: #f8fafc !important;
border-radius: 14px;
border: 1px solid #e2e8f0;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease, margin-bottom 0.2s ease;
min-height: 50px;
display: flex;
align-items: center;
}
:deep(.van-field::after) {
display: none;
}
:deep(.van-field:focus-within) {
background: #ffffff !important;
border-color: #0284c7 !important;
box-shadow: 0 0 0 3px rgba(2, 132, 199, 0.15);
}
:deep(.van-field:focus-within .field-icon) {
color: #0284c7;
}
.field-icon {
display: grid;
place-items: center;
color: #94a3b8;
margin-right: 10px;
transition: color 0.2s ease;
}
:deep(.van-field__value) {
position: static;
overflow: visible;
}
:deep(.van-field__control) {
color: #0f172a;
font-weight: 500;
font-size: 15px;
}
:deep(.van-field__control::placeholder) {
color: #94a3b8;
font-weight: normal;
font-size: 14px;
}
:deep(.van-field__error-message) {
position: absolute;
top: calc(100% + 4px);
left: 14px;
font-size: 12px;
line-height: 1.4;
color: #ef4444;
font-weight: 500;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
z-index: 2;
}
:deep(.van-field__error-message::before) {
content: "⚠";
font-size: 11px;
}
:deep(.van-field:has(.van-field__error-message)),
:deep(.van-field--error) {
margin-bottom: 26px !important;
border-color: #ef4444 !important;
background-color: #fffafa !important;
}
:deep(.van-field:has(.van-field__error-message):focus-within),
:deep(.van-field--error:focus-within) {
border-color: #ef4444 !important;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12) !important;
}
:deep(.van-button--primary) {
margin-top: 18px;
height: 50px;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.02em;
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%) !important;
border: none !important;
border-radius: 14px !important;
box-shadow: 0 8px 20px -3px rgba(2, 132, 199, 0.32);
color: #ffffff !important;
transition: transform 0.1s;
}
:deep(.van-button--primary:active) {
transform: scale(0.985);
}
.privacy-note {
margin: 18px 0 0;
color: #94a3b8;
font-size: 11px;
line-height: 1.6;
text-align: center;
}
</style>
+90 -141
View File
@@ -1,71 +1,64 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button as VanButton, showToast } from 'vant'
import AppTabBar from '../components/AppTabBar.vue'
import { clearTokens } from '../services/auth'
import {
healthApi,
type Dashboard,
type HomeAssistantStatus
} from '../services/health'
import { useDeviceScenes } from '../composables/useDeviceScenes'
import { healthApi, type Dashboard, type HomeAssistantStatus } from '../services/health'
const router = useRouter()
const loading = ref(true)
const sceneRunning = ref<'sleep' | 'all-off'>()
const loadError = ref('')
const dashboard = ref<Dashboard>()
const homeAssistant = ref<HomeAssistantStatus>()
const actionFeedback = ref<{ type: 'success' | 'fail'; message: string }>()
let active = true, loadSequence = 0, statusSequence = 0
const { sceneRunning, executeScene } = useDeviceScenes({
hasConstitution: () => Boolean(dashboard.value?.tcmResult?.hasCompleted),
needsConstitution: () => { void router.push('/tcm-assessment') },
refresh: refreshDevices,
feedback: (type, message) => { if (active) actionFeedback.value = { type, message } },
})
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 11) return '早上好'
if (hour < 18) return '下午好'
return '晚上好'
return hour < 11 ? '早上好' : hour < 18 ? '下午好' : '晚上好'
})
const sceneAvailable = computed(() => homeAssistant.value?.sceneDevices?.available ?? 0)
const sceneHint = computed(() => {
if (!dashboard.value?.tcmResult?.hasCompleted) return '完成中医四诊、生成体质倾向后,即可一键开启。'
if (!homeAssistant.value?.connected) return '设备连接暂时不可用,可进入设备总览检查并重试。'
if (sceneAvailable.value === 0) return '当前没有可用场景设备,请在设备总览检查连接。'
return `${sceneAvailable.value} 台场景设备可用 · 根据室温调节空调`
})
const comfortReason = computed(() => dashboard.value?.environmentAdvice?.climateAdvice?.reason || '')
async function load() {
const sequence = ++loadSequence, statusRequest = ++statusSequence
loading.value = true
loadError.value = ''
try {
const [dashboardResult, homeAssistantResult] = await Promise.all([
healthApi.dashboard(),
healthApi.homeAssistantStatus().catch(() => undefined)
healthApi.dashboard(), healthApi.homeAssistantStatus().catch(() => undefined),
])
if (!active || sequence !== loadSequence) return
if (!dashboardResult) throw new Error('健康档案暂时无法读取,请重试')
dashboard.value = dashboardResult
homeAssistant.value = homeAssistantResult
if (statusRequest === statusSequence) homeAssistant.value = homeAssistantResult
} catch (error) {
showToast(error instanceof Error ? error.message : '加载失败')
if (active && sequence === loadSequence) loadError.value = error instanceof Error ? error.message : '加载失败,请重试'
} finally {
loading.value = false
if (active && sequence === loadSequence) loading.value = false
}
}
async function executeScene(scene: 'sleep' | 'all-off') {
if (scene === 'sleep' && !dashboard.value?.tcmResult?.hasCompleted) {
showToast('请先完成中医四诊,生成体质倾向')
router.push('/tcm-assessment')
return
}
sceneRunning.value = scene
async function refreshDevices() {
if (!active) return
const sequence = ++statusSequence
try {
const result = scene === 'sleep'
? await healthApi.executeSleepScene()
: await healthApi.executeAllOffScene()
showToast({ type: result.success ? 'success' : 'fail', message: result.message })
homeAssistant.value = await healthApi.homeAssistantStatus()
} catch (error) {
showToast(error instanceof Error ? error.message : '场景执行失败')
} finally {
sceneRunning.value = undefined
const result = await healthApi.homeAssistantStatus()
if (active && sequence === statusSequence) homeAssistant.value = result
} catch { /* 保留最后回读,不把请求失败显示成全部关闭。 */ }
}
}
function logout() {
clearTokens()
router.replace('/auth')
}
onMounted(load)
onUnmounted(() => { active = false; loadSequence++; statusSequence++ })
</script>
<template>
@@ -73,123 +66,79 @@ onMounted(load)
<header class="dashboard-header">
<div>
<p class="eyebrow">EVERGREEN · TODAY</p>
<h1>{{ greeting }},{{ dashboard?.user.displayName || '您好' }}</h1>
<h1>{{ greeting }},{{ dashboard?.user.displayName || '欢迎您' }}</h1>
<p>{{ new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' }).format(new Date()) }}</p>
</div>
<button class="avatar-button" type="button" @click="logout">
<button class="avatar-button" type="button" aria-label="打开我的健康档案" @click="router.push('/profile')">
{{ dashboard?.user.displayName?.slice(0, 1) || '常' }}
</button>
</header>
<section v-if="loading" class="section-card loading-card">正在读取今日健康方案…</section>
<template v-else>
<section class="hero-card">
<p class="eyebrow">今日健康建议</p>
<h2>{{ dashboard?.currentPlan?.title || '尚未完成健康评估' }}</h2>
<p>{{ dashboard?.currentPlan?.summary || '完成健康评估后,系统会整理您的基本情况并生成日常健康建议。' }}</p>
<van-button
v-if="!dashboard?.currentPlan"
round
color="#f1c37a"
text-color="#183b34"
@click="router.replace('/assessment')"
>
去健康评估
</van-button>
<section v-if="loading" class="section-card page-load-state" role="status" aria-live="polite">
<span class="page-loading-mark" aria-hidden="true"></span><p>正在读取健康档案与舒睡设备…</p>
</section>
<!-- 未完成评估提示横幅 -->
<section v-if="!dashboard?.hasCompletedAssessment" class="assessment-guide-banner" @click="router.replace('/assessment')">
<div class="guide-badge">重要提醒</div>
<div class="guide-body">
<div class="guide-text">
<h3>完善个人基本信息与健康评估</h3>
<p>尚未记录身体及睡眠质量、运动情况、饮食情况。健康评估仅用于了解基本情况;设备启动参数由中医四诊体质结果生成。</p>
</div>
<button class="guide-btn" type="button">去评估 &gt;</button>
</div>
<section v-else-if="loadError" class="section-card page-error-state" role="alert">
<h2>暂时无法读取首页</h2><p>{{ loadError }}</p>
<button class="retry-button" type="button" @click="load">重新加载</button>
</section>
<!-- 智能家居启动参数仅来源于中医四诊体质结果 -->
<template v-else-if="dashboard">
<section class="section-card personalized-startup-card">
<div class="section-title">
<span>体质专属舒睡启动参数</span>
<span>我的舒睡空间</span>
<span class="constitution-chip">{{ dashboard.tcmResult?.hasCompleted ? dashboard.tcmResult.constitutionName : '待生成体质' }}</span>
</div>
<p class="startup-card-desc">
{{ dashboard?.tcmResult?.hasCompleted
? `依据${dashboard.tcmResult.constitutionName}倾向生成基础预设;床垫加热看重庆天气,软硬度看 BMI。`
: '先完成中医四诊并生成体质倾向,才可一键开启专属场景。' }}
<a v-if="!dashboard?.tcmResult?.hasCompleted" href="/tcm-assessment" @click.prevent="router.push('/tcm-assessment')">去完成四诊 ›</a>
</p>
<div v-if="dashboard?.tcmResult?.hasCompleted" class="startup-params-tags">
<div class="startup-param-badge">
<span class="lbl">恒湿目标</span>
<strong>{{ dashboard?.sceneConfig?.targetHumidity || 55 }}% · {{ dashboard?.sceneConfig?.fanLevel || '1挡' }}</strong>
</div>
<div class="startup-param-badge">
<span class="lbl">助眠灯光</span>
<strong>50%渐暗至关闭 · {{ dashboard?.sceneConfig?.lightColorTemperatureKelvin || 2700 }}K</strong>
</div>
<div class="startup-param-badge">
<span class="lbl">助眠音箱</span>
<strong>{{ 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>
<p class="startup-card-desc">体质决定基础预设,身体档案与室内环境用于补充调整。</p>
<p class="scene-availability" :class="{ unavailable: !homeAssistant?.connected || sceneAvailable === 0 }">{{ sceneHint }}</p>
<div class="startup-scene-actions">
<button
class="startup-action-primary"
type="button"
:disabled="!dashboard?.tcmResult?.hasCompleted || !homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('sleep')"
>
{{ sceneRunning === 'sleep' ? '正在开启…' : '一键开启' }}
<button class="startup-action-primary" type="button"
:disabled="!dashboard.tcmResult?.hasCompleted || !homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('sleep')">{{ sceneRunning === 'sleep' ? '正在开启…' : '一键开启舒睡' }}</button>
<button class="startup-action-secondary" type="button" :disabled="sceneRunning === 'all-off'"
@click="executeScene('all-off')">{{ sceneRunning === 'all-off' ? '正在关闭…' : '一键关闭' }}</button>
</div>
<p v-if="sceneRunning || actionFeedback" class="scene-feedback" :class="actionFeedback?.type" role="status" aria-live="polite">
{{ sceneRunning ? sceneRunning === 'sleep' ? '正在开启设备;如需中止,可点击一键关闭。' : '正在关闭设备,请等待回读结果。' : actionFeedback?.message }}
</p>
<button v-if="!dashboard.tcmResult?.hasCompleted" class="inline-link" type="button" @click="router.push('/tcm-assessment')">去完成中医四诊 ›</button>
<button class="detail-entry-bar" type="button" @click="router.push('/device-control')">
<span class="entry-left"><span class="entry-symbol" aria-hidden="true">▦</span><span>
<strong>查看舒睡设备</strong><small>设备总览、快捷开关与完整参数</small>
</span></span><span class="arrow" aria-hidden="true">›</span>
</button>
<button
class="startup-action-secondary"
type="button"
:disabled="!homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('all-off')"
>
{{ sceneRunning === 'all-off' ? '正在关闭…' : '一键关闭' }}
<button class="detail-entry-bar" type="button" @click="router.push('/sleep-data')">
<span class="entry-left"><span class="entry-symbol" aria-hidden="true">☾</span><span>
<strong>睡眠数据</strong><small>最近读数与历史记录</small>
</span></span><span class="arrow" aria-hidden="true">›</span>
</button>
</div>
<div class="detail-entry-bar" @click="router.push('/device-control')">
<div class="entry-left">
<span class="icon">⚙️</span>
<div>
<strong>进入设备详情与独立参数调节</strong>
<small>显示每个设备在线状态,可单独开关与精细滑块调节</small>
<details v-if="dashboard.tcmResult?.hasCompleted" class="startup-preset-details">
<summary>查看体质启动预设</summary>
<div v-if="homeAssistant?.scenePresets?.length" class="startup-params-tags">
<div v-for="preset in homeAssistant.scenePresets" :key="`${preset.entityId}-${preset.action}`" class="startup-param-badge">
<span class="lbl">{{ preset.deviceName }}</span><strong>{{ preset.detail || preset.action }}</strong>
<small v-if="!preset.available">{{ preset.availabilityMessage }}</small>
</div>
</div>
<span class="arrow">&gt;</span>
</div>
<p v-else class="startup-card-desc">暂无可显示的设备预设。</p>
<p class="startup-card-desc">香型需人工更换。</p>
<p v-if="dashboard.sceneConfig?.enableUv" class="startup-card-desc">除湿机预设开启 UV 消杀。</p>
<p v-if="comfortReason" class="startup-card-desc">{{ comfortReason }}</p>
</details>
</section>
<section class="metric-strip">
<div><small>睡眠目标</small><strong>7–8<em>h</em></strong></div>
<div><small>活动目标</small><strong>30<em>min</em></strong></div>
<div><small>方案版本</small><strong>V{{ dashboard?.currentPlan?.version || 0 }}</strong></div>
<section v-if="!dashboard.hasCompletedAssessment" class="assessment-guide-banner">
<span class="guide-badge">完善档案</span>
<div class="guide-body"><div class="guide-text"><h3>补充身体指标与生活习惯</h3><p>健康评估记录基本情况,不生成另一套设备启动参数。</p></div>
<button class="guide-btn" type="button" @click="router.push('/assessment')">去评估 ›</button></div>
</section>
<p class="medical-note">健康建议仅用于日常管理演示,不构成医疗诊断或治疗意见。</p>
<section v-if="dashboard.currentPlan" class="hero-card health-advice-card">
<p class="eyebrow">日常健康建议</p><h2>{{ dashboard.currentPlan.title }}</h2><p>{{ dashboard.currentPlan.summary }}</p>
<button class="health-advice-link" type="button" @click="router.push('/profile')">查看我的健康档案 ›</button>
</section>
<section class="metric-strip" aria-label="日常参考与身体指标">
<div><small>参考睡眠目标</small><strong>7–8<em>h</em></strong></div>
<div><small>参考活动目标</small><strong>30<em>min</em></strong></div>
<div><small>身体 BMI</small><strong>{{ dashboard.bmi ?? '--' }}</strong></div>
</section>
<p class="medical-note">固定规则演示,不构成医疗诊断或治疗意见。</p>
</template>
<!-- 固定底部导航栏 -->
<AppTabBar />
</main>
</template>
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
import { provide } from 'vue'
import { useDeviceControl } from '../composables/useDeviceControl'
import { deviceControlKey } from '../devices/controlContext'
import '../styles/device-control.css'
// 总览与详情只创建一份控制上下文,子页面切换不重复加载或启动渐变轮询。
provide(deviceControlKey, useDeviceControl())
</script>
<template><router-view /></template>
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useDeviceControlContext } from '../devices/controlContext'
import { deviceCardFor } from '../devices/cardRegistry'
import DeviceOperationStatus from '../components/devices/DeviceOperationStatus.vue'
const route = useRoute()
const { router, loading, haStatus, operationBusy, loadStatus } = useDeviceControlContext()
const device = computed(() => haStatus.value?.devices.find(item => item.deviceId === route.params.deviceId))
const waiting = computed(() => !haStatus.value && loading.value)
const connected = computed(() => haStatus.value?.connected === true)
</script>
<template>
<div class="control-page device-detail-page">
<header class="top-nav">
<button class="back-btn" type="button" @click="router.replace('/device-control')">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="15 18 9 12 15 6" /></svg>
<span>全部设备</span>
</button>
<h2>{{ device?.name || '设备详情' }}</h2>
<button class="refresh-btn" type="button" :disabled="operationBusy" @click="loadStatus(true)">{{ loading ? '刷新中…' : '刷新' }}</button>
</header>
<main class="control-content">
<DeviceOperationStatus />
<section v-if="waiting" class="device-empty-state" role="status">正在读取设备详情…</section>
<section v-else-if="!connected" class="device-empty-state" role="alert">
<strong>暂时无法读取设备</strong><p>{{ haStatus?.message || '连接暂时不可用,请刷新后再试。' }}</p>
<button class="mini-toggle" type="button" :disabled="operationBusy" @click="loadStatus(true)">重新读取</button>
</section>
<template v-else-if="device">
<p class="device-detail-hint">灰色控件或 “--” 表示该能力当前不可用。<template v-if="device.moduleKey === 'audio'">音箱播放与音量通过独立网关控制。</template></p>
<div v-if="!device.online" class="device-connection-note">设备回读显示离线,操作可能无法完成;下方保留已有控制入口。</div>
<component :is="deviceCardFor(device)" :device="device" :key="device.deviceId" />
<section class="device-information" v-if="device.areaName || device.manufacturer || device.model">
<h3>设备信息</h3>
<p v-if="device.areaName"><span>所在房间</span><span>{{ device.areaName }}</span></p>
<p v-if="device.manufacturer"><span>品牌</span><span>{{ device.manufacturer }}</span></p>
<p v-if="device.model"><span>型号</span><span>{{ device.model }}</span></p>
</section>
</template>
<section v-else class="device-empty-state">
<strong>未找到这个设备</strong><p>设备可能已移除,或绑定信息已变更。返回设备总览重新选择即可。</p>
<button class="mini-toggle" type="button" @click="router.replace('/device-control')">返回设备总览</button>
</section>
</main>
</div>
</template>
+132 -136
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button as VanButton, showConfirmDialog, showToast } from 'vant'
import AppTabBar from '../components/AppTabBar.vue'
import { usePageRequest } from '../composables/usePageRequest'
import { ageFromBirthday } from '../services/formValidation'
import { clearTokens } from '../services/auth'
import { healthApi, type Dashboard, type HealthAssessmentOutput } from '../services/health'
@@ -10,34 +12,42 @@ const router = useRouter()
const loading = ref(true)
const dashboard = ref<Dashboard>()
const assessment = ref<HealthAssessmentOutput>()
const loadError = ref('')
const pageRequest = usePageRequest()
const logoutBusy = ref(false)
let active = true
async function loadData() {
const requestId = pageRequest.begin()
loading.value = true
loadError.value = ''
try {
const [dashRes, assessRes] = await Promise.all([
healthApi.dashboard(),
healthApi.getAssessment().catch(() => undefined)
healthApi.getAssessment()
])
if (!pageRequest.isCurrent(requestId)) return
if (!dashRes || !assessRes) throw new Error('健康档案暂时无法读取,请重试')
dashboard.value = dashRes
assessment.value = assessRes
} catch (err) {
showToast(err instanceof Error ? err.message : '加载失败')
if (pageRequest.isCurrent(requestId)) loadError.value = err instanceof Error ? err.message : '加载失败,请重试'
} finally {
loading.value = false
if (pageRequest.isCurrent(requestId)) loading.value = false
}
}
const bmiDisplay = computed(() => {
if (dashboard.value?.bmi) {
if (typeof dashboard.value?.bmi === 'number' && Number.isFinite(dashboard.value.bmi) && dashboard.value.bmi > 0) {
return {
val: dashboard.value.bmi,
status: dashboard.value.bmiStatus || '正常'
status: dashboard.value.bmiStatus || '未提供分类'
}
}
if (assessment.value?.bmi) {
if (typeof assessment.value?.bmi === 'number' && Number.isFinite(assessment.value.bmi) && assessment.value.bmi > 0) {
return {
val: assessment.value.bmi,
status: assessment.value.bmiStatus || '正常'
status: assessment.value.bmiStatus || '未提供分类'
}
}
return null
@@ -45,12 +55,7 @@ const bmiDisplay = computed(() => {
const ageDisplay = computed(() => {
const raw = dashboard.value?.profile?.birthday || assessment.value?.basicInfo.birthday
if (!raw) return undefined
const birthday = new Date(raw)
const today = new Date()
let age = today.getFullYear() - birthday.getFullYear()
if (today < new Date(today.getFullYear(), birthday.getMonth(), birthday.getDate())) age--
return age
return ageFromBirthday(raw)
})
// 健康评估只展示生活习惯画像;体质由中医四诊独立判定。
@@ -64,7 +69,7 @@ const profileTags = computed(() => {
list.push({ label: '夜间偏热感', desc: '自述夜间较容易感到闷热', type: 'blue' })
} else if (ans.temperaturePreference === 'cold_fear') {
list.push({ label: '夜间偏冷感', desc: '自述睡眠时较容易感到寒冷', type: 'gold' })
} else {
} else if (ans.temperaturePreference === 'normal') {
list.push({ label: '冷热感受适中', desc: '自述夜间温度感受较平稳', type: 'green' })
}
@@ -86,19 +91,23 @@ const profileTags = computed(() => {
})
function handleLogout() {
if (!active || logoutBusy.value) return
logoutBusy.value = true
showConfirmDialog({
title: '确认退出',
message: '确定要退出当前账号登录吗?'
}).then(() => {
if (!active) return
clearTokens()
showToast('已安全退出')
router.replace('/auth')
}).catch(() => {})
}).catch(() => {}).finally(() => { if (active) logoutBusy.value = false })
}
onMounted(() => {
loadData()
})
onUnmounted(() => { active = false })
</script>
<template>
@@ -110,7 +119,7 @@ onMounted(() => {
<span>{{ (dashboard?.user.displayName || '客').slice(0, 1) }}</span>
</div>
<div class="user-info">
<h2>{{ dashboard?.user.displayName || '尊贵会员' }}</h2>
<h2>{{ dashboard?.user.displayName || '我的健康档案' }}</h2>
<p class="phone-text">{{ dashboard?.user.phone || '健康管理账号' }}</p>
<span class="user-badge">常青树 · 智能健康睡眠会员</span>
</div>
@@ -118,17 +127,22 @@ onMounted(() => {
</header>
<main class="profile-content">
<section v-if="loading" class="profile-card page-load-state" role="status"><span class="page-loading-mark" aria-hidden="true"></span><p>正在读取您的健康档案…</p></section>
<section v-else-if="loadError" class="profile-card page-error-state" role="alert">
<h3>暂时无法读取健康档案</h3><p>{{ loadError }}</p><button class="retry-button" type="button" @click="loadData">重新加载</button>
</section>
<template v-else-if="dashboard">
<!-- 身体指标基线档案 -->
<section class="profile-card">
<div class="card-head">
<h3>身体基线档案</h3>
<button class="edit-btn" @click="router.replace('/assessment')">编辑指标</button>
<button class="edit-btn" type="button" @click="router.replace('/assessment')">编辑指标</button>
</div>
<div class="metrics-grid">
<div class="metric-item">
<span class="m-label">性别</span>
<strong class="m-val">{{ dashboard?.profile?.gender || assessment?.basicInfo.gender || '已保密' }}</strong>
<strong class="m-val">{{ dashboard?.profile?.gender || assessment?.basicInfo.gender || '未填写' }}</strong>
</div>
<div class="metric-item">
<span class="m-label">身高</span>
@@ -142,7 +156,7 @@ onMounted(() => {
<span class="m-label">体重</span>
<strong class="m-val">{{ dashboard?.profile?.weightKg || assessment?.basicInfo.weightKg || '--' }} <small>kg</small></strong>
</div>
<div class="metric-item">
<div class="metric-item bmi-metric">
<span class="m-label">BMI 指数</span>
<strong class="m-val" :class="{ highlight: bmiDisplay }">
{{ bmiDisplay?.val || '--' }}
@@ -155,7 +169,7 @@ onMounted(() => {
<!-- 专属评估画像标签 -->
<section class="profile-card">
<div class="card-head">
<h3>健康评估画像与专属策略</h3>
<h3>生活习惯画像</h3>
<span class="status-indicator" :class="{ done: assessment?.hasCompletedAssessment }">
{{ assessment?.hasCompletedAssessment ? '已完成评估' : '未评估' }}
</span>
@@ -179,7 +193,7 @@ onMounted(() => {
type="primary"
size="small"
round
color="#0c5045"
color="#0284c7"
@click="router.replace('/assessment')"
>
立即进行健康评估
@@ -200,7 +214,7 @@ onMounted(() => {
<p class="plan-summary-text">香型建议:{{ dashboard.tcmResult.aromaRecommendation }};音乐建议:{{ dashboard.tcmResult.musicRecommendation }}</p>
</template>
<p v-else class="plan-summary-text">完成四诊后,才能一键启动体质相关的设备预设。</p>
<VanButton type="primary" size="small" round color="#0c5045" @click="router.push('/tcm-assessment')">
<VanButton type="primary" size="small" round color="#0284c7" @click="router.push('/tcm-assessment')">
{{ dashboard?.tcmResult?.hasCompleted ? '重新评估体质' : '去完成四诊' }}
</VanButton>
</section>
@@ -208,8 +222,8 @@ onMounted(() => {
<!-- 当前健康方案 -->
<section class="profile-card" v-if="dashboard?.currentPlan">
<div class="card-head">
<h3>当前执行方案</h3>
<span class="version-tag">版本 v{{ dashboard.currentPlan.version }}.0</span>
<h3>日常健康建议</h3>
<span class="version-tag">版本 v{{ dashboard.currentPlan.version }}</span>
</div>
<p class="plan-summary-text">{{ dashboard.currentPlan.summary }}</p>
@@ -231,7 +245,7 @@ onMounted(() => {
<!-- 快捷功能入口与操作菜单 -->
<section class="action-card-menu">
<div class="menu-item" @click="router.replace('/assessment')">
<button class="menu-item" type="button" @click="router.replace('/assessment')">
<div class="menu-left">
<div class="menu-icon icon-assess">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
@@ -247,9 +261,9 @@ onMounted(() => {
</div>
</div>
<span class="arrow">&gt;</span>
</div>
</button>
<div class="menu-item" @click="router.push('/device-control')">
<button class="menu-item" type="button" @click="router.push('/device-control')">
<div class="menu-left">
<div class="menu-icon icon-device">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
@@ -263,16 +277,17 @@ onMounted(() => {
</div>
<div class="menu-text">
<strong>设备详情与参数调节</strong>
<small>查看7台设备状态、独立开关与精细参数设置</small>
<small>查看设备状态、快捷开关与完整参数</small>
</div>
</div>
<span class="arrow">&gt;</span>
</div>
</button>
</section>
</template>
<!-- 退出登录按钮 -->
<div class="logout-wrap">
<button class="logout-btn" @click="handleLogout">
<button class="logout-btn" type="button" :disabled="logoutBusy" @click="handleLogout">
退出登录
</button>
</div>
@@ -285,17 +300,20 @@ onMounted(() => {
<style scoped>
.profile-page {
padding-bottom: 90px;
background: #f2f0e9;
max-width: 480px;
margin: 0 auto;
padding: 20px var(--page-gutter, 16px) calc(88px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, #bfe3fc 0%, #e0f2fe 22%, #f0f9ff 48%, #f8fafc 100%);
min-height: 100vh;
box-sizing: border-box;
}
.profile-header {
background: linear-gradient(135deg, #0c5045, #123d36);
color: #fff;
padding: 34px 20px 28px;
border-radius: 0 0 32px 32px;
box-shadow: 0 10px 30px rgba(12, 80, 69, 0.12);
background: transparent;
color: #0f172a;
padding: 4px 4px 20px;
border-radius: 0;
box-shadow: none;
}
.user-row {
@@ -305,52 +323,54 @@ onMounted(() => {
}
.avatar-box {
width: 60px;
height: 60px;
width: 58px;
height: 58px;
border-radius: 50%;
background: linear-gradient(135deg, #d6a657, #b88636);
color: #fff;
background: #f0f9ff;
color: #0284c7;
display: grid;
place-items: center;
font-size: 26px;
font-family: serif;
font-size: 24px;
font-weight: 700;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 2px solid rgba(255, 255, 255, 0.4);
box-shadow: 0 2px 10px rgba(2, 132, 199, 0.12);
border: 2px solid #bae6fd;
}
.user-info h2 {
margin: 0 0 4px;
font-size: 20px;
font-weight: 700;
color: #0f172a;
}
.phone-text {
margin: 0 0 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
color: #64748b;
}
.user-badge {
display: inline-block;
font-size: 11px;
background: rgba(255, 255, 255, 0.15);
color: #ffe6ba;
background: #f0f9ff;
color: #0284c7;
border: 1px solid #bae6fd;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
}
.profile-content {
padding: 16px 14px;
padding: 0;
}
.profile-card {
background: #ffffff;
border-radius: 20px;
padding: 18px 16px;
border-radius: 16px;
padding: 20px 16px;
margin-bottom: 14px;
box-shadow: 0 4px 18px rgba(18, 63, 55, 0.04);
border: 1px solid rgba(18, 63, 55, 0.06);
box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f6;
}
.card-head {
@@ -363,14 +383,14 @@ onMounted(() => {
.card-head h3 {
margin: 0;
font-size: 15px;
color: #123f37;
color: #0f172a;
font-weight: 700;
}
.edit-btn {
border: 0;
background: transparent;
color: #0c5045;
color: #0284c7;
font-size: 12px;
font-weight: 600;
}
@@ -379,13 +399,14 @@ onMounted(() => {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: #f0f0ed;
color: #88948e;
background: #f1f5f9;
color: #64748b;
}
.status-indicator.done {
background: #eef6f3;
color: #0c5045;
background: #f0f9ff;
color: #0284c7;
border: 1px solid #bae6fd;
font-weight: 600;
}
@@ -396,7 +417,8 @@ onMounted(() => {
}
.metric-item {
background: #f8f7f3;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 10px 6px;
text-align: center;
@@ -407,30 +429,31 @@ onMounted(() => {
.m-label {
font-size: 11px;
color: #798a83;
color: #64748b;
margin-bottom: 4px;
}
.m-val {
font-size: 15px;
color: #173d36;
color: #0f172a;
font-weight: 700;
}
.m-val small {
font-size: 10px;
color: #8fa099;
color: #64748b;
}
.m-val.highlight {
color: #0c5045;
color: #0284c7;
font-size: 16px;
}
.m-status-tag {
font-size: 10px;
color: #0c5045;
background: rgba(12, 80, 69, 0.1);
color: #0284c7;
background: #f0f9ff;
border: 1px solid #bae6fd;
padding: 1px 6px;
border-radius: 8px;
margin-top: 4px;
@@ -451,40 +474,28 @@ onMounted(() => {
}
.profile-tag-item.theme-gold {
background: #fcf9f2;
border-color: rgba(214, 166, 87, 0.3);
}
.profile-tag-item.theme-gold .tag-title {
color: #9c6c19;
background: #fffbeb;
border-color: #fde68a;
}
.profile-tag-item.theme-gold .tag-title { color: #d97706; }
.profile-tag-item.theme-blue {
background: #f3f8fd;
border-color: rgba(66, 153, 225, 0.3);
}
.profile-tag-item.theme-blue .tag-title {
color: #2b6cb0;
background: #f0f9ff;
border-color: #bae6fd;
}
.profile-tag-item.theme-blue .tag-title { color: #0284c7; }
.profile-tag-item.theme-green {
background: #f1f8f5;
border-color: rgba(12, 80, 69, 0.2);
}
.profile-tag-item.theme-green .tag-title {
color: #0c5045;
background: #ecfdf5;
border-color: #a7f3d0;
}
.profile-tag-item.theme-green .tag-title { color: #10b981; }
.profile-tag-item.theme-purple {
background: #f8f4fd;
border-color: rgba(159, 122, 234, 0.3);
}
.profile-tag-item.theme-purple .tag-title {
color: #6b46c1;
background: #faf5ff;
border-color: #e9d5ff;
}
.profile-tag-item.theme-purple .tag-title { color: #9333ea; }
.tag-title {
font-size: 13px;
@@ -494,7 +505,7 @@ onMounted(() => {
.tag-desc {
font-size: 11px;
color: #60736c;
color: #64748b;
}
.empty-state {
@@ -505,15 +516,15 @@ onMounted(() => {
.empty-state p {
margin: 0 0 12px;
font-size: 12px;
color: #84968f;
color: #64748b;
line-height: 1.5;
}
/* 方案卡片 */
.version-tag {
font-size: 11px;
color: #0c5045;
background: #eef6f3;
color: #0284c7;
background: #f0f9ff;
border: 1px solid #bae6fd;
padding: 2px 8px;
border-radius: 8px;
font-weight: 600;
@@ -522,7 +533,7 @@ onMounted(() => {
.plan-summary-text {
margin: 0 0 14px;
font-size: 12px;
color: #556c64;
color: #475569;
line-height: 1.6;
}
@@ -533,7 +544,8 @@ onMounted(() => {
}
.plan-row {
background: #f9f8f4;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 10px 12px;
}
@@ -549,28 +561,28 @@ onMounted(() => {
width: 6px;
height: 6px;
border-radius: 50%;
background: #0c5045;
background: #0284c7;
}
.plan-row-head strong {
font-size: 13px;
color: #173e37;
color: #0f172a;
}
.plan-target, .plan-advice {
margin: 2px 0;
font-size: 12px;
color: #697c75;
color: #64748b;
line-height: 1.4;
}
/* 菜单导航 */
.action-card-menu {
background: #ffffff;
border-radius: 20px;
border-radius: 16px;
overflow: hidden;
margin-bottom: 16px;
border: 1px solid rgba(18, 63, 55, 0.06);
border: 1px solid #eef2f6;
box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.04);
}
.menu-item {
@@ -578,24 +590,15 @@ onMounted(() => {
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(18, 63, 55, 0.05);
border-bottom: 1px solid #f1f5f9;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-item:last-child { border-bottom: none; }
.menu-item:active { background: #f8fafc; }
.menu-item:active {
background: #f8f7f3;
}
.menu-left {
display: flex;
align-items: center;
gap: 12px;
}
.menu-left { display: flex; align-items: center; gap: 12px; }
.menu-icon {
width: 36px;
@@ -606,51 +609,44 @@ onMounted(() => {
}
.icon-assess {
background: #eef7f3;
color: #0c5045;
background: #f0f9ff;
color: #0284c7;
}
.icon-device {
background: #fbf5e8;
color: #b07e26;
background: #f0fdf4;
color: #16a34a;
}
.menu-text strong {
display: block;
font-size: 13px;
color: #183e37;
color: #0f172a;
}
.menu-text small {
display: block;
font-size: 11px;
color: #7b8e88;
color: #64748b;
margin-top: 2px;
}
.arrow {
color: #b3bfb9;
color: #94a3b8;
font-size: 14px;
}
.logout-wrap {
margin-top: 24px;
}
.logout-wrap { margin-top: 24px; }
.logout-btn {
width: 100%;
padding: 13px 0;
border-radius: 14px;
border: 1px solid rgba(220, 70, 70, 0.25);
background: #ffffff;
color: #d64545;
border: 1px solid #fecaca;
background: #fff5f5;
color: #ef4444;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
}
.logout-btn:active {
background: #fff5f5;
}
.logout-btn:active { background: #fee2e2; }
</style>
+248
View File
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import AppTabBar from '../components/AppTabBar.vue'
import Ib43AutomationCard from '../components/Ib43AutomationCard.vue'
import { usePageRequest } from '../composables/usePageRequest'
import { displayIb43Value, formatIb43Time, ib43Api, ib43Metrics,
type Ib43Bed, type Ib43Current, type Ib43HistoryItem, type Ib43HistoryQuery } from '../services/ib43'
const beds = ref<Ib43Bed[]>([])
const bedId = ref('')
const selectedBed = computed(() => beds.value.find(bed => bed.id === bedId.value))
const current = ref<Ib43Current>()
const history = ref<Ib43HistoryItem[]>([])
const historyDays = ref(7)
const nextBeforeId = ref<number | string>()
const bindingsLoading = ref(true)
const currentLoading = ref(false)
const historyLoading = ref(false)
const bindingsError = ref('')
const currentError = ref('')
const historyError = ref('')
const bindingsRequest = usePageRequest()
const currentRequest = usePageRequest()
const historyRequest = usePageRequest()
let historyRange: Pick<Ib43HistoryQuery, 'fromMs' | 'toMs'>
let refreshTimer: ReturnType<typeof setInterval>
function message(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback
}
function bedLabel(bed: Ib43Bed) {
const side = bed.side === 'left' ? '左侧' : '右侧'
const name = bed.bedName.endsWith(side) ? bed.bedName : `${bed.bedName} · ${side}`
return bed.endedAtMs == null ? name : `${name}(已解绑)`
}
const connectionLabel = computed(() => {
if (!current.value) return ''
if (!current.value.active) return '已解绑'
switch (current.value.transport) {
case 'connected': return '数据服务已连接'
case 'connecting': return '数据服务正在连接'
case 'disconnected': return '数据服务连接中断'
case 'disabled': return '数据采集未开启'
case 'unconfigured': return '设备未配置采集'
default: return '连接状态未知'
}
})
async function loadBindings() {
const requestId = bindingsRequest.begin()
currentRequest.begin()
historyRequest.begin()
bindingsLoading.value = true
bindingsError.value = ''
try {
const result = await ib43Api.bindings()
if (!bindingsRequest.isCurrent(requestId)) return
beds.value = result
bedId.value = result.find(bed => bed.id === bedId.value)?.id ??
result.find(bed => bed.endedAtMs == null)?.id ?? result[0]?.id ?? ''
if (bedId.value) loadBed()
} catch (error) {
if (bindingsRequest.isCurrent(requestId)) bindingsError.value = message(error, '暂时无法读取床位')
} finally {
if (bindingsRequest.isCurrent(requestId)) bindingsLoading.value = false
}
}
async function loadCurrent() {
const id = bedId.value
const requestId = currentRequest.begin()
currentLoading.value = true
currentError.value = ''
try {
const result = await ib43Api.current(id)
if (currentRequest.isCurrent(requestId)) current.value = result
} catch (error) {
if (currentRequest.isCurrent(requestId)) currentError.value = message(error, '暂时无法读取最近数据')
} finally {
if (currentRequest.isCurrent(requestId)) currentLoading.value = false
}
}
async function loadHistory(append = false) {
const id = bedId.value
const requestId = historyRequest.begin()
const beforeId = append ? nextBeforeId.value : undefined
if (!append) {
const toMs = Date.now() + 1
historyRange = { fromMs: toMs - historyDays.value * 86400000, toMs }
history.value = []
nextBeforeId.value = undefined
}
historyLoading.value = true
historyError.value = ''
try {
const result = await ib43Api.history(id, { ...historyRange, beforeId, take: 20 })
if (!historyRequest.isCurrent(requestId)) return
history.value = append ? [...history.value, ...result.items] : result.items
nextBeforeId.value = result.nextBeforeId ?? undefined
} catch (error) {
if (historyRequest.isCurrent(requestId)) historyError.value = message(error, '暂时无法读取历史记录')
} finally {
if (historyRequest.isCurrent(requestId)) historyLoading.value = false
}
}
function loadBed() {
current.value = undefined
void loadCurrent()
void loadHistory()
}
onMounted(() => {
void loadBindings()
refreshTimer = setInterval(() => {
if (!document.hidden && !bindingsLoading.value && !bindingsError.value && !currentLoading.value &&
selectedBed.value && selectedBed.value.endedAtMs == null) void loadCurrent()
}, 30000)
})
onUnmounted(() => clearInterval(refreshTimer))
</script>
<template>
<main class="page-shell sleep-data-page">
<header class="sleep-heading">
<div>
<RouterLink class="sleep-back" to="/dashboard">‹ 返回首页</RouterLink>
<h1>睡眠数据</h1>
</div>
<button class="sleep-button" type="button" :disabled="bindingsLoading" @click="loadBindings">刷新</button>
</header>
<section v-if="bindingsLoading" class="section-card page-load-state" role="status">
<span class="page-loading-mark" aria-hidden="true"></span><p>正在读取床位…</p>
</section>
<section v-else-if="bindingsError" class="section-card page-error-state" role="alert">
<h2>暂时无法读取床位</h2><p>{{ bindingsError }}</p>
<button class="retry-button" type="button" @click="loadBindings">重新加载</button>
</section>
<section v-else-if="!selectedBed" class="section-card page-load-state">
<h2>暂无绑定床位</h2><p>绑定床位后,这里会显示该侧的观测记录。</p>
</section>
<template v-else>
<section class="section-card sleep-bed" aria-label="当前床位">
<label v-if="beds.length > 1" for="sleep-bed">我的床位</label>
<span v-else class="sleep-muted">我的床位</span>
<select v-if="beds.length > 1" id="sleep-bed" v-model="bedId" @change="loadBed">
<option v-for="bed in beds" :key="bed.id" :value="bed.id">{{ bedLabel(bed) }}</option>
</select>
<strong v-else>{{ bedLabel(selectedBed) }}</strong>
<div class="sleep-bed-status">
<span v-if="selectedBed.simulation" class="sleep-chip">模拟设备</span>
<span v-if="connectionLabel">{{ connectionLabel }}</span>
</div>
</section>
<section class="section-card" aria-labelledby="sleep-current-title">
<div class="sleep-section-title"><h2 id="sleep-current-title">最近读数</h2></div>
<p v-if="currentLoading && !current" class="sleep-muted" role="status">正在读取最近读数…</p>
<div v-if="currentError" class="sleep-error" role="alert">
<p>{{ currentError }}</p>
<button class="sleep-button" type="button" :disabled="currentLoading" @click="loadCurrent">重试</button>
</div>
<dl v-if="current" class="sleep-metrics">
<div v-for="metric in ib43Metrics" :key="metric.key">
<dt>{{ metric.label }}</dt>
<dd class="sleep-metric-value">{{ displayIb43Value(metric.key, current.readings[metric.key]?.value) }}</dd>
<dd class="sleep-metric-meta">
<small>{{ formatIb43Time(current.readings[metric.key]?.receivedAt) || '暂无上报' }}</small>
<span v-if="current.readings[metric.key]?.freshness === 'stale'" class="sleep-chip stale">数据已过期</span>
<small v-else-if="current.readings[metric.key]?.receivedAt && current.readings[metric.key]?.value == null">暂无有效读数</small>
</dd>
</div>
</dl>
</section>
<Ib43AutomationCard v-if="selectedBed.simulation" :key="bedId" :binding-id="bedId" />
<section class="section-card" aria-labelledby="sleep-history-title">
<div class="sleep-section-title">
<h2 id="sleep-history-title">历史记录</h2>
<select v-model.number="historyDays" aria-label="历史时间范围" @change="loadHistory()">
<option :value="7">最近 7 天</option>
<option :value="1">最近 24 小时</option>
</select>
</div>
<p v-if="historyLoading && history.length === 0" class="sleep-muted" role="status">正在读取历史记录…</p>
<p v-else-if="!historyError && history.length === 0" class="sleep-muted">这段时间暂无记录。</p>
<ol v-if="history.length" class="sleep-history">
<li v-for="item in history" :key="item.id">
<time>{{ formatIb43Time(item.receivedAtMs) }}</time>
<dl>
<template v-for="metric in ib43Metrics" :key="metric.key">
<div v-if="item.readings[metric.key]">
<dt>{{ metric.label }}</dt>
<dd>{{ displayIb43Value(metric.key, item.readings[metric.key]?.value) }}</dd>
</div>
</template>
</dl>
</li>
</ol>
<div v-if="historyError" class="sleep-error" role="alert">
<p>{{ historyError }}</p>
<button class="sleep-button" type="button" @click="loadHistory(history.length > 0)">重试</button>
</div>
<button v-else-if="nextBeforeId != null" class="sleep-button sleep-more" type="button"
:disabled="historyLoading" @click="loadHistory(true)">{{ historyLoading ? '正在加载…' : '加载更多' }}</button>
</section>
</template>
<AppTabBar />
</main>
</template>
<style scoped>
.sleep-heading, .sleep-section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.sleep-heading { margin-bottom: 18px; }
.sleep-heading h1 { margin: 5px 0 0; font-size: 26px; line-height: 1.3; }
.sleep-back { display: inline-flex; align-items: center; min-height: 40px; color: var(--green); font-size: 13px; text-decoration: none; }
.sleep-section-title { margin-bottom: 14px; }
.sleep-data-page h2 { margin: 0; font-size: 17px; }
.sleep-button, .sleep-data-page select { min-height: 44px; border: 1px solid #bae6fd; border-radius: 12px; background: #f0f9ff; color: #0284c7; padding: 8px 12px; font: inherit; font-size: 13px; }
.sleep-button:disabled { opacity: .55; }
.sleep-bed { display: grid; gap: 8px; overflow-wrap: anywhere; }
.sleep-bed label, .sleep-bed-status, .sleep-muted, .sleep-metrics small { font-size: 13px; color: var(--muted-text); }
.sleep-bed-status { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.sleep-chip { display: inline-block; width: fit-content; padding: 4px 8px; border-radius: 8px; background: #f0f9ff; color: #0284c7; font-size: 12px; }
.sleep-chip.stale { background: #fef3c7; color: #92400e; }
.sleep-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; }
.sleep-metrics > div { display: grid; align-content: start; gap: 5px; padding: 12px; border-radius: 13px; background: #f8fafc; border: 1px solid #e2e8f0; }
.sleep-metrics dt, .sleep-history dt { color: var(--muted-text); font-size: 13px; }
.sleep-metrics dd { margin: 0; }
.sleep-metric-value { font-size: 22px; font-weight: 700; }
.sleep-metric-meta { display: grid; gap: 5px; }
.sleep-metrics small, .sleep-history time { font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.sleep-history { list-style: none; padding: 0; margin: 0; }
.sleep-history li { border-top: 1px solid #e2e8f0; padding: 12px 0; }
.sleep-history time { color: #0f172a; font-size: 13px; font-weight: 600; }
.sleep-history dl { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 8px 0 0; }
.sleep-history dl > div { display: flex; align-items: baseline; gap: 5px; }
.sleep-history dd { margin: 0; font-size: 14px; }
.sleep-more { width: 100%; margin-top: 8px; }
.sleep-error { color: #8b432f; font-size: 13px; padding: 8px 0; overflow-wrap: anywhere; }
.sleep-error p { margin: 0 0 8px; }
</style>
+91 -46
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { showFailToast, showSuccessToast } from 'vant'
import AppTabBar from '../components/AppTabBar.vue'
import { usePageRequest } from '../composables/usePageRequest'
import { healthApi, type TcmAssessmentResult } from '../services/health'
type Question = { key: string; title: string; options: { value: string; label: string }[] }
@@ -75,42 +76,70 @@ const notes = reactive<Record<string, string>>({ tongue: '', odor: '', breath: '
const result = ref<TcmAssessmentResult>()
const loading = ref(true)
const saving = ref(false)
const loadError = ref('')
const savedSnapshot = ref<string>()
const pageRequest = usePageRequest()
const questions = sections.flatMap(section => section.questions)
const answeredCount = computed(() => questions.filter(question => answers[question.key]).length)
const validAnswer = (question: Question) => question.options.some(option => option.value === answers[question.key])
const answeredCount = computed(() => questions.filter(validAnswer).length)
const formSnapshot = () => JSON.stringify({
answers: Object.fromEntries(questions.map(question => [question.key, answers[question.key] ?? ''])),
notes: Object.fromEntries(Object.keys(notes).sort().map(key => [key, notes[key] ?? '']))
})
const resultOutdated = computed(() => result.value?.hasCompleted && savedSnapshot.value !== formSnapshot())
onMounted(async () => {
async function loadExisting() {
if (saving.value) return
const requestId = pageRequest.begin()
loading.value = true
loadError.value = ''
try {
const saved = await healthApi.getTcmAssessment()
if (!pageRequest.isCurrent(requestId)) return
if (!saved) throw new Error('四诊记录读取失败,请重试')
if (saved.hasCompleted) {
result.value = saved
Object.assign(answers, saved.answers)
Object.assign(notes, saved.notes)
savedSnapshot.value = formSnapshot()
}
} catch (error) {
showFailToast(error instanceof Error ? error.message : '四诊记录加载失败')
if (!pageRequest.isCurrent(requestId)) return
loadError.value = error instanceof Error ? error.message : '四诊记录加载失败,请重试'
} finally {
loading.value = false
if (pageRequest.isCurrent(requestId)) loading.value = false
}
})
}
onMounted(loadExisting)
async function submit() {
const missing = questions.find(question => !answers[question.key])
if (loading.value || loadError.value || saving.value) return
const missing = questions.find(question => !validAnswer(question))
if (missing) {
showFailToast(`请先选择“${missing.title}”`)
document.getElementById(`tcm-${missing.key}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
const requestId = pageRequest.begin()
saving.value = true
try {
result.value = await healthApi.saveTcmAssessment({
const saved = await healthApi.saveTcmAssessment({
answers: { ...answers }, notes: { ...notes }
})
if (!pageRequest.isCurrent(requestId)) return
if (!saved?.hasCompleted) throw new Error('未生成完整体质结果,请重试')
result.value = saved
savedSnapshot.value = formSnapshot()
showSuccessToast('体质倾向已生成,下次一键启动将使用新参数')
await nextTick()
if (!pageRequest.isCurrent(requestId)) return
document.getElementById('tcm-result')?.scrollIntoView({ behavior: 'smooth' })
} catch (error) {
if (!pageRequest.isCurrent(requestId)) return
showFailToast(error instanceof Error ? error.message : '生成失败,请稍后重试')
} finally {
saving.value = false
if (pageRequest.isCurrent(requestId)) saving.value = false
}
}
</script>
@@ -121,11 +150,13 @@ async function submit() {
<p class="eyebrow">EVERGREEN · FOUR DIAGNOSES</p>
<h1>中医四诊</h1>
<p>填写望、闻、问、切的观察信息,生成九大体质中的演示倾向与家居参数。</p>
<div class="progress-row"><span>已完成 {{ answeredCount }}/{{ questions.length }} 项</span><span>{{ result?.hasCompleted ? '已生成结果' : '待生成' }}</span></div>
<div class="progress-row"><span>已完成 {{ answeredCount }}/{{ questions.length }} 项</span><span>{{ loading ? '读取中' : loadError ? '读取失败' : resultOutdated ? '修改待更新' : result?.hasCompleted ? '已生成结果' : '待生成' }}</span></div>
<progress class="assessment-progress" :value="answeredCount" :max="questions.length" aria-label="四诊必选项完成进度" />
</header>
<section v-if="result?.hasCompleted" id="tcm-result" class="tcm-card result-card">
<div class="result-top"><span>当前体质倾向</span><small>演示参考 · 非医学诊断</small></div>
<div class="result-top"><span>{{ resultOutdated ? '上次保存的体质倾向' : '当前体质倾向' }}</span><small>演示参考 · 非医学诊断</small></div>
<p v-if="resultOutdated" class="result-change-note" role="status">答案已修改,以下仍是上次保存的结果与参数。请点击重新评估,更新后才会用于一键启动。</p>
<h2>{{ result.constitutionName }}</h2>
<p v-if="result.secondaryName">次要倾向:{{ result.secondaryName }}</p>
<p>{{ result.summary }}</p>
@@ -134,17 +165,24 @@ async function submit() {
<div><span>音乐风格建议</span><strong>{{ result.musicRecommendation }}</strong></div>
<div><span>室温建议</span><strong>{{ result.roomTemperatureRecommendation }}</strong></div>
</div>
<p class="capability-note">香型需人工更换,音乐风格暂不能自动选择,室温仅供参考;一键启动只下发当前已支持的设备参数。</p>
<p class="capability-note">香型需人工更换,体质音乐可由音箱自动播放,室温仅供参考;一键启动只下发当前已支持的设备参数。</p>
<div class="parameter-preview">
<span>一键启动预设</span>
<p>湿度 {{ result.sceneConfig.targetHumidity }}% · 灯光 {{ result.sceneConfig.lightColorTemperatureKelvin }}K</p>
<p>香氛{{ result.sceneConfig.enableFragrance ? '开启' : '关闭' }} · 音箱睡眠模式 {{ result.sceneConfig.speakerVolumePercent }}% 音量</p>
<p>床垫加热按重庆冬季天气决定;软硬度按健康档案 BMI 给出可手动修改的演示起点。</p>
<p>目标湿度 {{ result.sceneConfig.targetHumidity }}%</p>
<p>香氛{{ result.sceneConfig.enableFragrance ? '开启' : '关闭' }} · 体质音乐循环播放,从 50% 每 5 秒降 1% 至停止</p>
<p>空调根据当前室温与体质建议区间决定制热或制冷;室温不可用时不自动开启,区间内保持当前运行状态。</p>
</div>
</section>
<div v-if="loading" class="tcm-card">正在读取四诊记录…</div>
<div v-if="loading" class="tcm-card page-load-state" role="status">正在读取四诊记录…</div>
<section v-else-if="loadError" class="tcm-card page-error-state" role="alert">
<h2>暂时无法读取四诊记录</h2>
<p>{{ loadError }}</p>
<p>为避免覆盖已保存的结果,请重试后再填写。</p>
<button class="retry-button" type="button" @click="loadExisting">重新加载</button>
</section>
<template v-else>
<fieldset class="tcm-form" :disabled="saving" :inert="saving || undefined" :aria-busy="saving">
<section v-for="section in sections" :key="section.title" class="tcm-card">
<div class="section-heading"><h2>{{ section.title }}</h2><p>{{ section.hint }}</p></div>
<div v-for="question in section.questions" :id="`tcm-${question.key}`" :key="question.key" class="question">
@@ -163,6 +201,7 @@ async function submit() {
</template>
<label v-if="section.title === '切诊'" class="note-label">切诊备注(选填)<textarea v-model="notes.pulseNote" maxlength="300" placeholder="如有专业测量结果可补充" /></label>
</section>
</fieldset>
<button class="submit-button" type="button" :disabled="saving" @click="submit">
{{ saving ? '正在生成…' : result?.hasCompleted ? '重新评估并更新参数' : '生成我的体质与家居参数' }}
</button>
@@ -173,37 +212,43 @@ async function submit() {
</template>
<style scoped>
.tcm-page { max-width: 480px; margin: 0 auto; padding: 18px 16px calc(100px + env(safe-area-inset-bottom)); color: #173d35; box-sizing: border-box; }
.tcm-header { padding: 18px 8px 20px; }
.eyebrow { color: #a37941; font-size: 11px; font-weight: 700; letter-spacing: .14em; }
.tcm-header h1 { font-size: 26px; margin: 5px 0 8px; }
.tcm-header > p:not(.eyebrow) { color: #71837b; font-size: 13px; line-height: 1.65; }
.progress-row { display: flex; justify-content: space-between; color: #527169; font-size: 12px; margin-top: 18px; }
.tcm-card { background: #fff; border: 1px solid #e9eee9; box-shadow: 0 8px 26px rgba(18, 62, 55, .06); border-radius: 20px; padding: 20px; margin-bottom: 15px; }
.section-heading { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; border-bottom: 1px solid #edf1ee; padding-bottom: 12px; }
.section-heading h2 { margin: 0; font-size: 19px; }
.section-heading p { margin: 0; color: #86958d; font-size: 11px; text-align: right; }
.question { padding: 15px 0; border-bottom: 1px solid #f0f2f0; scroll-margin-top: 80px; }
.question h3 { font-size: 14px; margin: 0 0 10px; }
.question small { color: #a88b5c; font-size: 10px; font-weight: 400; margin-left: 5px; }
.tcm-page { max-width: 480px; margin: 0 auto; padding: 20px var(--page-gutter, 16px) calc(88px + env(safe-area-inset-bottom)); color: #0f172a; box-sizing: border-box; background: linear-gradient(180deg, #bfe3fc 0%, #e0f2fe 22%, #f0f9ff 48%, #f8fafc 100%); min-height: 100vh; }
.tcm-header { padding: 4px 4px 16px; background: transparent; }
.eyebrow { color: #0284c7; font-size: 11px; font-weight: 700; letter-spacing: .08em; }
.tcm-header h1 { font-size: 26px; margin: 5px 0 8px; color: #0f172a; font-weight: 700; }
.tcm-header > p:not(.eyebrow) { color: #64748b; font-size: 13px; line-height: 1.65; }
.progress-row { display: flex; justify-content: space-between; color: #64748b; font-size: 12px; margin-top: 14px; }
.tcm-card { background: #ffffff; border: 1px solid #eef2f6; box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.04); border-radius: 16px; padding: 20px 16px; 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; color: #0f172a; }
.page-error-state p { color: #64748b; font-size: 13px; line-height: 1.6; }
.result-change-note { padding: 10px 12px; border-radius: 10px; background: #fffbeb; color: #b45309; border: 1px solid #fde68a; }
.section-heading { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; border-bottom: 1px solid #f1f5f9; padding-bottom: 12px; }
.section-heading h2 { margin: 0; font-size: 18px; color: #0f172a; font-weight: 700; }
.section-heading p { margin: 0; color: #64748b; font-size: 11px; text-align: right; }
.question { padding: 14px 0; border-bottom: 1px solid #f1f5f9; scroll-margin-top: 80px; }
.question h3 { font-size: 14px; margin: 0 0 10px; color: #0f172a; font-weight: 600; }
.question small { color: #d97706; font-size: 10px; font-weight: 600; margin-left: 5px; background: #fffbeb; padding: 1px 5px; border-radius: 4px; border: 1px solid #fde68a; }
.choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.choice-grid button { min-height: 39px; border: 1px solid #e4e9e5; border-radius: 11px; background: #f9fbf9; color: #496158; font-size: 13px; }
.choice-grid button.selected { border-color: #0c5045; background: #0c5045; color: #fff; font-weight: 700; }
.note-label { display: block; font-size: 13px; font-weight: 600; margin-top: 15px; }
.note-label textarea { display: block; width: 100%; min-height: 68px; resize: vertical; box-sizing: border-box; margin-top: 8px; border: 1px solid #e4e9e5; border-radius: 11px; padding: 10px; font: inherit; font-weight: 400; color: #173d35; }
.result-card { background: linear-gradient(150deg, #123e37, #0b5144); color: #fff; scroll-margin-top: 16px; }
.result-top { display: flex; justify-content: space-between; font-size: 12px; opacity: .8; }
.result-card h2 { font-size: 27px; margin: 14px 0 8px; }
.result-card p { font-size: 12px; line-height: 1.6; }
.choice-grid button { min-height: 42px; border: 1px solid #e2e8f0; border-radius: 12px; background: #f8fafc; color: #334155; font-size: 13px; font-weight: 500; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); }
.choice-grid button.selected { border-color: #0284c7; background: #f0f9ff; color: #0369a1; font-weight: 700; box-shadow: 0 2px 8px rgba(2, 132, 199, 0.08); }
.note-label { display: block; font-size: 12px; font-weight: 600; color: #334155; margin-top: 15px; }
.note-label textarea { display: block; width: 100%; min-height: 68px; resize: vertical; box-sizing: border-box; margin-top: 8px; border: 1px solid #e2e8f0; border-radius: 12px; padding: 10px 12px; font: inherit; font-weight: 400; color: #0f172a; background: #f8fafc; }
.note-label textarea:focus { border-color: #0284c7; background: #ffffff; outline: none; box-shadow: 0 0 0 3px rgba(2, 132, 199, 0.12); }
.result-card { background: #ffffff; color: #0f172a; border: 1px solid #bae6fd; box-shadow: 0 4px 20px -2px rgba(2, 132, 199, 0.08); border-radius: 16px; padding: 20px 16px; }
.result-top { display: flex; justify-content: space-between; font-size: 12px; color: #64748b; }
.result-card h2 { font-size: 26px; margin: 14px 0 8px; color: #0284c7; font-weight: 800; }
.result-card p { font-size: 12px; line-height: 1.6; color: #475569; }
.recommendations { display: grid; gap: 7px; margin: 16px 0; }
.recommendations div { display: flex; justify-content: space-between; gap: 10px; padding: 9px 0; border-bottom: 1px solid rgba(255,255,255,.17); font-size: 12px; }
.recommendations span { opacity: .7; }
.recommendations strong { text-align: right; }
.capability-note { opacity: .78; }
.parameter-preview { background: rgba(255,255,255,.1); border-radius: 12px; padding: 12px; margin-top: 16px; }
.parameter-preview span { font-size: 12px; font-weight: 700; }
.parameter-preview p { margin: 6px 0 0; }
.submit-button { width: 100%; min-height: 49px; border: 0; border-radius: 25px; background: #0c5045; color: #fff; font-weight: 700; font-size: 15px; }
.recommendations div { display: flex; justify-content: space-between; gap: 10px; padding: 9px 0; border-bottom: 1px solid #f1f5f9; font-size: 12px; }
.recommendations span { color: #64748b; }
.recommendations strong { color: #0f172a; text-align: right; }
.capability-note { color: #64748b; }
.parameter-preview { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 12px; padding: 12px; margin-top: 16px; }
.parameter-preview span { font-size: 12px; font-weight: 700; color: #0f172a; }
.parameter-preview p { margin: 6px 0 0; color: #64748b; }
.submit-button { width: 100%; min-height: 48px; border: 0; border-radius: 14px; background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%); color: #fff; font-weight: 700; font-size: 15px; box-shadow: 0 4px 14px rgba(2, 132, 199, 0.28); }
.submit-button:disabled { opacity: .55; }
.tcm-disclaimer { color: #8a9890; font-size: 11px; line-height: 1.6; text-align: center; margin: 14px 12px 0; }
.tcm-disclaimer { color: #94a3b8; font-size: 11px; line-height: 1.6; text-align: center; margin: 14px 12px 0; }
</style>
+2 -1
View File
@@ -6,7 +6,8 @@ export default defineConfig(({ mode }) => {
return {
plugins: [vue()],
server: {
port: 5173,
port: 5176,
strictPort: true,
proxy: {
'/api': {
target: env.VITE_API_BASE_URL || 'http://localhost:5005',