wangrong
2026-08-30 8954db4cf4969166961c72569dfa24645276b947
修复首页问题
12个文件已修改
2个文件已添加
803 ■■■■■ 已修改文件
src/modules/monitor/views/cameras.vue 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/components/kpi-card.vue 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/components/screen-auto-scroll.vue 142 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/components/screen-camera-preview.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/components/screen-shell.vue 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/data/screen.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/hooks/use-screen.ts 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/styles/screen-kpi.scss 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/carbon.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/index.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/operation.vue 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/overview.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/participation.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/resource.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/monitor/views/cameras.vue
@@ -1,17 +1,17 @@
<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"
@@ -20,7 +20,7 @@
        </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>
@@ -44,42 +44,100 @@
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() {
@@ -98,6 +156,9 @@
    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 = [];
@@ -107,22 +168,26 @@
}
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>
src/modules/report/components/kpi-card.vue
@@ -1,13 +1,10 @@
<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>
@@ -21,6 +18,7 @@
    fixed?: number;
    hint?: string;
    alert?: boolean;
    /** @deprecated 首页不再展示演示标识,保留 prop 以免调用方报错 */
    demo?: boolean;
}>();
@@ -29,31 +27,47 @@
<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;
@@ -64,9 +78,14 @@
    }
    &__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 {
@@ -76,10 +95,6 @@
        .kpi-card__value {
            color: var(--el-color-danger);
        }
    }
    &.is-demo:not(.is-alert) {
        border-style: dashed;
    }
}
</style>
src/modules/report/components/screen-auto-scroll.vue
New file
@@ -0,0 +1,142 @@
<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>
src/modules/report/components/screen-camera-preview.vue
@@ -9,8 +9,8 @@
                <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"
@@ -27,7 +27,7 @@
    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';
@@ -45,6 +45,11 @@
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('');
@@ -78,9 +83,14 @@
}
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>
src/modules/report/components/screen-shell.vue
@@ -4,7 +4,7 @@
            <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>
@@ -16,7 +16,7 @@
                    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>
@@ -24,17 +24,6 @@
            </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" />
@@ -43,20 +32,6 @@
        <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>
@@ -65,32 +40,46 @@
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() {
@@ -127,6 +116,7 @@
    &.is-fullscreen {
        padding: 12px;
        height: 100vh;
        min-height: 100vh;
        background: #07111f;
@@ -137,6 +127,77 @@
        .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;
                }
            }
        }
    }
@@ -223,18 +284,5 @@
    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>
src/modules/report/data/screen.ts
@@ -2,14 +2,24 @@
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' },
src/modules/report/hooks/use-screen.ts
@@ -3,9 +3,7 @@
import { useTheme } from '/#/theme';
import { useCool } from '/@/cool';
import {
    groupDemoFields,
    isDemoField,
    isDemoSection
    groupDemoFields
} from '../data/screen-field-meta';
import {
    initialScreenData,
@@ -22,6 +20,9 @@
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;
@@ -29,6 +30,34 @@
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() {
@@ -73,7 +102,11 @@
            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;
        }
@@ -91,18 +124,18 @@
    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 {
src/modules/report/styles/screen-kpi.scss
New file
@@ -0,0 +1,19 @@
/* 驾驶舱 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;
    }
}
src/modules/report/views/screen/carbon.vue
@@ -1,10 +1,8 @@
<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">
@@ -50,7 +48,7 @@
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(() => [
@@ -148,19 +146,12 @@
</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;
        }
src/modules/report/views/screen/index.vue
@@ -10,20 +10,19 @@
});
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,
@@ -33,12 +32,8 @@
    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>
src/modules/report/views/screen/operation.vue
@@ -1,39 +1,35 @@
<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"
        <div class="kpi-grid" style="--kpi-cols: 7">
            <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"
            />
            <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"
            />
            <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"
            />
            <kpi-card label="今日回收订单" :value="data.kpis.todayOrders" />
            <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
            />
            <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>
            />
            <kpi-card label="车辆安全事件" :value="safetyCount" hint="超速 / 急刹等,来自车辆日汇总" />
        </div>
        <el-row :gutter="10" class="mid-row">
            <el-col :lg="14" :xs="24">
@@ -58,7 +54,7 @@
            </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">
@@ -72,7 +68,8 @@
            </el-col>
            <el-col :lg="8" :xs="24">
                <panel-card title="车辆运行" sub="公司领导看运力与安全,不进校领导首页" :demo="isDemoSection('vehicles')">
                    <div class="table table--vehicle">
                    <screen-auto-scroll class="table table--vehicle" :watch-key="vehicleWatchKey">
                        <template #head>
                        <div class="table__head">
                            <span>车牌</span>
                            <span>里程km</span>
@@ -80,6 +77,7 @@
                            <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>
@@ -87,23 +85,25 @@
                            <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">
                    <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>
@@ -118,13 +118,16 @@
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;
@@ -165,17 +168,11 @@
</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,
@@ -272,32 +269,47 @@
        }
    }
    .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);
        }
src/modules/report/views/screen/overview.vue
@@ -1,10 +1,8 @@
<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">
@@ -56,7 +54,7 @@
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');
@@ -171,17 +169,12 @@
</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 {
src/modules/report/views/screen/participation.vue
@@ -1,10 +1,8 @@
<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">
@@ -75,7 +73,7 @@
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');
@@ -181,19 +179,12 @@
</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;
        }
src/modules/report/views/screen/resource.vue
@@ -1,10 +1,8 @@
<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">
@@ -40,7 +38,7 @@
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(() => [
@@ -131,19 +129,12 @@
</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;
        }