| | |
| | | <template> |
| | | <div class="camera-page"> |
| | | <div class="scroll-wrapper"> |
| | | <el-button class="arrow left" @click="scrollLeft" :disabled="canScrollLeft" circle> |
| | | <el-button class="arrow left" @click="scrollLeftManual" :disabled="canScrollLeft" circle> |
| | | <el-icon><ArrowLeft /></el-icon> |
| | | </el-button> |
| | | |
| | | <div class="scroll-viewport" ref="viewportRef"> |
| | | <div class="scroll-viewport" ref="viewportRef" @mouseenter="pauseAuto" @mouseleave="resumeAuto"> |
| | | <div class="scroll-track" :style="{ transform: `translateX(${-scrollPos}px)` }"> |
| | | <div v-for="dev in deviceList" :key="dev.id" class="player-card"> |
| | | <ezuikit-player |
| | | v-if="ysToken" |
| | | :key="'p_' + dev.id + '_' + ysToken" |
| | | :container-id="'player_' + dev.id" |
| | | :key="'p_' + dev.id + '_' + ysToken + '_' + playerRemountKey" |
| | | :container-id="'player_' + dev.id + '_' + playerRemountKey" |
| | | :access-token="ysToken" |
| | | :url="`ezopen://open.ys7.com/${dev.iotCode || dev.code}/1.hd.live`" |
| | | :pause-after-first-frame="true" |
| | |
| | | </div> |
| | | </div> |
| | | |
| | | <el-button class="arrow right" @click="scrollRight" :disabled="canScrollRight" circle> |
| | | <el-button class="arrow right" @click="scrollRightManual" :disabled="canScrollRight" circle> |
| | | <el-icon><ArrowRight /></el-icon> |
| | | </el-button> |
| | | </div> |
| | |
| | | const loading = ref(false); |
| | | const scrollPos = ref(0); |
| | | const viewportRef = ref<HTMLElement | null>(null); |
| | | /** 全屏切换后递增,强制重建萤石播放器 */ |
| | | const playerRemountKey = ref(0); |
| | | |
| | | // 每次滚动的距离(一个摄像头的宽度 + 间距) |
| | | function onScreenFullscreenChange() { |
| | | playerRemountKey.value += 1; |
| | | } |
| | | |
| | | // 自动滚动控制 |
| | | const AUTO_INTERVAL = 3000; // 每次滚动间隔(ms) |
| | | const autoPlay = ref(true); // 是否自动滚动 |
| | | let autoTimer: ReturnType<typeof setInterval> | null = null; |
| | | |
| | | // 每次滚动的距离 |
| | | const scrollStep = computed(() => { |
| | | const cardWidth = 312; // 300px 宽度 + 12px 间距的一半 |
| | | const cardWidth = 300; // 与 player-card 宽度一致 |
| | | const gap = 12; |
| | | return cardWidth + gap; |
| | | }); |
| | | |
| | | // 是否可以向左滚动 |
| | | const canScrollLeft = computed(() => scrollPos.value <= 0); |
| | | |
| | | // 是否可以向右滚动 |
| | | const canScrollRight = computed(() => { |
| | | if (!viewportRef.value) return true; |
| | | // 可滚动的最大距离 |
| | | const maxScroll = computed(() => { |
| | | if (!viewportRef.value) return 0; |
| | | const viewportWidth = viewportRef.value.clientWidth; |
| | | const trackWidth = deviceList.value.length * scrollStep.value; |
| | | // 增加 2px 容差 |
| | | return scrollPos.value >= trackWidth - viewportWidth - 2; |
| | | return Math.max(0, trackWidth - viewportWidth); |
| | | }); |
| | | |
| | | // 是否可以向左/向右滚动(手动箭头用) |
| | | const canScrollLeft = computed(() => scrollPos.value <= 0); |
| | | const canScrollRight = computed(() => scrollPos.value >= maxScroll.value - 2); |
| | | |
| | | // 自动滚动方向: 1 向右, -1 向左 |
| | | const direction = ref(1); |
| | | |
| | | function scrollLeft() { |
| | | scrollPos.value = Math.max(0, scrollPos.value - scrollStep.value); |
| | | } |
| | | |
| | | function scrollRight() { |
| | | if (!viewportRef.value) return; |
| | | const viewportWidth = viewportRef.value.clientWidth; |
| | | const trackWidth = deviceList.value.length * scrollStep.value; |
| | | const maxScroll = trackWidth - viewportWidth; |
| | | |
| | | // 如果剩余距离小于一个步长,直接滚动到底部 |
| | | if (scrollPos.value + scrollStep.value >= maxScroll) { |
| | | scrollPos.value = maxScroll; |
| | | const max = maxScroll.value; |
| | | if (scrollPos.value + scrollStep.value >= max) { |
| | | scrollPos.value = max; |
| | | } else { |
| | | scrollPos.value += scrollStep.value; |
| | | } |
| | | } |
| | | |
| | | // 执行一步自动滚动(到边界自动换向,回到起点循环) |
| | | function autoTick() { |
| | | if (deviceList.value.length === 0) return; |
| | | if (maxScroll.value <= 0) return; // 一屏装下全部,不滚动 |
| | | |
| | | scrollPos.value += scrollStep.value * direction.value; |
| | | |
| | | if (scrollPos.value >= maxScroll.value) { |
| | | scrollPos.value = maxScroll.value; |
| | | direction.value = -1; // 到最右,回头向左 |
| | | } else if (scrollPos.value <= 0) { |
| | | scrollPos.value = 0; |
| | | direction.value = 1; // 回到起点,循环向右 |
| | | pauseAuto(); // 回到起点停一下(可选,见下方 startAuto) |
| | | startAuto(); // 立即开始新一轮 |
| | | } |
| | | } |
| | | |
| | | function startAuto() { |
| | | stopAuto(); |
| | | if (!autoPlay.value) return; |
| | | autoTimer = setInterval(autoTick, AUTO_INTERVAL); |
| | | } |
| | | |
| | | function stopAuto() { |
| | | if (autoTimer) { |
| | | clearInterval(autoTimer); |
| | | autoTimer = null; |
| | | } |
| | | } |
| | | |
| | | // 鼠标悬停暂停,移开继续 |
| | | function pauseAuto() { |
| | | stopAuto(); |
| | | } |
| | | function resumeAuto() { |
| | | startAuto(); |
| | | } |
| | | |
| | | // 手动点击箭头:重置自动滚动定时器,避免与自动节奏冲突 |
| | | function scrollLeftManual() { |
| | | scrollLeft(); |
| | | startAuto(); |
| | | } |
| | | function scrollRightManual() { |
| | | scrollRight(); |
| | | startAuto(); |
| | | } |
| | | |
| | | async function fetchYsToken() { |
| | |
| | | const res = await service.basicdata.iot.getCameras(); |
| | | deviceList.value = (res || []) as any[]; |
| | | scrollPos.value = 0; |
| | | direction.value = 1; |
| | | // 数据加载完再启动自动滚动 |
| | | startAuto(); |
| | | } catch (e: any) { |
| | | console.error('获取摄像头列表失败', e?.response?.data?.message || e.message); |
| | | deviceList.value = []; |
| | |
| | | } |
| | | |
| | | async function refreshDevices() { |
| | | stopAuto(); |
| | | await fetchDevices(); |
| | | } |
| | | |
| | | function handleKeydown(e: KeyboardEvent) { |
| | | if (e.key === 'ArrowLeft') scrollLeft(); |
| | | if (e.key === 'ArrowRight') scrollRight(); |
| | | if (e.key === 'ArrowLeft') { scrollLeftManual(); } |
| | | if (e.key === 'ArrowRight') { scrollRightManual(); } |
| | | } |
| | | |
| | | onMounted(async () => { |
| | | window.addEventListener('keydown', handleKeydown); |
| | | window.addEventListener('report-screen-fullscreen-change', onScreenFullscreenChange); |
| | | await fetchYsToken(); |
| | | await fetchDevices(); |
| | | await fetchDevices(); // 内部会在数据就绪后 startAuto |
| | | }); |
| | | |
| | | onUnmounted(() => { |
| | | stopAuto(); |
| | | window.removeEventListener('keydown', handleKeydown); |
| | | window.removeEventListener('report-screen-fullscreen-change', onScreenFullscreenChange); |
| | | }); |
| | | </script> |
| | | |
| | |
| | | <template> |
| | | <div class="kpi-card" :class="{ 'is-alert': alert, 'is-demo': demo }"> |
| | | <div class="kpi-card__label"> |
| | | <span>{{ label }}</span> |
| | | <el-tag v-if="demo" class="kpi-card__badge" type="info" effect="plain" size="small">演示</el-tag> |
| | | </div> |
| | | <div class="kpi-card" :class="{ 'is-alert': alert }"> |
| | | <div class="kpi-card__label" :title="label">{{ label }}</div> |
| | | <div class="kpi-card__value"> |
| | | <cl-number :value="numeric" :fixed="fixed" type="amount" :suffix="suffix" /> |
| | | </div> |
| | | <div v-if="hint" class="kpi-card__hint">{{ hint }}</div> |
| | | <div class="kpi-card__hint" :title="hint || ''">{{ hint || '\u00A0' }}</div> |
| | | </div> |
| | | </template> |
| | | |
| | |
| | | fixed?: number; |
| | | hint?: string; |
| | | alert?: boolean; |
| | | /** @deprecated 首页不再展示演示标识,保留 prop 以免调用方报错 */ |
| | | demo?: boolean; |
| | | }>(); |
| | | |
| | |
| | | |
| | | <style lang="scss" scoped> |
| | | .kpi-card { |
| | | padding: 14px 16px; |
| | | box-sizing: border-box; |
| | | display: flex; |
| | | flex-direction: column; |
| | | width: 100%; |
| | | height: 112px; |
| | | padding: 12px 14px; |
| | | border-radius: 12px; |
| | | border: 1px solid var(--el-border-color-extra-light); |
| | | background: var(--el-bg-color); |
| | | color: var(--el-text-color-primary); |
| | | min-height: 96px; |
| | | overflow: hidden; |
| | | |
| | | &__label { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 6px; |
| | | flex-shrink: 0; |
| | | height: 20px; |
| | | line-height: 20px; |
| | | font-size: 13px; |
| | | color: var(--el-text-color-secondary); |
| | | } |
| | | |
| | | &__badge { |
| | | transform: scale(0.92); |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | &__value { |
| | | margin-top: 8px; |
| | | font-size: 26px; |
| | | flex: 1; |
| | | display: flex; |
| | | align-items: center; |
| | | min-height: 0; |
| | | margin: 4px 0; |
| | | font-size: 24px; |
| | | font-weight: 700; |
| | | line-height: 1.2; |
| | | line-height: 1.15; |
| | | color: var(--el-text-color-primary); |
| | | overflow: hidden; |
| | | |
| | | :deep(.cl-number) { |
| | | width: 100%; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | :deep(.cl-number__suffix) { |
| | | margin-left: 4px; |
| | |
| | | } |
| | | |
| | | &__hint { |
| | | margin-top: 8px; |
| | | flex-shrink: 0; |
| | | height: 18px; |
| | | line-height: 18px; |
| | | font-size: 12px; |
| | | color: var(--el-text-color-secondary); |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | &.is-alert { |
| | |
| | | .kpi-card__value { |
| | | color: var(--el-color-danger); |
| | | } |
| | | } |
| | | |
| | | &.is-demo:not(.is-alert) { |
| | | border-style: dashed; |
| | | } |
| | | } |
| | | </style> |
| New file |
| | |
| | | <template> |
| | | <div class="auto-scroll" @mouseenter="pause" @mouseleave="resume"> |
| | | <div v-if="$slots.head" class="auto-scroll__head"> |
| | | <slot name="head" /> |
| | | </div> |
| | | <div ref="viewportRef" class="auto-scroll__viewport"> |
| | | <div ref="contentRef" class="auto-scroll__content"> |
| | | <slot /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script lang="ts" setup> |
| | | defineOptions({ |
| | | name: 'report-screen-auto-scroll' |
| | | }); |
| | | |
| | | import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | /** 滚动步进像素 */ |
| | | step?: number; |
| | | /** 滚动间隔 ms */ |
| | | interval?: number; |
| | | /** 依赖变化时重新检测是否需要滚动(如列表数据) */ |
| | | watchKey?: string | number; |
| | | }>(), |
| | | { |
| | | step: 1, |
| | | interval: 40 |
| | | } |
| | | ); |
| | | |
| | | const viewportRef = ref<HTMLElement | null>(null); |
| | | const contentRef = ref<HTMLElement | null>(null); |
| | | const paused = ref(false); |
| | | const direction = ref(1); |
| | | let timer: ReturnType<typeof setInterval> | null = null; |
| | | let resizeObserver: ResizeObserver | null = null; |
| | | |
| | | function canScroll() { |
| | | const el = viewportRef.value; |
| | | if (!el) return false; |
| | | return el.scrollHeight > el.clientHeight + 2; |
| | | } |
| | | |
| | | function tick() { |
| | | const el = viewportRef.value; |
| | | if (!el || paused.value || !canScroll()) return; |
| | | |
| | | const max = el.scrollHeight - el.clientHeight; |
| | | let next = el.scrollTop + props.step * direction.value; |
| | | |
| | | if (next >= max) { |
| | | next = max; |
| | | direction.value = -1; |
| | | } else if (next <= 0) { |
| | | next = 0; |
| | | direction.value = 1; |
| | | } |
| | | |
| | | el.scrollTop = next; |
| | | } |
| | | |
| | | function start() { |
| | | stop(); |
| | | timer = setInterval(tick, props.interval); |
| | | } |
| | | |
| | | function stop() { |
| | | if (timer) { |
| | | clearInterval(timer); |
| | | timer = null; |
| | | } |
| | | } |
| | | |
| | | function pause() { |
| | | paused.value = true; |
| | | } |
| | | |
| | | function resume() { |
| | | paused.value = false; |
| | | } |
| | | |
| | | async function reset() { |
| | | await nextTick(); |
| | | const el = viewportRef.value; |
| | | if (el) el.scrollTop = 0; |
| | | direction.value = 1; |
| | | } |
| | | |
| | | onMounted(() => { |
| | | start(); |
| | | const el = viewportRef.value; |
| | | if (el && typeof ResizeObserver !== 'undefined') { |
| | | resizeObserver = new ResizeObserver(() => { |
| | | reset(); |
| | | }); |
| | | resizeObserver.observe(el); |
| | | if (contentRef.value) resizeObserver.observe(contentRef.value); |
| | | } |
| | | window.addEventListener('resize', reset); |
| | | window.addEventListener('report-screen-fullscreen-change', reset); |
| | | }); |
| | | |
| | | onUnmounted(() => { |
| | | stop(); |
| | | resizeObserver?.disconnect(); |
| | | window.removeEventListener('resize', reset); |
| | | window.removeEventListener('report-screen-fullscreen-change', reset); |
| | | }); |
| | | |
| | | watch( |
| | | () => props.watchKey, |
| | | () => reset() |
| | | ); |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .auto-scroll { |
| | | display: flex; |
| | | flex-direction: column; |
| | | height: 100%; |
| | | min-height: 0; |
| | | |
| | | &__head { |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | &__viewport { |
| | | flex: 1; |
| | | min-height: 0; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | &__content { |
| | | min-height: min-content; |
| | | } |
| | | } |
| | | </style> |
| | |
| | | <div class="screen-cameras__player"> |
| | | <ezuikit-player |
| | | v-if="ysToken" |
| | | :key="'screen_cam_' + dev.id + '_' + ysToken" |
| | | :container-id="'screen_cam_' + dev.id" |
| | | :key="'screen_cam_' + dev.id + '_' + ysToken + '_' + playerRemountKey" |
| | | :container-id="'screen_cam_' + dev.id + '_' + playerRemountKey" |
| | | :access-token="ysToken" |
| | | :url="cameraUrl(dev)" |
| | | :pause-after-first-frame="true" |
| | |
| | | name: 'report-screen-camera-preview' |
| | | }); |
| | | |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | import { computed, onMounted, onUnmounted, ref } from 'vue'; |
| | | import { useCool } from '/@/cool'; |
| | | import EzuikitPlayer from '../../ezuikit/components/ezuikit-player.vue'; |
| | | |
| | |
| | | const previewList = computed(() => deviceList.value.slice(0, maxCount.value)); |
| | | |
| | | const { service } = useCool(); |
| | | const playerRemountKey = ref(0); |
| | | |
| | | function onScreenFullscreenChange() { |
| | | playerRemountKey.value += 1; |
| | | } |
| | | |
| | | const deviceList = ref<any[]>([]); |
| | | const ysToken = ref(''); |
| | |
| | | } |
| | | |
| | | onMounted(async () => { |
| | | window.addEventListener('report-screen-fullscreen-change', onScreenFullscreenChange); |
| | | await fetchYsToken(); |
| | | await fetchDevices(); |
| | | }); |
| | | |
| | | onUnmounted(() => { |
| | | window.removeEventListener('report-screen-fullscreen-change', onScreenFullscreenChange); |
| | | }); |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | |
| | | <div class="brand"> |
| | | <div class="brand__mark" /> |
| | | <div> |
| | | <div class="brand__title">校园绿色运营驾驶舱</div> |
| | | <div class="brand__title">绿色校园运营驾驶舱</div> |
| | | <div class="brand__sub">{{ current?.audience }}视角 · {{ current?.label }}</div> |
| | | </div> |
| | | </div> |
| | |
| | | class="nav__item" |
| | | :class="{ 'is-active': item.key === currentKey }" |
| | | type="button" |
| | | @click="go(item.path)" |
| | | @click="go(item.key)" |
| | | > |
| | | <span class="nav__index">{{ item.index }}</span> |
| | | <span>{{ item.label }}</span> |
| | |
| | | </nav> |
| | | |
| | | <div class="tools"> |
| | | <el-tag v-if="loadError" type="danger" effect="plain" size="small">{{ loadError }}</el-tag> |
| | | <el-tag v-if="dataMode === 'demo'" type="info" effect="plain" size="small"> |
| | | 演示数据 · 待业务数据接入 |
| | | </el-tag> |
| | | <el-tag v-else-if="dataMode === 'mixed'" type="warning" effect="plain" size="small"> |
| | | 部分演示 · 部分指标待补数 |
| | | </el-tag> |
| | | <el-tag v-else-if="unavailable.length" type="warning" effect="plain" size="small"> |
| | | 部分指标待补数 |
| | | </el-tag> |
| | | <el-tag v-else type="success" effect="plain" size="small">实时数据</el-tag> |
| | | <cl-select-button v-model="timeRange" :options="TIME_RANGE_OPTIONS" small /> |
| | | <el-button :icon="Refresh" circle :loading="loading" @click="reload" /> |
| | | <el-button :icon="isFullscreen ? Close : FullScreen" circle @click="toggleFullscreen" /> |
| | |
| | | |
| | | <section v-loading="loading" class="green-screen__body"> |
| | | <slot /> |
| | | <el-collapse v-if="dataMode === 'mixed' && demoSections.length" class="missing"> |
| | | <el-collapse-item title="当前为演示数据的区块" name="demo"> |
| | | <ul> |
| | | <li v-for="item in demoSections" :key="item.section">{{ item.label }}</li> |
| | | </ul> |
| | | </el-collapse-item> |
| | | </el-collapse> |
| | | <el-collapse v-if="unavailable.length" class="missing"> |
| | | <el-collapse-item title="当前拿不到或口径待确认的指标" name="1"> |
| | | <ul> |
| | | <li v-for="item in unavailable" :key="item">{{ item }}</li> |
| | | </ul> |
| | | </el-collapse-item> |
| | | </el-collapse> |
| | | </section> |
| | | </div> |
| | | </template> |
| | |
| | | import { computed, onMounted, onUnmounted, ref } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | import { Close, FullScreen, Refresh } from '@element-plus/icons-vue'; |
| | | import { SCREEN_NAV, TIME_RANGE_OPTIONS, type ScreenKey } from '../data/screen'; |
| | | import { provideScreenTime, useScreenData, useScreenFieldSource } from '../hooks/use-screen'; |
| | | import { |
| | | SCREEN_HOME_PATH, |
| | | SCREEN_NAV, |
| | | TIME_RANGE_OPTIONS, |
| | | resolveScreenKey, |
| | | type ScreenKey |
| | | } from '../data/screen'; |
| | | import { provideScreenTime, useScreenData, useScreenFullscreen } from '../hooks/use-screen'; |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | const timeRange = provideScreenTime(); |
| | | const { data, dataMode, loading, loadError, reload } = useScreenData(); |
| | | const { demoSections } = useScreenFieldSource(); |
| | | const { loading, reload } = useScreenData(); |
| | | const { isFullscreen, setFullscreen } = useScreenFullscreen(); |
| | | const rootRef = ref<HTMLElement>(); |
| | | const isFullscreen = ref(false); |
| | | |
| | | const unavailable = computed(() => data.value.unavailable || []); |
| | | |
| | | const currentKey = computed<ScreenKey>(() => { |
| | | const page = String(route.params.page || 'overview') as ScreenKey; |
| | | return SCREEN_NAV.some(e => e.key === page) ? page : 'overview'; |
| | | }); |
| | | const currentKey = computed<ScreenKey>(() => |
| | | resolveScreenKey(route.params.page as string, route.query.page as string) |
| | | ); |
| | | |
| | | const current = computed(() => SCREEN_NAV.find(e => e.key === currentKey.value)); |
| | | |
| | | function go(path: string) { |
| | | if (path !== route.path) router.push(path); |
| | | /** 始终留在首页路径,用 ?page= 切换 pageMap,避免跳到 /overview 丢掉导航壳 */ |
| | | function go(key: ScreenKey) { |
| | | if (key === currentKey.value) return; |
| | | const query = { ...route.query } as Record<string, any>; |
| | | if (key === 'overview') { |
| | | delete query.page; |
| | | } else { |
| | | query.page = key; |
| | | } |
| | | router.replace({ path: SCREEN_HOME_PATH, query }); |
| | | } |
| | | |
| | | function syncFullscreen() { |
| | | isFullscreen.value = document.fullscreenElement === rootRef.value; |
| | | setFullscreen(document.fullscreenElement === rootRef.value); |
| | | // 全屏后触发布局重算,让 echarts / 子页 flex 高度铺满 |
| | | window.setTimeout(() => { |
| | | window.dispatchEvent(new Event('resize')); |
| | | }, 320); |
| | | } |
| | | |
| | | async function toggleFullscreen() { |
| | |
| | | |
| | | &.is-fullscreen { |
| | | padding: 12px; |
| | | height: 100vh; |
| | | min-height: 100vh; |
| | | background: #07111f; |
| | | |
| | |
| | | |
| | | .brand__sub { |
| | | color: #94a3b8; |
| | | } |
| | | |
| | | .green-screen__body { |
| | | display: flex; |
| | | flex-direction: column; |
| | | overflow: hidden; |
| | | |
| | | :deep(> *) { |
| | | flex: 1; |
| | | min-height: 0; |
| | | height: 100%; |
| | | } |
| | | |
| | | /* 02/03/04 等子页在全屏下纵向铺满 */ |
| | | :deep(.screen-page) { |
| | | height: 100%; |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 10px; |
| | | |
| | | .kpi-row, |
| | | .kpi-grid { |
| | | flex-shrink: 0; |
| | | margin-bottom: 0 !important; |
| | | } |
| | | |
| | | .mid-row, |
| | | .el-row:not(.kpi-row) { |
| | | flex: 1; |
| | | min-height: 0; |
| | | margin-bottom: 0 !important; |
| | | display: flex !important; |
| | | |
| | | > .el-col { |
| | | height: 100%; |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | } |
| | | |
| | | .panel-card { |
| | | flex: 1; |
| | | min-height: 0 !important; |
| | | height: 100%; |
| | | } |
| | | |
| | | .panel-card__body { |
| | | display: flex; |
| | | flex-direction: column; |
| | | flex: 1; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .chart, |
| | | .chart--bar, |
| | | .chart--value, |
| | | .chart--rank, |
| | | .flow, |
| | | .note { |
| | | flex: 1; |
| | | min-height: 0; |
| | | height: 100% !important; |
| | | } |
| | | |
| | | /* 全屏时强制刷新 echarts 容器高度 */ |
| | | .echarts, |
| | | [_echarts_instance_] { |
| | | height: 100% !important; |
| | | width: 100% !important; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | align-items: center; |
| | | gap: 10px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .missing { |
| | | margin: 8px 0 0; |
| | | border: none; |
| | | |
| | | ul { |
| | | margin: 0; |
| | | padding-left: 18px; |
| | | line-height: 1.8; |
| | | color: var(--el-text-color-regular); |
| | | font-size: 13px; |
| | | } |
| | | } |
| | | </style> |
| | |
| | | export type ScreenKey = 'overview' | 'resource' | 'carbon' | 'participation' | 'operation'; |
| | | export type RankMetric = 'weight' | 'people' | 'pointPerCapita' | 'weightPerCapita' | 'carbon'; |
| | | |
| | | export const SCREEN_NAV: { key: ScreenKey; path: string; index: string; label: string; audience: string }[] = [ |
| | | { key: 'overview', path: '/report/screen/overview', index: '01', label: '绿色校园总览', audience: '校领导' }, |
| | | { key: 'resource', path: '/report/screen/resource', index: '02', label: '垃圾资源化分析', audience: '后勤 / 环保' }, |
| | | { key: 'carbon', path: '/report/screen/carbon', index: '03', label: '碳减排与绿色贡献', audience: '双碳 / ESG' }, |
| | | { key: 'participation', path: '/report/screen/participation', index: '04', label: '师生参与与碳积分', audience: '学工 / 团委' }, |
| | | { key: 'operation', path: '/report/screen/operation', index: '05', label: '回收运营监控', audience: '运营公司' } |
| | | /** 首页菜单固定为 /report/screen/index,子页通过 ?page= 切换,避免离开首页入口 */ |
| | | export const SCREEN_HOME_PATH = '/report/screen/index'; |
| | | |
| | | export const SCREEN_NAV: { key: ScreenKey; index: string; label: string; audience: string }[] = [ |
| | | { key: 'overview', index: '01', label: '绿色校园总览', audience: '校领导' }, |
| | | { key: 'resource', index: '02', label: '垃圾资源化分析', audience: '后勤 / 环保' }, |
| | | { key: 'carbon', index: '03', label: '碳减排与绿色贡献', audience: '双碳 / ESG' }, |
| | | { key: 'participation', index: '04', label: '师生参与与碳积分', audience: '学工 / 团委' }, |
| | | { key: 'operation', index: '05', label: '回收运营监控', audience: '运营公司' } |
| | | ]; |
| | | |
| | | /** 从路由解析当前大屏子页;index / 空 / 非法值均回落到 overview */ |
| | | export function resolveScreenKey(page?: string | string[] | null, queryPage?: string | string[] | null): ScreenKey { |
| | | const raw = String(queryPage || page || 'overview'); |
| | | if (raw === 'index') return 'overview'; |
| | | return SCREEN_NAV.some(e => e.key === raw) ? (raw as ScreenKey) : 'overview'; |
| | | } |
| | | |
| | | export const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [ |
| | | { label: '今日', value: 'today' }, |
| | | { label: '本周', value: 'week' }, |
| | |
| | | import { useTheme } from '/#/theme'; |
| | | import { useCool } from '/@/cool'; |
| | | import { |
| | | groupDemoFields, |
| | | isDemoField, |
| | | isDemoSection |
| | | groupDemoFields |
| | | } from '../data/screen-field-meta'; |
| | | import { |
| | | initialScreenData, |
| | |
| | | const liveFields = ref<string[]>(initial.liveFields); |
| | | const loading = ref(false); |
| | | const loadError = ref(''); |
| | | /** 驾驶舱是否处于浏览器全屏;萤石播放器在全屏切换后需 remount 才能恢复画面 */ |
| | | const screenFullscreen = ref(false); |
| | | const playerRemountKey = ref(0); |
| | | |
| | | export function provideScreenTime() { |
| | | return sharedTimeRange; |
| | |
| | | |
| | | export function useScreenTime() { |
| | | return sharedTimeRange; |
| | | } |
| | | |
| | | export function useScreenFullscreen() { |
| | | function bumpPlayerRemount(delay = 280) { |
| | | window.setTimeout(() => { |
| | | playerRemountKey.value += 1; |
| | | window.dispatchEvent( |
| | | new CustomEvent('report-screen-fullscreen-change', { |
| | | detail: { remountKey: playerRemountKey.value } |
| | | }) |
| | | ); |
| | | }, delay); |
| | | } |
| | | |
| | | function setFullscreen(active: boolean) { |
| | | const changed = screenFullscreen.value !== active; |
| | | screenFullscreen.value = active; |
| | | if (changed) { |
| | | bumpPlayerRemount(); |
| | | } |
| | | } |
| | | |
| | | return { |
| | | isFullscreen: screenFullscreen, |
| | | playerRemountKey, |
| | | bumpPlayerRemount, |
| | | setFullscreen |
| | | }; |
| | | } |
| | | |
| | | export function useScreenData() { |
| | |
| | | applyResolved(resolveScreenData(res, sharedTimeRange.value)); |
| | | } catch (e: any) { |
| | | applyResolved(resolveScreenData(null, sharedTimeRange.value)); |
| | | loadError.value = e?.message || '驾驶舱接口调用失败'; |
| | | const msg = e?.message || ''; |
| | | // 首页静默降级到演示数据,不展示登录/权限类错误文案 |
| | | if (!/登录失效|无权限/.test(msg)) { |
| | | loadError.value = msg || '驾驶舱接口调用失败'; |
| | | } |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | |
| | | return { load, loading, loadError, dataMode }; |
| | | } |
| | | |
| | | /** 判断区块/字段是否为演示数据(mixed 模式下按字段级判定) */ |
| | | /** 首页不展示「演示」标识,底层仍可静默使用演示数据填充 */ |
| | | export function useScreenFieldSource() { |
| | | const { dataMode, demoFields } = useScreenData(); |
| | | |
| | | const demoSections = computed(() => groupDemoFields(demoFields.value)); |
| | | |
| | | function demoSection(section: string) { |
| | | return isDemoSection(section, demoFields.value, dataMode.value); |
| | | function demoSection(_section: string) { |
| | | return false; |
| | | } |
| | | |
| | | function demoField(field: string) { |
| | | return isDemoField(field, demoFields.value, dataMode.value); |
| | | function demoField(_field: string) { |
| | | return false; |
| | | } |
| | | |
| | | return { |
| New file |
| | |
| | | /* 驾驶舱 KPI 行:等宽等高卡片网格 */ |
| | | .kpi-grid { |
| | | display: grid; |
| | | gap: 10px; |
| | | margin-bottom: 10px; |
| | | grid-template-columns: repeat(var(--kpi-cols, 6), minmax(0, 1fr)); |
| | | |
| | | @media (max-width: 1400px) { |
| | | grid-template-columns: repeat(min(var(--kpi-cols, 6), 4), minmax(0, 1fr)); |
| | | } |
| | | |
| | | @media (max-width: 992px) { |
| | | grid-template-columns: repeat(2, minmax(0, 1fr)); |
| | | } |
| | | |
| | | @media (max-width: 576px) { |
| | | grid-template-columns: 1fr; |
| | | } |
| | | } |
| | |
| | | <template> |
| | | <div class="screen-page"> |
| | | <el-row :gutter="10" class="kpi-row"> |
| | | <el-col v-for="item in kpis" :key="item.label" :lg="4" :sm="12" :xs="24"> |
| | | <kpi-card v-bind="item" :demo="isDemoField(item.field)" /> |
| | | </el-col> |
| | | </el-row> |
| | | <div class="kpi-grid" style="--kpi-cols: 6"> |
| | | <kpi-card v-for="item in kpis" :key="item.label" v-bind="item" /> |
| | | </div> |
| | | |
| | | <el-row :gutter="10" class="mid-row"> |
| | | <el-col :lg="14" :xs="24"> |
| | |
| | | import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen'; |
| | | |
| | | const { data, timeRange } = useScreenData(); |
| | | const { isDemoField, isDemoSection } = useScreenFieldSource(); |
| | | const { isDemoSection } = useScreenFieldSource(); |
| | | const { textColor, mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme(); |
| | | |
| | | const kpis = computed(() => [ |
| | |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | @import '../../styles/screen-kpi.scss'; |
| | | |
| | | .screen-page { |
| | | .kpi-row, |
| | | .mid-row { |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .kpi-row { |
| | | :deep(.el-col) { |
| | | margin-bottom: 10px; |
| | | } |
| | | } |
| | | |
| | | .mid-row { |
| | | :deep(.panel-card) { |
| | | min-height: 360px; |
| | | } |
| | |
| | | }); |
| | | |
| | | import { computed } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | | import { useRoute } from 'vue-router'; |
| | | import ScreenShell from '../../components/screen-shell.vue'; |
| | | import Overview from './overview.vue'; |
| | | import Resource from './resource.vue'; |
| | | import Carbon from './carbon.vue'; |
| | | import Participation from './participation.vue'; |
| | | import Operation from './operation.vue'; |
| | | import { SCREEN_NAV, type ScreenKey } from '../../data/screen'; |
| | | import { resolveScreenKey, type ScreenKey } from '../../data/screen'; |
| | | import { useScreenLoader } from '../../hooks/use-screen'; |
| | | |
| | | useScreenLoader(); |
| | | |
| | | const route = useRoute(); |
| | | const router = useRouter(); |
| | | |
| | | const pageMap = { |
| | | overview: Overview, |
| | |
| | | operation: Operation |
| | | }; |
| | | |
| | | const currentKey = computed<ScreenKey>(() => { |
| | | const page = String(route.params.page || 'overview') as ScreenKey; |
| | | return SCREEN_NAV.some(e => e.key === page) ? page : 'overview'; |
| | | }); |
| | | |
| | | if (!route.params.page) { |
| | | router.replace('/report/screen/overview'); |
| | | } |
| | | /** 停留在 /report/screen/index,用 query.page(或动态路由 :page)切换子页,不再强制跳走 */ |
| | | const currentKey = computed<ScreenKey>(() => |
| | | resolveScreenKey(route.params.page as string, route.query.page as string) |
| | | ); |
| | | </script> |
| | |
| | | <template> |
| | | <div class="screen-page"> |
| | | <el-row :gutter="10" class="kpi-row"> |
| | | <el-col :lg="3" :sm="8" :xs="12"> |
| | | <kpi-card label="在线分拣员" :value="data.kpis.sorterOnline" |
| | | :hint="`${data.kpis.sorterOnline} / ${data.kpis.sorterTotal}`" |
| | | :demo="isDemoField('kpis.sorterOnline')" /> |
| | | </el-col> |
| | | <el-col :lg="3" :sm="8" :xs="12"> |
| | | <kpi-card label="在线车辆" :value="data.kpis.vehicleOnline" |
| | | :hint="`${data.kpis.vehicleOnline} / ${data.kpis.vehicleTotal}`" |
| | | :demo="isDemoField('kpis.vehicleOnline')" /> |
| | | </el-col> |
| | | <el-col :lg="3" :sm="8" :xs="12"> |
| | | <kpi-card label="在线分拣点" :value="data.kpis.siteOnline" |
| | | :hint="`${data.kpis.siteOnline} / ${data.kpis.siteTotal},按站点启用`" |
| | | :demo="isDemoField('kpis.siteOnline')" /> |
| | | </el-col> |
| | | <el-col :lg="3" :sm="8" :xs="12"> |
| | | <kpi-card label="今日回收订单" :value="data.kpis.todayOrders" :demo="isDemoField('kpis.todayOrders')" /> |
| | | </el-col> |
| | | <el-col :lg="4" :sm="8" :xs="12"> |
| | | <kpi-card label="今日预约 / 待处理" :value="data.kpis.todayAppointments" |
| | | :hint="`待处理 ${data.kpis.pendingAppointments}`" |
| | | :demo="isDemoField('kpis.todayAppointments')" /> |
| | | </el-col> |
| | | <el-col :lg="4" :sm="8" :xs="12"> |
| | | <kpi-card label="异常告警" :value="data.kpis.alerts" alert |
| | | :hint="`健康异常 ${data.kpis.healthAlerts},不展示隐私体征`" |
| | | :demo="isDemoField('kpis.alerts')" /> |
| | | </el-col> |
| | | <el-col :lg="4" :sm="8" :xs="12"> |
| | | <kpi-card label="车辆安全事件" :value="safetyCount" hint="超速 / 急刹等,来自车辆日汇总" |
| | | :demo="isDemoSection('vehicles')" /> |
| | | </el-col> |
| | | </el-row> |
| | | <div class="kpi-grid" style="--kpi-cols: 7"> |
| | | <kpi-card |
| | | label="在线分拣员" |
| | | :value="data.kpis.sorterOnline" |
| | | :hint="`${data.kpis.sorterOnline} / ${data.kpis.sorterTotal}`" |
| | | /> |
| | | <kpi-card |
| | | label="在线车辆" |
| | | :value="data.kpis.vehicleOnline" |
| | | :hint="`${data.kpis.vehicleOnline} / ${data.kpis.vehicleTotal}`" |
| | | /> |
| | | <kpi-card |
| | | label="在线分拣点" |
| | | :value="data.kpis.siteOnline" |
| | | :hint="`${data.kpis.siteOnline} / ${data.kpis.siteTotal},按站点启用`" |
| | | /> |
| | | <kpi-card label="今日回收订单" :value="data.kpis.todayOrders" /> |
| | | <kpi-card |
| | | label="今日预约 / 待处理" |
| | | :value="data.kpis.todayAppointments" |
| | | :hint="`待处理 ${data.kpis.pendingAppointments}`" |
| | | /> |
| | | <kpi-card |
| | | label="异常告警" |
| | | :value="data.kpis.alerts" |
| | | alert |
| | | :hint="`健康异常 ${data.kpis.healthAlerts},不展示隐私体征`" |
| | | /> |
| | | <kpi-card label="车辆安全事件" :value="safetyCount" hint="超速 / 急刹等,来自车辆日汇总" /> |
| | | </div> |
| | | |
| | | <el-row :gutter="10" class="mid-row"> |
| | | <el-col :lg="14" :xs="24"> |
| | |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <el-row :gutter="10"> |
| | | <el-row :gutter="10" class="bottom-row"> |
| | | <el-col :lg="8" :xs="24"> |
| | | <panel-card title="分拣员状态" sub="来自分拣员日表,只汇总状态,不展示体征明细" :demo="isDemoSection('sorterStatus')"> |
| | | <div class="status-list"> |
| | |
| | | </el-col> |
| | | <el-col :lg="8" :xs="24"> |
| | | <panel-card title="车辆运行" sub="公司领导看运力与安全,不进校领导首页" :demo="isDemoSection('vehicles')"> |
| | | <div class="table table--vehicle"> |
| | | <div class="table__head"> |
| | | <span>车牌</span> |
| | | <span>里程km</span> |
| | | <span>怠速分</span> |
| | | <span>超速</span> |
| | | <span>状态</span> |
| | | </div> |
| | | <screen-auto-scroll class="table table--vehicle" :watch-key="vehicleWatchKey"> |
| | | <template #head> |
| | | <div class="table__head"> |
| | | <span>车牌</span> |
| | | <span>里程km</span> |
| | | <span>怠速分</span> |
| | | <span>超速</span> |
| | | <span>状态</span> |
| | | </div> |
| | | </template> |
| | | <div v-for="item in data.vehicles" :key="item.no" class="table__row"> |
| | | <span>{{ item.no }}</span> |
| | | <span>{{ item.km }}</span> |
| | |
| | | <span>{{ item.overspeed }}</span> |
| | | <span :class="item.status === '在线' ? 'is-on' : 'is-off'">{{ item.status }}</span> |
| | | </div> |
| | | </div> |
| | | </screen-auto-scroll> |
| | | </panel-card> |
| | | </el-col> |
| | | <el-col :lg="8" :xs="24"> |
| | | <panel-card title="分拣点吞吐" sub="运营维度才下钻到点位" :demo="isDemoSection('sites')"> |
| | | <div class="table table--site"> |
| | | <div class="table__head"> |
| | | <span>收集点</span> |
| | | <span>今日kg</span> |
| | | <span>在线</span> |
| | | </div> |
| | | <screen-auto-scroll class="table table--site" :watch-key="siteWatchKey"> |
| | | <template #head> |
| | | <div class="table__head"> |
| | | <span>收集点</span> |
| | | <span>今日kg</span> |
| | | <span>在线</span> |
| | | </div> |
| | | </template> |
| | | <div v-for="item in data.sites" :key="item.name" class="table__row"> |
| | | <span>{{ item.name }}</span> |
| | | <span>{{ item.weight }}</span> |
| | | <span :class="item.online ? 'is-on' : 'is-off'">{{ item.online ? '在线' : '离线' }}</span> |
| | | </div> |
| | | </div> |
| | | </screen-auto-scroll> |
| | | </panel-card> |
| | | </el-col> |
| | | </el-row> |
| | |
| | | import { computed } from 'vue'; |
| | | import KpiCard from '../../components/kpi-card.vue'; |
| | | import PanelCard from '../../components/panel-card.vue'; |
| | | import ScreenAutoScroll from '../../components/screen-auto-scroll.vue'; |
| | | import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen'; |
| | | |
| | | const { data } = useScreenData(); |
| | | const { isDemoField, isDemoSection } = useScreenFieldSource(); |
| | | const { isDemoSection } = useScreenFieldSource(); |
| | | const { mutedColor, splitColor, tooltip } = useChartTheme(); |
| | | |
| | | const safetyCount = computed(() => data.value.vehicles.reduce((s, e) => s + e.overspeed, 0)); |
| | | const vehicleWatchKey = computed(() => data.value.vehicles.map(e => e.no).join('|')); |
| | | const siteWatchKey = computed(() => data.value.sites.map(e => e.name).join('|')); |
| | | |
| | | function funnelWidth(value: number) { |
| | | const max = data.value.appointmentFlow[0]?.value || 1; |
| | |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .screen-page { |
| | | @import '../../styles/screen-kpi.scss'; |
| | | |
| | | .kpi-row, |
| | | .screen-page { |
| | | .mid-row { |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .kpi-row { |
| | | :deep(.el-col) { |
| | | margin-bottom: 10px; |
| | | } |
| | | } |
| | | |
| | | .mid-row, |
| | |
| | | } |
| | | } |
| | | |
| | | .table { |
| | | padding: 8px 12px 16px; |
| | | font-size: 13px; |
| | | .bottom-row { |
| | | :deep(.panel-card) { |
| | | min-height: 280px; |
| | | } |
| | | |
| | | &--vehicle &__head, |
| | | &--vehicle &__row { |
| | | :deep(.panel-card__body) { |
| | | display: flex; |
| | | flex-direction: column; |
| | | min-height: 0; |
| | | overflow: hidden; |
| | | } |
| | | } |
| | | |
| | | .table { |
| | | padding: 8px 12px 12px; |
| | | font-size: 13px; |
| | | height: 100%; |
| | | min-height: 0; |
| | | |
| | | &--vehicle :deep(.table__head), |
| | | &--vehicle .table__row { |
| | | grid-template-columns: 1.4fr repeat(4, 0.8fr); |
| | | } |
| | | |
| | | &--site &__head, |
| | | &--site &__row { |
| | | &--site :deep(.table__head), |
| | | &--site .table__row { |
| | | grid-template-columns: 1.6fr 0.8fr 0.6fr; |
| | | } |
| | | |
| | | &__head, |
| | | &__row { |
| | | :deep(.table__head), |
| | | .table__row { |
| | | display: grid; |
| | | gap: 8px; |
| | | padding: 10px 4px; |
| | | } |
| | | |
| | | &__head { |
| | | :deep(.table__head) { |
| | | color: var(--el-text-color-secondary); |
| | | } |
| | | |
| | | &__row { |
| | | .table__row { |
| | | border-top: 1px solid var(--el-border-color-extra-light); |
| | | } |
| | | |
| | |
| | | <template> |
| | | <div class="screen-page"> |
| | | <el-row :gutter="10" class="kpi-row"> |
| | | <el-col v-for="item in kpis" :key="item.label" :xl="3" :lg="6" :sm="12" :xs="24"> |
| | | <kpi-card v-bind="item" :demo="isDemoField(item.field)" /> |
| | | </el-col> |
| | | </el-row> |
| | | <div class="kpi-grid" style="--kpi-cols: 8"> |
| | | <kpi-card v-for="item in kpis" :key="item.label" v-bind="item" /> |
| | | </div> |
| | | |
| | | <el-row :gutter="10" class="charts-row"> |
| | | <el-col :lg="7" :xs="24"> |
| | |
| | | import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen'; |
| | | |
| | | const { data, timeRange } = useScreenData(); |
| | | const { isDemoField, isDemoSection } = useScreenFieldSource(); |
| | | const { isDemoSection } = useScreenFieldSource(); |
| | | const { textColor, mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme(); |
| | | |
| | | const mixMetric = ref<'weight' | 'value' | 'count'>('weight'); |
| | |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | @import '../../styles/screen-kpi.scss'; |
| | | |
| | | .screen-page { |
| | | .kpi-row, |
| | | .charts-row, |
| | | .camera-row { |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .kpi-row { |
| | | :deep(.el-col) { |
| | | margin-bottom: 10px; |
| | | } |
| | | } |
| | | |
| | | .charts-row { |
| | |
| | | <template> |
| | | <div class="screen-page"> |
| | | <el-row :gutter="10" class="kpi-row"> |
| | | <el-col v-for="item in kpis" :key="item.label" :lg="4" :sm="12" :xs="24"> |
| | | <kpi-card v-bind="item" :demo="isDemoField(item.field)" /> |
| | | </el-col> |
| | | </el-row> |
| | | <div class="kpi-grid" style="--kpi-cols: 6"> |
| | | <kpi-card v-for="item in kpis" :key="item.label" v-bind="item" /> |
| | | </div> |
| | | |
| | | <el-row :gutter="10" class="mid-row"> |
| | | <el-col :lg="14" :xs="24"> |
| | |
| | | import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen'; |
| | | |
| | | const { data } = useScreenData(); |
| | | const { isDemoField, isDemoSection } = useScreenFieldSource(); |
| | | const { isDemoSection } = useScreenFieldSource(); |
| | | const { textColor, mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme(); |
| | | |
| | | const trendKind = ref<'people' | 'times'>('people'); |
| | |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | @import '../../styles/screen-kpi.scss'; |
| | | |
| | | .screen-page { |
| | | .kpi-row, |
| | | .mid-row { |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .kpi-row { |
| | | :deep(.el-col) { |
| | | margin-bottom: 10px; |
| | | } |
| | | } |
| | | |
| | | .mid-row { |
| | | :deep(.panel-card) { |
| | | min-height: 360px; |
| | | } |
| | |
| | | <template> |
| | | <div class="screen-page"> |
| | | <el-row :gutter="10" class="kpi-row"> |
| | | <el-col v-for="item in kpis" :key="item.label" :lg="4" :sm="12" :xs="24"> |
| | | <kpi-card v-bind="item" :demo="isDemoField(item.field)" /> |
| | | </el-col> |
| | | </el-row> |
| | | <div class="kpi-grid" style="--kpi-cols: 5"> |
| | | <kpi-card v-for="item in kpis" :key="item.label" v-bind="item" /> |
| | | </div> |
| | | |
| | | <el-row :gutter="10" class="mid-row"> |
| | | <el-col :lg="10" :xs="24"> |
| | |
| | | import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen'; |
| | | |
| | | const { data } = useScreenData(); |
| | | const { isDemoField, isDemoSection } = useScreenFieldSource(); |
| | | const { isDemoSection } = useScreenFieldSource(); |
| | | const { textColor, mutedColor, splitColor, primary, tooltip } = useChartTheme(); |
| | | |
| | | const kpis = computed(() => [ |
| | |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | @import '../../styles/screen-kpi.scss'; |
| | | |
| | | .screen-page { |
| | | .kpi-row, |
| | | .mid-row { |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .kpi-row { |
| | | :deep(.el-col) { |
| | | margin-bottom: 10px; |
| | | } |
| | | } |
| | | |
| | | .mid-row { |
| | | :deep(.panel-card) { |
| | | min-height: 360px; |
| | | } |