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