Commit b045a98f by James Yuan

feat: 照明免登控制,底栏分类与主视图锁定

标题栏切换两侧面板动画;设备分类改到底部;去掉自动曝光强制;启动多次补飞锁定「主视图」;照明开关接口匿名可写。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 29340069
...@@ -37,6 +37,22 @@ export function authRequired(req: Request, res: Response, next: NextFunction) { ...@@ -37,6 +37,22 @@ export function authRequired(req: Request, res: Response, next: NextFunction) {
} }
} }
/** 有 Token 则解析用户;无 Token / 无效 Token 仍放行(匿名) */
export function authOptional(req: Request, _res: Response, next: NextFunction) {
const header = req.headers.authorization
const token = header?.startsWith('Bearer ') ? header.slice(7) : undefined
if (!token) {
next()
return
}
try {
req.user = jwt.verify(token, config.jwtSecret) as AuthPayload
} catch {
/* ignore */
}
next()
}
export function requireRole(...roles: Role[]) { export function requireRole(...roles: Role[]) {
return (req: Request, res: Response, next: NextFunction) => { return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) { if (!req.user) {
......
import { Router } from 'express' import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js' import { authOptional, authRequired, requireRole } from '../auth/middleware.js'
import { applyWriteLog } from '../realtime/store.js' import { applyWriteLog } from '../realtime/store.js'
import { import {
createDevice, createDevice,
...@@ -128,19 +128,20 @@ lightRouter.delete('/devices/:id', authRequired, requireRole('admin'), (req, res ...@@ -128,19 +128,20 @@ lightRouter.delete('/devices/:id', authRequired, requireRole('admin'), (req, res
} }
}) })
lightRouter.post('/devices/:id/switch', authRequired, requireRole('admin'), (req, res) => { lightRouter.post('/devices/:id/switch', authOptional, (req, res) => {
try { try {
const powerOn = Boolean(req.body?.powerOn ?? req.body?.on) const powerOn = Boolean(req.body?.powerOn ?? req.body?.on)
if (req.body?.powerOn === undefined && req.body?.on === undefined) { if (req.body?.powerOn === undefined && req.body?.on === undefined) {
res.status(400).json({ message: '请指定 powerOn / on' }) res.status(400).json({ message: '请指定 powerOn / on' })
return return
} }
const device = switchDevice(req.params.id, powerOn, req.user!.username) const by = req.user?.username || 'screen'
const device = switchDevice(req.params.id, powerOn, by)
applyWriteLog('light_switch', { applyWriteLog('light_switch', {
deviceId: device.id, deviceId: device.id,
name: device.name, name: device.name,
powerOn, powerOn,
by: req.user!.username, by,
}) })
res.json({ res.json({
ok: true, ok: true,
...@@ -152,18 +153,19 @@ lightRouter.post('/devices/:id/switch', authRequired, requireRole('admin'), (req ...@@ -152,18 +153,19 @@ lightRouter.post('/devices/:id/switch', authRequired, requireRole('admin'), (req
} }
}) })
lightRouter.post('/switch-all', authRequired, requireRole('admin'), (req, res) => { lightRouter.post('/switch-all', authOptional, (req, res) => {
try { try {
const powerOn = Boolean(req.body?.powerOn ?? req.body?.on) const powerOn = Boolean(req.body?.powerOn ?? req.body?.on)
if (req.body?.powerOn === undefined && req.body?.on === undefined) { if (req.body?.powerOn === undefined && req.body?.on === undefined) {
res.status(400).json({ message: '请指定 powerOn / on' }) res.status(400).json({ message: '请指定 powerOn / on' })
return return
} }
const result = switchAll(powerOn, req.user!.username) const by = req.user?.username || 'screen'
const result = switchAll(powerOn, by)
applyWriteLog('light_switch_all', { applyWriteLog('light_switch_all', {
powerOn, powerOn,
count: result.count, count: result.count,
by: req.user!.username, by,
}) })
res.json({ res.json({
ok: true, ok: true,
......
import { Router } from 'express' import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js' import { authOptional, authRequired, requireRole } from '../auth/middleware.js'
import { applyPointTelemetry, listPoints } from '../devices/store.js' import { applyPointTelemetry, listPoints } from '../devices/store.js'
import { inferPointRw } from '../devices/pointRw.js' import { inferPointRw } from '../devices/pointRw.js'
import { gatewayModbusWrite } from '../gateway/client.js' import { gatewayModbusWrite } from '../gateway/client.js'
...@@ -111,8 +111,8 @@ sensorsRouter.delete('/:id', authRequired, requireRole('admin'), async (req, res ...@@ -111,8 +111,8 @@ sensorsRouter.delete('/:id', authRequired, requireRole('admin'), async (req, res
res.json({ ok: true }) res.json({ ok: true })
}) })
/** 照明等开关控制:写 status/value 绑定点位 */ /** 照明开关控制:无需登录(大屏可直接控);仅 lightN / lightE */
sensorsRouter.post('/:id/control', authRequired, requireRole('admin'), async (req, res) => { sensorsRouter.post('/:id/control', authOptional, async (req, res) => {
try { try {
const on = req.body?.on === true || req.body?.on === 1 || req.body?.on === '1' const on = req.body?.on === true || req.body?.on === 1 || req.body?.on === '1'
let sensor = await getSensor(req.params.id) let sensor = await getSensor(req.params.id)
...@@ -121,6 +121,10 @@ sensorsRouter.post('/:id/control', authRequired, requireRole('admin'), async (re ...@@ -121,6 +121,10 @@ sensorsRouter.post('/:id/control', authRequired, requireRole('admin'), async (re
res.status(404).json({ message: '设施不存在' }) res.status(404).json({ message: '设施不存在' })
return return
} }
if (sensor.category !== 'lightN' && sensor.category !== 'lightE') {
res.status(403).json({ message: '仅照明设施可匿名控制' })
return
}
const bind = const bind =
sensor.points.find((p) => p.role === 'status') || sensor.points.find((p) => p.role === 'status') ||
sensor.points.find((p) => p.role === 'value') || sensor.points.find((p) => p.role === 'value') ||
...@@ -156,7 +160,7 @@ sensorsRouter.post('/:id/control', authRequired, requireRole('admin'), async (re ...@@ -156,7 +160,7 @@ sensorsRouter.post('/:id/control', authRequired, requireRole('admin'), async (re
itemId: point.itemId, itemId: point.itemId,
value, value,
on, on,
by: req.user!.username, by: req.user?.username || 'screen',
gateway: result, gateway: result,
}) })
// 先落本地遥测,保证大屏开关有反馈 // 先落本地遥测,保证大屏开关有反馈
......
...@@ -359,10 +359,6 @@ function findLightSensor(marker: SceneMarkerHit, markerId: string | null): Senso ...@@ -359,10 +359,6 @@ function findLightSensor(marker: SceneMarkerHit, markerId: string | null): Senso
async function onLightControl(on: boolean) { async function onLightControl(on: boolean) {
if (!nearSensorId.value) return if (!nearSensorId.value) return
if (!auth.token) {
nearSwitchError.value = t('screen.loginToControl')
return
}
const sid = nearSensorId.value const sid = nearSensorId.value
const prevOn = liveSwitchOn.value const prevOn = liveSwitchOn.value
nearSwitching.value = true nearSwitching.value = true
...@@ -379,9 +375,10 @@ async function onLightControl(on: boolean) { ...@@ -379,9 +375,10 @@ async function onLightControl(on: boolean) {
}) })
window.setTimeout(() => syncLightEffects(), 0) window.setTimeout(() => syncLightEffects(), 0)
try { try {
// 照明控制无需登录
await api(`/api/sensors/${sid}/control`, { await api(`/api/sensors/${sid}/control`, {
method: 'POST', method: 'POST',
token: auth.token, token: auth.token || undefined,
body: JSON.stringify({ on }), body: JSON.stringify({ on }),
}) })
} catch (e) { } catch (e) {
...@@ -420,7 +417,7 @@ function scheduleMarkersAfterFly(rebuild = false) { ...@@ -420,7 +417,7 @@ function scheduleMarkersAfterFly(rebuild = false) {
}, 900) }, 900)
} }
/** 引擎就绪后:先飞「主视图」,再开 POI 打点(避免飞镜清点) */ /** 引擎就绪后:锁定「主视图」(覆盖引擎自动飞测序区),再开 POI 打点 */
async function onEngineReady() { async function onEngineReady() {
if (!useCloudFrame.value || engineReadyDone || engineBootstrapping) return if (!useCloudFrame.value || engineReadyDone || engineBootstrapping) return
engineBootstrapping = true engineBootstrapping = true
...@@ -431,12 +428,16 @@ async function onEngineReady() { ...@@ -431,12 +428,16 @@ async function onEngineReady() {
syncCameraMarkers(true) syncCameraMarkers(true)
applyCameraVisibility() applyCameraVisibility()
screenMode.setMode('pressure') screenMode.setMode('pressure')
// 先飞默认视点;等飞稳再开 POI,否则会被引擎清掉 // 锁定主视图并多次补飞,防止引擎稍后自动切到其它书签
flyToHome(true, false) await bridge.lockDefaultBookmark(DEFAULT_BOOKMARK)
await new Promise<void>((r) => window.setTimeout(r, 900)) await new Promise<void>((r) => window.setTimeout(r, 1100))
modePoisBootstrapped.value = true modePoisBootstrapped.value = true
await syncModePois() await syncModePois()
scheduleMarkersAfterFly(false) scheduleMarkersAfterFly(false)
// 末次补飞后再打一次点,避免长延迟飞镜清标
window.setTimeout(() => {
void syncModePois()
}, 4800)
window.setTimeout(() => { window.setTimeout(() => {
if (!markersSynced) syncCameraMarkers(true) if (!markersSynced) syncCameraMarkers(true)
}, 2000) }, 2000)
...@@ -831,14 +832,14 @@ onBeforeUnmount(() => { ...@@ -831,14 +832,14 @@ onBeforeUnmount(() => {
<span class="view">{{ modeLabel }}</span> <span class="view">{{ modeLabel }}</span>
</div> </div>
<!-- 全屏:右侧设备分类(只切图标) --> <!-- 全屏:底部设备分类(只切图标) -->
<Teleport v-if="props.layout === 'fill' && floatTargetReady" to="#screen-float-overlays" defer> <Teleport v-if="props.layout === 'fill' && floatTargetReady" to="#screen-float-overlays" defer>
<div class="vp-area-side" :aria-label="t('screen.deviceCategory')"> <div class="vp-area-bottom" :aria-label="t('screen.deviceCategory')">
<button <button
v-for="item in DEVICE_MENU" v-for="item in DEVICE_MENU"
:key="item.id" :key="item.id"
type="button" type="button"
class="vp-area-side-item" class="vp-area-bottom-item"
:class="{ active: item.id === activeMode }" :class="{ active: item.id === activeMode }"
@click="onSelectMode(item.id)" @click="onSelectMode(item.id)"
> >
...@@ -970,61 +971,66 @@ onBeforeUnmount(() => { ...@@ -970,61 +971,66 @@ onBeforeUnmount(() => {
overflow: hidden; overflow: hidden;
pointer-events: auto; pointer-events: auto;
} }
/* 右侧区域飞视(原底栏位置切换) */ /* 大屏底部设备分类 */
.vp-area-side { .vp-area-bottom {
position: absolute; position: absolute;
z-index: 8; z-index: 8;
/* 贴在右侧业务面板内侧,对应原 screen-nav--side 占位 */ left: 50%;
right: calc(var(--screen-side-w, 350px) + 16px); bottom: 18px;
top: 50%; transform: translateX(-50%);
transform: translateY(-50%);
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 6px; flex-wrap: nowrap;
max-height: min(72vh, 640px); align-items: center;
overflow-y: auto; justify-content: center;
overflow-x: hidden; gap: 8px;
padding: 4px; max-width: min(92vw, 1100px);
overflow-x: auto;
overflow-y: hidden;
padding: 6px 10px;
border-radius: 8px;
background: rgba(4, 14, 34, 0.72);
border: 1px solid rgba(91, 212, 255, 0.22);
backdrop-filter: blur(6px);
pointer-events: auto; pointer-events: auto;
} }
.vp-area-side-item { .vp-area-bottom-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: center;
gap: 6px; gap: 6px;
width: auto; flex: 0 0 auto;
min-width: 0; min-width: 0;
max-width: 108px; padding: 8px 12px;
padding: 7px 8px;
border-radius: 4px; border-radius: 4px;
border: 1px solid rgba(91, 212, 255, 0.32); border: 1px solid rgba(91, 212, 255, 0.32);
background: rgba(8, 28, 58, 0.9); background: rgba(8, 28, 58, 0.9);
color: rgba(235, 245, 255, 0.95); color: rgba(235, 245, 255, 0.95);
cursor: pointer; cursor: pointer;
font-size: 11px; font-size: 12px;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
} }
.vp-area-side-item :deep(.room-ico) { .vp-area-bottom-item :deep(.room-ico) {
width: 14px; width: 14px;
height: 14px; height: 14px;
flex: 0 0 auto; flex: 0 0 auto;
opacity: 0.95; opacity: 0.95;
} }
.vp-area-side-item .name { .vp-area-bottom-item .name {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.vp-area-side-item:hover { .vp-area-bottom-item:hover {
border-color: rgba(62, 200, 255, 0.5); border-color: rgba(62, 200, 255, 0.5);
background: rgba(30, 82, 150, 0.7); background: rgba(30, 82, 150, 0.7);
} }
.vp-area-side-item.active { .vp-area-bottom-item.active {
border-color: rgba(91, 212, 255, 0.7); border-color: rgba(91, 212, 255, 0.7);
background: rgba(26, 111, 255, 0.55); background: rgba(26, 111, 255, 0.55);
box-shadow: inset 0 0 0 1px rgba(91, 212, 255, 0.15); box-shadow: inset 0 0 0 1px rgba(91, 212, 255, 0.15);
} }
.vp-area-side-item.alarm .name { .vp-area-bottom-item.alarm .name {
color: #ffb0b0; color: #ffb0b0;
} }
......
...@@ -507,6 +507,13 @@ export function useRtengineBridge(opts: { ...@@ -507,6 +507,13 @@ export function useRtengineBridge(opts: {
/** 同一书签短时间内只飞一次。引擎过早调用会自己排队补飞,再重试就会连播 Changing */ /** 同一书签短时间内只飞一次。引擎过早调用会自己排队补飞,再重试就会连播 Changing */
const recentFlies = new Map<string, number>() const recentFlies = new Map<string, number>()
const FLY_DEDUPE_MS = 2500 const FLY_DEDUPE_MS = 2500
/** 启动后要钉住的默认书签(覆盖引擎自动飞到列表首项「测序区」) */
let lockedHomeId = ''
const homeLockTimers: number[] = []
function enqueueFly(id: string) {
if (!pendingFlies.includes(id)) pendingFlies.push(id)
}
function rememberFly(id: string) { function rememberFly(id: string) {
recentFlies.set(id, Date.now()) recentFlies.set(id, Date.now())
...@@ -570,14 +577,19 @@ export function useRtengineBridge(opts: { ...@@ -570,14 +577,19 @@ export function useRtengineBridge(opts: {
const ret = const ret =
Object.keys(rest).length > 0 ? gis.flyToBookmark(id, rest) : gis.flyToBookmark(id) Object.keys(rest).length > 0 ? gis.flyToBookmark(id, rest) : gis.flyToBookmark(id)
const queued = !!(ret && typeof ret === 'object' && (ret as { queued?: boolean }).queued) const queued = !!(ret && typeof ret === 'object' && (ret as { queued?: boolean }).queued)
if (queued) {
enqueueFly(id)
return
}
rememberFly(id) rememberFly(id)
if (queued) return
return return
} catch { } catch {
/* 直调失败走 postMessage */ /* 直调失败走 postMessage */
} }
} }
// 书签/通道未就绪:入队,等 bookmarksReady / markReady 再刷
if (!ready.value || !bookmarksEnsured) enqueueFly(id)
rememberFly(id) rememberFly(id)
const { force: _f, ...rest } = options || {} const { force: _f, ...rest } = options || {}
send( send(
...@@ -587,8 +599,35 @@ export function useRtengineBridge(opts: { ...@@ -587,8 +599,35 @@ export function useRtengineBridge(opts: {
} }
function flushPendingFlies() { function flushPendingFlies() {
// 优先钉住启动默认书签,避免 flush 旧队列把视角带走
if (lockedHomeId) enqueueFly(lockedHomeId)
const list = pendingFlies.splice(0, pendingFlies.length) const list = pendingFlies.splice(0, pendingFlies.length)
for (const id of list) flyToBookmark(id) for (const id of list) flyToBookmark(id, id === lockedHomeId ? { force: true } : undefined)
}
/**
* 启动锁定「主视图」:引擎常在就绪后自动飞列表第一项(测序区),
* 需 force + 多次补飞覆盖。
*/
async function lockDefaultBookmark(name: string) {
const id = String(name || '').trim()
if (!id) return
lockedHomeId = id
for (const t of homeLockTimers) window.clearTimeout(t)
homeLockTimers.length = 0
await ensureBookmarks().catch(() => undefined)
enqueueFly(id)
flyToBookmark(id, { force: true })
for (const ms of [600, 1400, 2600, 4200, 6500]) {
homeLockTimers.push(
window.setTimeout(() => {
if (lockedHomeId !== id) return
void ensureBookmarks().then(() => {
flyToBookmark(id, { force: true })
})
}, ms),
)
}
} }
function setPickMode(enabled: boolean) { function setPickMode(enabled: boolean) {
...@@ -994,18 +1033,6 @@ export function useRtengineBridge(opts: { ...@@ -994,18 +1033,6 @@ export function useRtengineBridge(opts: {
send('setGlobalConfig', cfg) send('setGlobalConfig', cfg)
} }
/**
* 任何时候保持自动曝光开启(禁止关闭)。
* 与场景 JSON sceneConfig.autoExposure / enableAutoExposure / eyeAdaptation 对齐。
*/
function ensureAutoExposure() {
setSceneConfig({
autoExposure: true,
enableAutoExposure: true,
eyeAdaptation: true,
})
}
function setLighting(opts: { function setLighting(opts: {
sunBrightness?: number sunBrightness?: number
ambientFill?: number ambientFill?: number
...@@ -1132,7 +1159,6 @@ export function useRtengineBridge(opts: { ...@@ -1132,7 +1159,6 @@ export function useRtengineBridge(opts: {
* 夜间默认「关太阳、空气清澈」,便于 spotlight 照亮房间。 * 夜间默认「关太阳、空气清澈」,便于 spotlight 照亮房间。
* opts.denseFog=true 才恢复旧的浓雾全黑(一般不用)。 * opts.denseFog=true 才恢复旧的浓雾全黑(一般不用)。
* 离开夜景:恢复场景初始环境,而不是假白天 12:00。 * 离开夜景:恢复场景初始环境,而不是假白天 12:00。
* 照明模式禁止再碰自动曝光(不关、也不反复 setSceneConfig 拨开关)。
*/ */
function applyDayNight(night: boolean, opts?: { denseFog?: boolean }) { function applyDayNight(night: boolean, opts?: { denseFog?: boolean }) {
if (night) { if (night) {
...@@ -1213,18 +1239,13 @@ export function useRtengineBridge(opts: { ...@@ -1213,18 +1239,13 @@ export function useRtengineBridge(opts: {
function markReady() { function markReady() {
suppressOrbitPivotHud() suppressOrbitPivotHud()
if (readyNotified) { if (readyNotified) {
ensureAutoExposure()
void ensureBookmarks().then(() => flushPendingFlies()) void ensureBookmarks().then(() => flushPendingFlies())
return return
} }
readyNotified = true readyNotified = true
ready.value = true ready.value = true
ensureAutoExposure()
// 引擎就绪后记下初始光线,供离开照明时还原 // 引擎就绪后记下初始光线,供离开照明时还原
window.setTimeout(() => captureEnvironmentLighting(), 800) window.setTimeout(() => captureEnvironmentLighting(), 800)
// 场景异步落盘后再钉一次,防止配置被场景文件覆盖成关曝光
window.setTimeout(() => ensureAutoExposure(), 1200)
window.setTimeout(() => ensureAutoExposure(), 3000)
window.setTimeout(() => suppressOrbitPivotHud(), 500) window.setTimeout(() => suppressOrbitPivotHud(), 500)
window.setTimeout(() => suppressOrbitPivotHud(), 2000) window.setTimeout(() => suppressOrbitPivotHud(), 2000)
opts.onReady?.() opts.onReady?.()
...@@ -1356,6 +1377,7 @@ export function useRtengineBridge(opts: { ...@@ -1356,6 +1377,7 @@ export function useRtengineBridge(opts: {
ready, ready,
send, send,
flyToBookmark, flyToBookmark,
lockDefaultBookmark,
setPickMode, setPickMode,
addMarker, addMarker,
addMarkers, addMarkers,
...@@ -1376,7 +1398,6 @@ export function useRtengineBridge(opts: { ...@@ -1376,7 +1398,6 @@ export function useRtengineBridge(opts: {
setWeather, setWeather,
setLighting, setLighting,
setSceneConfig, setSceneConfig,
ensureAutoExposure,
applyDayNight, applyDayNight,
captureEnvironmentLighting, captureEnvironmentLighting,
restoreInitialEnvironment, restoreInitialEnvironment,
......
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime' import { useRealtimeStore } from '@/stores/realtime'
import { useScreenUiStore } from '@/stores/screenUi'
import { useClock } from '@/composables/useClock' import { useClock } from '@/composables/useClock'
import { useMacauWeather } from '@/composables/useMacauWeather' import { useMacauWeather } from '@/composables/useMacauWeather'
import { useStageScale } from '@/composables/useStageScale' import { useStageScale } from '@/composables/useStageScale'
...@@ -14,6 +16,8 @@ import { useScreenTitle } from '@/composables/useScreenTitle' ...@@ -14,6 +16,8 @@ import { useScreenTitle } from '@/composables/useScreenTitle'
const auth = useAuthStore() const auth = useAuthStore()
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
const screenUi = useScreenUiStore()
const { sidePanelsVisible } = storeToRefs(screenUi)
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const { time, date } = useClock() const { time, date } = useClock()
...@@ -35,13 +39,18 @@ function goAdmin() { ...@@ -35,13 +39,18 @@ function goAdmin() {
else router.push({ path: '/login', query: { redirect: '/admin' } }) else router.push({ path: '/login', query: { redirect: '/admin' } })
} }
/** 标题栏:切换左右业务面板显隐 */
function onHeaderHome() { function onHeaderHome() {
sceneRef.value?.flyToHome?.(true) screenUi.toggleSidePanels()
} }
</script> </script>
<template> <template>
<div class="stage stage--immersive" :style="style"> <div
class="stage stage--immersive"
:class="{ 'side-panels-hidden': !sidePanelsVisible }"
:style="style"
>
<!-- 浮层挂载点需先于 SceneViewport,供 Teleport;z-index 高于 edge-mask --> <!-- 浮层挂载点需先于 SceneViewport,供 Teleport;z-index 高于 edge-mask -->
<div id="screen-float-overlays" class="screen-float-overlays" /> <div id="screen-float-overlays" class="screen-float-overlays" />
...@@ -137,6 +146,12 @@ function onHeaderHome() { ...@@ -137,6 +146,12 @@ function onHeaderHome() {
rgba(0, 10, 28, 0) 100% rgba(0, 10, 28, 0) 100%
); );
} }
.edge-l,
.edge-r {
transition:
opacity 0.45s ease,
transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
.edge-l { .edge-l {
left: 0; left: 0;
top: 0; top: 0;
...@@ -161,6 +176,14 @@ function onHeaderHome() { ...@@ -161,6 +176,14 @@ function onHeaderHome() {
rgba(0, 12, 32, 0) 100% rgba(0, 12, 32, 0) 100%
); );
} }
.side-panels-hidden .edge-l {
opacity: 0;
transform: translateX(-28px);
}
.side-panels-hidden .edge-r {
opacity: 0;
transform: translateX(28px);
}
.ui-layer { .ui-layer {
position: absolute; position: absolute;
inset: 0; inset: 0;
......
import { ref } from 'vue'
import { defineStore } from 'pinia'
/** 大屏 UI:两侧业务面板显隐等 */
export const useScreenUiStore = defineStore('screenUi', () => {
const sidePanelsVisible = ref(true)
function toggleSidePanels() {
sidePanelsVisible.value = !sidePanelsVisible.value
}
function setSidePanelsVisible(next: boolean) {
sidePanelsVisible.value = !!next
}
return { sidePanelsVisible, toggleSidePanels, setSidePanelsVisible }
})
...@@ -120,13 +120,12 @@ async function removeDevice(d: LightDevice) { ...@@ -120,13 +120,12 @@ async function removeDevice(d: LightDevice) {
} }
async function remoteSwitch(d: LightDevice, powerOn: boolean) { async function remoteSwitch(d: LightDevice, powerOn: boolean) {
if (!auth.isAdmin) return
switching.value = d.id switching.value = d.id
message.value = '' message.value = ''
try { try {
const r = await api<{ message: string; device: LightDevice }>(`/api/light/devices/${d.id}/switch`, { const r = await api<{ message: string; device: LightDevice }>(`/api/light/devices/${d.id}/switch`, {
method: 'POST', method: 'POST',
token: auth.token, token: auth.token || undefined,
body: JSON.stringify({ powerOn }), body: JSON.stringify({ powerOn }),
}) })
message.value = r.message message.value = r.message
...@@ -141,7 +140,6 @@ async function remoteSwitch(d: LightDevice, powerOn: boolean) { ...@@ -141,7 +140,6 @@ async function remoteSwitch(d: LightDevice, powerOn: boolean) {
} }
async function switchAll(powerOn: boolean) { async function switchAll(powerOn: boolean) {
if (!auth.isAdmin) return
const tip = powerOn ? t('light.allOn') : t('light.allOff') const tip = powerOn ? t('light.allOn') : t('light.allOff')
if (!confirm(t('light.confirmAll', { tip }))) return if (!confirm(t('light.confirmAll', { tip }))) return
switching.value = 'all' switching.value = 'all'
...@@ -149,7 +147,7 @@ async function switchAll(powerOn: boolean) { ...@@ -149,7 +147,7 @@ async function switchAll(powerOn: boolean) {
try { try {
const r = await api<{ message: string; devices: LightDevice[] }>('/api/light/switch-all', { const r = await api<{ message: string; devices: LightDevice[] }>('/api/light/switch-all', {
method: 'POST', method: 'POST',
token: auth.token, token: auth.token || undefined,
body: JSON.stringify({ powerOn }), body: JSON.stringify({ powerOn }),
}) })
devices.value = r.devices devices.value = r.devices
...@@ -236,14 +234,6 @@ onMounted(loadAll) ...@@ -236,14 +234,6 @@ onMounted(loadAll)
<section v-if="auth.isAdmin" class="panel"> <section v-if="auth.isAdmin" class="panel">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>{{ t('light.addTitle') }}</h3></div> <div class="panel-title"><div class="vbar" /><h3>{{ t('light.addTitle') }}</h3></div>
<div class="head-right">
<button type="button" class="btn ghost" :disabled="!!switching" @click="switchAll(true)">
{{ t('light.allOn') }}
</button>
<button type="button" class="btn ghost" :disabled="!!switching" @click="switchAll(false)">
{{ t('light.allOff') }}
</button>
</div>
</div> </div>
<div class="form-grid three"> <div class="form-grid three">
<div class="field"> <div class="field">
...@@ -271,7 +261,15 @@ onMounted(loadAll) ...@@ -271,7 +261,15 @@ onMounted(loadAll)
<section class="panel list-panel"> <section class="panel list-panel">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>{{ t('light.listTitle') }}</h3></div> <div class="panel-title"><div class="vbar" /><h3>{{ t('light.listTitle') }}</h3></div>
<div class="badge amber">{{ t('light.mockBadge') }}</div> <div class="head-right">
<button type="button" class="btn ghost" :disabled="!!switching" @click="switchAll(true)">
{{ t('light.allOn') }}
</button>
<button type="button" class="btn ghost" :disabled="!!switching" @click="switchAll(false)">
{{ t('light.allOff') }}
</button>
<span class="badge amber">{{ t('light.mockBadge') }}</span>
</div>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table class="table"> <table class="table">
...@@ -310,7 +308,7 @@ onMounted(loadAll) ...@@ -310,7 +308,7 @@ onMounted(loadAll)
type="button" type="button"
class="btn switch-btn" class="btn switch-btn"
:class="d.powerOn ? 'is-on' : 'is-off'" :class="d.powerOn ? 'is-on' : 'is-off'"
:disabled="!auth.isAdmin || !d.enabled || d.status === 'offline' || switching === d.id" :disabled="!d.enabled || d.status === 'offline' || switching === d.id"
@click="remoteSwitch(d, !d.powerOn)" @click="remoteSwitch(d, !d.powerOn)"
> >
{{ {{
......
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import PanelBox from '@/components/screen/PanelBox.vue' import PanelBox from '@/components/screen/PanelBox.vue'
import KpiFlipBoard, { type KpiFlipItem } from '@/components/screen/KpiFlipBoard.vue' import KpiFlipBoard, { type KpiFlipItem } from '@/components/screen/KpiFlipBoard.vue'
import EChart, { type ECOption } from '@/components/charts/EChart.vue' import EChart, { type ECOption } from '@/components/charts/EChart.vue'
import { useScreenDashboard } from '@/composables/useScreenDashboard' import { useScreenDashboard } from '@/composables/useScreenDashboard'
import { useRealtimeStore } from '@/stores/realtime' import { useRealtimeStore } from '@/stores/realtime'
import { useScreenUiStore } from '@/stores/screenUi'
import { multiLineOption, toHistorySeries } from '@/utils/historyChart' import { multiLineOption, toHistorySeries } from '@/utils/historyChart'
import { pieTooltip } from '@/utils/chartTooltip' import { pieTooltip } from '@/utils/chartTooltip'
const { t, te } = useI18n() const { t, te } = useI18n()
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
const { sidePanelsVisible } = storeToRefs(useScreenUiStore())
const { data, error } = useScreenDashboard(5000) const { data, error } = useScreenDashboard(5000)
const digits = computed(() => data.value?.digits) const digits = computed(() => data.value?.digits)
...@@ -228,12 +231,12 @@ const energyDigits = computed(() => digits.value) ...@@ -228,12 +231,12 @@ const energyDigits = computed(() => digits.value)
</script> </script>
<template> <template>
<div class="overview-float"> <div class="overview-float" :class="{ 'panels-collapsed': !sidePanelsVisible }">
<div class="kpi-top"> <div class="kpi-top">
<KpiFlipBoard :items="kpiItems" /> <KpiFlipBoard :items="kpiItems" />
</div> </div>
<div class="side left"> <div class="side left" :class="{ 'is-away': !sidePanelsVisible }">
<PanelBox :title="t('overview.envTitle')"> <PanelBox :title="t('overview.envTitle')">
<template #extra> <template #extra>
<span class="badge" :class="{ amber: (digits?.roomOk ?? 0) < (digits?.roomTotal ?? 0) }"> <span class="badge" :class="{ amber: (digits?.roomOk ?? 0) < (digits?.roomTotal ?? 0) }">
...@@ -290,7 +293,7 @@ const energyDigits = computed(() => digits.value) ...@@ -290,7 +293,7 @@ const energyDigits = computed(() => digits.value)
<div class="center-pass" aria-hidden="true" /> <div class="center-pass" aria-hidden="true" />
<div class="side right"> <div class="side right" :class="{ 'is-away': !sidePanelsVisible }">
<PanelBox :title="t('overview.systemTitle')"> <PanelBox :title="t('overview.systemTitle')">
<template #extra> <template #extra>
<span class="badge" :class="{ amber: !snap.hvac.systemRunning }"> <span class="badge" :class="{ amber: !snap.hvac.systemRunning }">
...@@ -412,6 +415,10 @@ const energyDigits = computed(() => digits.value) ...@@ -412,6 +415,10 @@ const energyDigits = computed(() => digits.value)
display: grid; display: grid;
gap: 10px; gap: 10px;
pointer-events: auto; pointer-events: auto;
will-change: transform, opacity;
transition:
opacity 0.4s ease,
transform 0.48s cubic-bezier(0.22, 1, 0.36, 1);
} }
.left, .left,
.right { .right {
...@@ -424,6 +431,16 @@ const energyDigits = computed(() => digits.value) ...@@ -424,6 +431,16 @@ const energyDigits = computed(() => digits.value)
.right { .right {
grid-column: 3; grid-column: 3;
} }
.left.is-away {
opacity: 0;
transform: translateX(calc(-100% - 28px));
pointer-events: none;
}
.right.is-away {
opacity: 0;
transform: translateX(calc(100% + 28px));
pointer-events: none;
}
.center-pass { .center-pass {
grid-column: 2; grid-column: 2;
grid-row: 2; grid-row: 2;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment