wangrong
2026-08-28 17b92e9b60dda6dcc20c748046a5ff3c3958d52e
增加首页大屏显示
10个文件已修改
6个文件已添加
1593 ■■■■■ 已修改文件
src/modules/monitor/views/cameras.vue 195 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/components/kpi-card.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/components/panel-card.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/components/screen-camera-preview.vue 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/components/screen-shell.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/data/screen-demo.ts 215 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/data/screen-field-meta.ts 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/data/screen-merge.ts 323 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/data/screen-structure.ts 162 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/data/screen.ts 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/hooks/use-screen.ts 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/carbon.vue 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/operation.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/overview.vue 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/participation.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/resource.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/monitor/views/cameras.vue
New file
@@ -0,0 +1,195 @@
<template>
  <div class="camera-page">
    <div class="scroll-wrapper">
      <el-button class="arrow left" @click="scrollLeft" :disabled="canScrollLeft" circle>
        <el-icon><ArrowLeft /></el-icon>
      </el-button>
      <div class="scroll-viewport" ref="viewportRef">
        <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"
              :access-token="ysToken"
              :url="`ezopen://open.ys7.com/${dev.iotCode || dev.code}/1.hd.live`"
              :pause-after-first-frame="true"
            />
          </div>
        </div>
      </div>
      <el-button class="arrow right" @click="scrollRight" :disabled="canScrollRight" circle>
        <el-icon><ArrowRight /></el-icon>
      </el-button>
    </div>
  </div>
</template>
<script lang="ts" setup>
defineOptions({
  name: 'camera-monitor',
});
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useCool } from '/@/cool';
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue';
import EzuikitPlayer from '../../ezuikit/components/ezuikit-player.vue';
const { service } = useCool();
const deviceList = ref<any[]>([]);
const ysToken = ref('');
const loading = ref(false);
const scrollPos = ref(0);
const viewportRef = ref<HTMLElement | null>(null);
// 每次滚动的距离(一个摄像头的宽度 + 间距)
const scrollStep = computed(() => {
  const cardWidth = 312; // 300px 宽度 + 12px 间距的一半
  const gap = 12;
  return cardWidth + gap;
});
// 是否可以向左滚动
const canScrollLeft = computed(() => scrollPos.value <= 0);
// 是否可以向右滚动
const canScrollRight = computed(() => {
  if (!viewportRef.value) return true;
  const viewportWidth = viewportRef.value.clientWidth;
  const trackWidth = deviceList.value.length * scrollStep.value;
  // 增加 2px 容差
  return scrollPos.value >= trackWidth - viewportWidth - 2;
});
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;
  } else {
    scrollPos.value += scrollStep.value;
  }
}
async function fetchYsToken() {
  if (ysToken.value) return;
  try {
    const res = await service.base.sys.param.stringbykey({ key: 'ys7.accessToken' });
    ysToken.value = res as string;
  } catch (e: any) {
    console.error('萤石 Token 获取失败', e?.response?.data?.message || e.message);
  }
}
async function fetchDevices() {
  loading.value = true;
  try {
    const res = await service.basicdata.iot.getCameras();
    deviceList.value = (res || []) as any[];
    scrollPos.value = 0;
  } catch (e: any) {
    console.error('获取摄像头列表失败', e?.response?.data?.message || e.message);
    deviceList.value = [];
  } finally {
    loading.value = false;
  }
}
async function refreshDevices() {
  await fetchDevices();
}
function handleKeydown(e: KeyboardEvent) {
  if (e.key === 'ArrowLeft') scrollLeft();
  if (e.key === 'ArrowRight') scrollRight();
}
onMounted(async () => {
  window.addEventListener('keydown', handleKeydown);
  await fetchYsToken();
  await fetchDevices();
});
onUnmounted(() => {
  window.removeEventListener('keydown', handleKeydown);
});
</script>
<style lang="scss" scoped>
.camera-page {
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
}
.scroll-wrapper {
  position: relative;
  display: flex;
  align-items: center;
  width: 100%;
  /* 移除 max-width 限制,让容器占满可用宽度 */
}
.scroll-viewport {
  overflow: hidden;
  flex: 1;
  margin: 0 42px;
}
.scroll-track {
  display: flex;
  gap: 12px;
  transition: transform 0.35s ease;
}
.player-card {
  flex-shrink: 0;
  width: 300px;
  height: 360px;
  border-radius: 8px;
  overflow: hidden;
  background: #000;
}
.arrow {
  flex-shrink: 0;
  z-index: 10;
  background: #00ff00 !important;
  border-color: #00ff00 !important;
  color: #000 !important;
  font-size: 18px;
  width: 32px;
  height: 96px; /* 加高箭头,更易点击 */
  border-radius: 4px !important;
}
.arrow:hover {
  background: #33ff33 !important;
  border-color: #33ff33 !important;
}
.arrow:active {
  background: #00cc00 !important;
  border-color: #00cc00 !important;
}
.el-button.is-disabled {
  opacity: 0.25;
  background: #555 !important;
  border-color: #555 !important;
  color: #888 !important;
}
</style>
src/modules/report/components/kpi-card.vue
@@ -1,6 +1,9 @@
<template>
    <div class="kpi-card" :class="{ 'is-alert': alert }">
        <div class="kpi-card__label">{{ label }}</div>
    <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__value">
            <cl-number :value="numeric" :fixed="fixed" type="amount" :suffix="suffix" />
        </div>
@@ -18,6 +21,7 @@
    fixed?: number;
    hint?: string;
    alert?: boolean;
    demo?: boolean;
}>();
const numeric = computed(() => Number(props.value) || 0);
@@ -33,8 +37,15 @@
    min-height: 96px;
    &__label {
        display: flex;
        align-items: center;
        gap: 6px;
        font-size: 13px;
        color: var(--el-text-color-secondary);
    }
    &__badge {
        transform: scale(0.92);
    }
    &__value {
@@ -66,5 +77,9 @@
            color: var(--el-color-danger);
        }
    }
    &.is-demo:not(.is-alert) {
        border-style: dashed;
    }
}
</style>
src/modules/report/components/panel-card.vue
@@ -3,6 +3,7 @@
        <div class="panel-card__header">
            <div class="panel-card__title">
                <span>{{ title }}</span>
                <el-tag v-if="demo" class="panel-card__badge" type="info" effect="plain" size="small">演示</el-tag>
                <span v-if="sub" class="panel-card__sub">{{ sub }}</span>
            </div>
            <div class="panel-card__extra">
@@ -19,6 +20,7 @@
defineProps<{
    title: string;
    sub?: string;
    demo?: boolean;
}>();
</script>
@@ -61,6 +63,10 @@
        color: var(--el-text-color-secondary);
    }
    &__badge {
        transform: translateY(-1px);
    }
    &__body {
        flex: 1;
        min-height: 0;
src/modules/report/components/screen-camera-preview.vue
New file
@@ -0,0 +1,148 @@
<template>
    <div v-loading="loading" class="screen-cameras">
        <el-empty v-if="!loading && !previewList.length" description="暂无在线摄像头" :image-size="72">
            <router-link class="screen-cameras__link" to="/monitor/device">前往监控设备</router-link>
        </el-empty>
        <div v-else class="screen-cameras__grid">
            <div v-for="dev in previewList" :key="dev.id" class="screen-cameras__item">
                <div class="screen-cameras__player">
                    <ezuikit-player
                        v-if="ysToken"
                        :key="'screen_cam_' + dev.id + '_' + ysToken"
                        :container-id="'screen_cam_' + dev.id"
                        :access-token="ysToken"
                        :url="cameraUrl(dev)"
                        :pause-after-first-frame="true"
                    />
                </div>
                <div v-if="dev.iotName" class="screen-cameras__name" :title="dev.iotName">{{ dev.iotName }}</div>
            </div>
        </div>
    </div>
</template>
<script lang="ts" setup>
defineOptions({
    name: 'report-screen-camera-preview'
});
import { computed, onMounted, ref } from 'vue';
import { useCool } from '/@/cool';
import EzuikitPlayer from '../../ezuikit/components/ezuikit-player.vue';
const props = withDefaults(
    defineProps<{
        /** 最多展示路数,4~6 */
        limit?: number;
    }>(),
    {
        limit: 6
    }
);
const maxCount = computed(() => Math.min(Math.max(props.limit, 4), 6));
const previewList = computed(() => deviceList.value.slice(0, maxCount.value));
const { service } = useCool();
const deviceList = ref<any[]>([]);
const ysToken = ref('');
const loading = ref(false);
function cameraUrl(dev: { iotCode?: string; code?: string }) {
    return `ezopen://open.ys7.com/${dev.iotCode || dev.code}/1.hd.live`;
}
async function fetchYsToken() {
    if (ysToken.value) return;
    try {
        const res = await service.base.sys.param.stringbykey({ key: 'ys7.accessToken' });
        ysToken.value = res as string;
    } catch (e: any) {
        console.error('萤石 Token 获取失败', e?.response?.data?.message || e.message);
    }
}
async function fetchDevices() {
    loading.value = true;
    try {
        const res = await service.basicdata.iot.getCameras();
        deviceList.value = (res || []) as any[];
    } catch (e: any) {
        console.error('获取摄像头列表失败', e?.response?.data?.message || e.message);
        deviceList.value = [];
    } finally {
        loading.value = false;
    }
}
onMounted(async () => {
    await fetchYsToken();
    await fetchDevices();
});
</script>
<style lang="scss" scoped>
.screen-cameras {
    min-height: 200px;
    &__grid {
        display: grid;
        grid-template-columns: repeat(3, minmax(0, 1fr));
        gap: 10px;
    }
    &__item {
        display: flex;
        flex-direction: column;
        min-width: 0;
        border-radius: 10px;
        overflow: hidden;
        background: #000;
        border: 1px solid var(--el-border-color-extra-light);
    }
    &__player {
        aspect-ratio: 16 / 9;
        background: #0f172a;
        :deep(> div) {
            width: 100%;
            height: 100%;
        }
    }
    &__name {
        padding: 6px 8px;
        font-size: 12px;
        line-height: 1.4;
        color: var(--el-text-color-regular);
        background: var(--el-fill-color-lighter);
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }
    &__link {
        color: var(--el-color-primary);
        text-decoration: none;
        &:hover {
            text-decoration: underline;
        }
    }
    @media (max-width: 1200px) {
        &__grid {
            grid-template-columns: repeat(2, minmax(0, 1fr));
        }
    }
    @media (max-width: 768px) {
        &__grid {
            grid-template-columns: 1fr;
        }
    }
}
</style>
src/modules/report/components/screen-shell.vue
@@ -25,6 +25,12 @@
            <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>
@@ -37,6 +43,13 @@
        <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>
@@ -53,12 +66,13 @@
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 } from '../hooks/use-screen';
import { provideScreenTime, useScreenData, useScreenFieldSource } from '../hooks/use-screen';
const route = useRoute();
const router = useRouter();
const timeRange = provideScreenTime();
const { data, loading, loadError, reload } = useScreenData();
const { data, dataMode, loading, loadError, reload } = useScreenData();
const { demoSections } = useScreenFieldSource();
const rootRef = ref<HTMLElement>();
const isFullscreen = ref(false);
src/modules/report/data/screen-demo.ts
New file
@@ -0,0 +1,215 @@
import type { ScreenData, TimeRange } from './screen';
import { ITEM_COLORS, WASTE_COLORS, buildScreenAxis, structureScreen } from './screen-structure';
function wave(len: number, base: number, amp: number, seed = 0) {
    return Array.from({ length: len }, (_, i) => {
        const v = base + amp * Math.sin((i + seed) * 0.85) + amp * 0.35 * Math.cos((i + seed) * 0.4);
        return Math.max(0, Math.round(v * 10) / 10);
    });
}
function intWave(len: number, base: number, amp: number, seed = 0) {
    return wave(len, base, amp, seed).map(v => Math.round(v));
}
function scaleByRange(range: TimeRange) {
    const map: Record<TimeRange, number> = {
        today: 0.08,
        week: 0.28,
        month: 1,
        quarter: 2.8,
        year: 1
    };
    return map[range] ?? 1;
}
function buildRank(name: string, org: string | undefined, people: number, weight: number, points: number) {
    return {
        name,
        org,
        weight: Math.round(weight * 10) / 10,
        people,
        rate: 0,
        points: Math.round(points),
        carbon: Math.round(weight * 10) / 10,
        size: people,
        weightPerCapita: Math.round((weight / people) * 100) / 100,
        pointPerCapita: Math.round((points / people) * 10) / 10
    };
}
/** 饱满演示数据,口径与后端结构一致 */
export function demoScreen(range: TimeRange = 'month'): ScreenData {
    const base = structureScreen(range);
    const axis = base.axis.length ? base.axis : buildScreenAxis(range);
    const s = scaleByRange(range);
    const periodWeightTon = Math.round(18.6 * s * 100) / 100;
    const recyclableTon = Math.round(7.9 * s * 100) / 100;
    const periodCarbonTon = Math.round(2.14 * s * 100) / 100;
    const periodValue = Math.round(12800 * s);
    const totalWeightTon = 126.8;
    const totalCarbonTon = 14.6;
    const totalParticipants = 3842;
    const totalPoints = 96500;
    const wasteWeights = [4200, 3100, 980, 420, 2860].map(v => Math.round(v * s));
    const wasteCounts = [860, 720, 96, 38, 540].map(v => Math.round(v * s));
    const wasteMix = base.wasteMix.map((item, i) => ({
        ...item,
        weight: wasteWeights[i],
        count: wasteCounts[i],
        value: i === 0 ? periodValue : 0,
        color: WASTE_COLORS[item.code || ''] || item.color
    }));
    const recycleWeights = [1820, 960, 540, 680, 320, 410, 270].map(v => Math.round(v * s));
    const recycleValues = [4200, 2100, 1800, 960, 480, 3200, 180].map(v => Math.round(v * s));
    const recycleCarbons = recycleWeights.map(w => Math.round(w * 0.42 * 10) / 10);
    const recycleItems = base.recycleItems.map((item, i) => ({
        ...item,
        weight: recycleWeights[i],
        value: recycleValues[i],
        carbon: recycleCarbons[i],
        color: ITEM_COLORS[i]
    }));
    const trendWeight = wave(axis.length, 620 * s, 180 * s, 1);
    const trendValue = intWave(axis.length, 420 * s, 120 * s, 2);
    const trendCarbon = wave(axis.length, 860 * s, 220 * s, 3);
    const yearCarbon = [0.8, 0.9, 1.1, 1.3, 1.5, 1.8, 2.0, 2.2, 1.9, 1.6, 1.2, 0.9];
    const deptRank = [
        buildRank('环境科学与工程学院', undefined, 486, 2860, 12800),
        buildRank('材料科学与工程学院', undefined, 412, 2410, 10600),
        buildRank('经济管理学院', undefined, 368, 1980, 9200),
        buildRank('计算机学院', undefined, 352, 1760, 8800),
        buildRank('外国语学院', undefined, 286, 1420, 6400),
        buildRank('土木工程学院', undefined, 248, 1180, 5200),
        buildRank('生命科学学院', undefined, 216, 960, 4600),
        buildRank('马克思主义学院', undefined, 168, 720, 3200)
    ];
    const classRank = [
        buildRank('环科2201', '环境科学与工程学院', 68, 420, 1860),
        buildRank('材料2102', '材料科学与工程学院', 62, 386, 1720),
        buildRank('计科2303', '计算机学院', 58, 352, 1580),
        buildRank('经管2204', '经济管理学院', 54, 318, 1460),
        buildRank('土木2101', '土木工程学院', 48, 286, 1280),
        buildRank('外语2202', '外国语学院', 44, 248, 1120),
        buildRank('生科2301', '生命科学学院', 40, 216, 980),
        buildRank('环工2103', '环境科学与工程学院', 36, 198, 860)
    ];
    const majorRank = [
        buildRank('环境工程', undefined, 286, 1680, 7600),
        buildRank('材料化学', undefined, 248, 1420, 6400),
        buildRank('软件工程', undefined, 236, 1280, 5800),
        buildRank('工商管理', undefined, 218, 1160, 5200),
        buildRank('英语', undefined, 186, 960, 4200),
        buildRank('土木工程', undefined, 168, 860, 3800),
        buildRank('生物技术', undefined, 142, 720, 3200),
        buildRank('环境科学', undefined, 128, 640, 2800)
    ];
    const issued = Math.round(186000 * Math.max(s, 0.2));
    const transfer = Math.round(issued * 0.12);
    const donate = Math.round(issued * 0.08);
    const mall = Math.round(issued * 0.46);
    const balance = totalPoints;
    const orderAxis = base.ordersByType.axis;
    const ordersByType = {
        axis: orderAxis,
        现场回收: intWave(orderAxis.length, 18 * s, 8 * s, 4),
        预约上门: intWave(orderAxis.length, 12 * s, 5 * s, 5),
        大屏投递: intWave(orderAxis.length, 26 * s, 10 * s, 6),
        活动回收: intWave(orderAxis.length, 8 * s, 4 * s, 7)
    };
    return {
        ...base,
        axis,
        kpis: {
            todayWeightKg: Math.round(286.5 * Math.max(s, 0.15) * 10) / 10,
            periodWeightTon,
            resourceRate: 42.3,
            periodValue,
            periodCarbonTon,
            totalWeightTon,
            totalParticipants,
            totalPoints,
            recyclableTon,
            bulkTon: Math.round(1.8 * s * 100) / 100,
            elecTon: Math.round(0.96 * s * 100) / 100,
            fabricTon: Math.round(1.24 * s * 100) / 100,
            yearCarbonTon: 18.6,
            monthCarbonTon: periodCarbonTon,
            totalCarbonTon,
            greenActions: Math.round(8620 * Math.max(s, 0.2)),
            newParticipants: Math.round(286 * s),
            participateRate: 31.6,
            pointsIssued: issued,
            pointsConsumed: Math.round(issued * 0.54),
            pointsBalance: balance,
            sorterOnline: 12,
            sorterTotal: 16,
            vehicleOnline: 5,
            vehicleTotal: 7,
            siteOnline: 18,
            siteTotal: 22,
            todayOrders: Math.round(46 * Math.max(s, 0.2)),
            todayAppointments: Math.round(28 * Math.max(s, 0.2)),
            pendingAppointments: Math.round(6 * Math.max(s, 0.2)),
            alerts: 2,
            healthAlerts: 1
        },
        wasteMix,
        recycleItems,
        trend: {
            weight: trendWeight,
            value: trendValue,
            carbon: trendCarbon
        },
        yearCarbon,
        deptRank,
        classRank,
        majorRank,
        pointFlow: { issued, transfer, donate, mall, balance },
        participateTrend: intWave(axis.length, 86 * s, 28 * s, 8),
        deliverTrend: intWave(axis.length, 168 * s, 42 * s, 9),
        ordersByType,
        appointmentFlow: [
            { name: '预约', value: Math.round(18 * Math.max(s, 0.2)) },
            { name: '接单', value: Math.round(14 * Math.max(s, 0.2)) },
            { name: '完成', value: Math.round(11 * Math.max(s, 0.2)) }
        ],
        sorterStatus: [
            { name: '工作中', value: 12, color: '#22c55e' },
            { name: '休息', value: 2, color: '#f59e0b' },
            { name: '异常', value: 1, color: '#ef4444' },
            { name: '离线', value: 1, color: '#94a3b8' }
        ],
        vehicles: [
            { no: '沪A·8K126', km: 86.4, idle: 12, overspeed: 0, status: '在线' },
            { no: '沪A·6D882', km: 72.1, idle: 8, overspeed: 1, status: '在线' },
            { no: '沪A·3F559', km: 64.8, idle: 18, overspeed: 0, status: '在线' },
            { no: '沪A·2H903', km: 58.2, idle: 6, overspeed: 0, status: '在线' },
            { no: '沪A·9M217', km: 46.5, idle: 22, overspeed: 2, status: '在线' },
            { no: '沪A·5P668', km: 38.6, idle: 0, overspeed: 0, status: '离线' },
            { no: '沪A·1R441', km: 24.3, idle: 0, overspeed: 0, status: '离线' }
        ],
        sites: [
            { name: '学生公寓一号站', weight: 286.5, online: true },
            { name: '图书馆回收点', weight: 248.2, online: true },
            { name: '第一食堂收集点', weight: 216.8, online: true },
            { name: '体育馆回收站', weight: 186.4, online: true },
            { name: '行政楼收集点', weight: 162.6, online: true },
            { name: '实验楼B区', weight: 148.2, online: true },
            { name: '研究生公寓站', weight: 126.8, online: false },
            { name: '南门临时点', weight: 98.4, online: true }
        ],
        unavailable: []
    };
}
src/modules/report/data/screen-field-meta.ts
New file
@@ -0,0 +1,158 @@
export type ScreenDataMode = 'live' | 'demo' | 'mixed';
/** KPI 分组:组内任一指标有真实值,则整组采用 live(含合法 0 值) */
export const KPI_GROUPS: Record<string, string[]> = {
    'kpis.weight': [
        'todayWeightKg',
        'periodWeightTon',
        'totalWeightTon',
        'recyclableTon',
        'bulkTon',
        'elecTon',
        'fabricTon',
        'resourceRate',
        'periodValue'
    ],
    'kpis.carbon': ['periodCarbonTon', 'yearCarbonTon', 'monthCarbonTon', 'totalCarbonTon', 'greenActions'],
    'kpis.participation': [
        'totalParticipants',
        'totalPoints',
        'newParticipants',
        'participateRate',
        'pointsIssued',
        'pointsConsumed',
        'pointsBalance'
    ],
    'kpis.operation': [
        'sorterOnline',
        'sorterTotal',
        'vehicleOnline',
        'vehicleTotal',
        'siteOnline',
        'siteTotal',
        'todayOrders',
        'todayAppointments',
        'pendingAppointments',
        'alerts',
        'healthAlerts'
    ]
};
export const SECTION_LABELS: Record<string, string> = {
    axis: '时间轴',
    'kpis.weight': '回收与资源化指标',
    'kpis.carbon': '碳减排指标',
    'kpis.participation': '参与与碳积分指标',
    'kpis.operation': '运营监控指标',
    wasteMix: '垃圾分类结构',
    recycleItems: '可回收物品类',
    trend: '回收趋势',
    yearCarbon: '年度月度碳减排',
    deptRank: '院系排行',
    classRank: '班级排行',
    majorRank: '专业排行',
    pointFlow: '碳积分流转',
    participateTrend: '参与人数趋势',
    deliverTrend: '投递次数趋势',
    ordersByType: '订单类型趋势',
    appointmentFlow: '预约订单漏斗',
    sorterStatus: '分拣员状态',
    vehicles: '车辆运行',
    sites: '分拣点吞吐'
};
export class FieldTracker {
    readonly demo = new Set<string>();
    readonly live = new Set<string>();
    markDemo(field: string) {
        this.demo.add(field);
        this.live.delete(field);
    }
    markLive(field: string) {
        this.live.add(field);
        this.demo.delete(field);
    }
    listDemo() {
        return [...this.demo];
    }
    listLive() {
        return [...this.live];
    }
}
export function groupHasLive(keys: string[], live: Record<string, number>) {
    return keys.some(k => Number(live[k]) > 0);
}
export function resolveDataMode(
    live: unknown,
    tracker: FieldTracker
): ScreenDataMode {
    if (!live) return 'demo';
    if (tracker.demo.size === 0) return 'live';
    if (tracker.live.size === 0) return 'demo';
    return 'mixed';
}
function kpiGroupOf(field: string) {
    if (!field.startsWith('kpis.')) return null;
    const key = field.slice(5);
    for (const [group, keys] of Object.entries(KPI_GROUPS)) {
        if (keys.includes(key)) return group;
    }
    return null;
}
function fieldBelongsToSection(field: string, section: string) {
    if (field === section || field.startsWith(`${section}.`)) return true;
    if (section.startsWith('kpis.')) {
        return kpiGroupOf(field) === section;
    }
    return false;
}
/** 将字段级 demo 标记折叠为可读区块列表 */
export function groupDemoFields(fields: string[]) {
    const sections = new Set<string>();
    for (const field of fields) {
        const group = kpiGroupOf(field);
        if (group) {
            sections.add(group);
            continue;
        }
        if (field.startsWith('pointFlow.')) {
            sections.add('pointFlow');
            continue;
        }
        sections.add(field.split('.')[0]);
    }
    return [...sections]
        .sort()
        .map(section => ({
            section,
            label: SECTION_LABELS[section] || section
        }));
}
export function isDemoSection(
    section: string,
    demoFields: string[],
    dataMode: ScreenDataMode
) {
    if (dataMode === 'live') return false;
    if (dataMode === 'demo') return true;
    return demoFields.some(field => fieldBelongsToSection(field, section));
}
export function isDemoField(field: string, demoFields: string[], dataMode: ScreenDataMode) {
    if (dataMode === 'live') return false;
    if (dataMode === 'demo') return true;
    if (demoFields.includes(field)) return true;
    const group = kpiGroupOf(field);
    if (!group) return false;
    return demoFields.some(item => fieldBelongsToSection(item, group));
}
src/modules/report/data/screen-merge.ts
New file
@@ -0,0 +1,323 @@
import type { ScreenData, TimeRange } from './screen';
import { demoScreen } from './screen-demo';
import {
    FieldTracker,
    KPI_GROUPS,
    groupHasLive,
    resolveDataMode,
    type ScreenDataMode
} from './screen-field-meta';
import { structureScreen } from './screen-structure';
export type { ScreenDataMode };
export interface ResolvedScreenData {
    data: ScreenData;
    mode: ScreenDataMode;
    demoFields: string[];
    liveFields: string[];
}
function sum(nums: number[]) {
    return nums.reduce((a, b) => a + (Number(b) || 0), 0);
}
function hasPositive(nums: number[]) {
    return nums.some(v => Number(v) > 0);
}
function hasRankData(list: any[]) {
    return list.length > 0 && list.some(e => Number(e?.people) > 0 || Number(e?.weight) > 0);
}
function pickKpis(
    live: Record<string, number> | undefined,
    demo: Record<string, number>,
    ctx: FieldTracker
) {
    const out = { ...demo };
    if (!live) {
        Object.values(KPI_GROUPS)
            .flat()
            .forEach(key => ctx.markDemo(`kpis.${key}`));
        return out;
    }
    for (const [group, keys] of Object.entries(KPI_GROUPS)) {
        if (groupHasLive(keys, live)) {
            keys.forEach(key => {
                const v = Number(live[key]);
                out[key] = Number.isFinite(v) ? v : demo[key];
                ctx.markLive(`kpis.${key}`);
            });
        } else {
            keys.forEach(key => ctx.markDemo(`kpis.${key}`));
        }
    }
    return out;
}
function pickArray<T>(
    field: string,
    live: T[] | undefined,
    demo: T[],
    ctx: FieldTracker,
    valid: (v: T[]) => boolean
) {
    if (live && valid(live)) {
        ctx.markLive(field);
        return live;
    }
    ctx.markDemo(field);
    return demo;
}
function pickTrend(
    field: string,
    live: { weight: number[]; value: number[]; carbon: number[] } | undefined,
    demo: ScreenData['trend'],
    axis: string[],
    ctx: FieldTracker
) {
    const valid =
        live &&
        live.weight?.length &&
        (sum(live.weight) > 0 || sum(live.value) > 0 || sum(live.carbon) > 0);
    if (valid) {
        ctx.markLive(field);
        return {
            weight: alignSeries(live!.weight, axis.length, live!.weight[live!.weight.length - 1] || 0),
            value: alignSeries(live!.value, axis.length, live!.value[live!.value.length - 1] || 0),
            carbon: alignSeries(live!.carbon, axis.length, live!.carbon[live!.carbon.length - 1] || 0)
        };
    }
    ctx.markDemo(field);
    return alignTrend(demo, axis.length);
}
function alignSeries(source: number[], len: number, fill = 0) {
    if (!source?.length) return Array.from({ length: len }, () => fill);
    if (source.length === len) return [...source];
    if (source.length > len) return source.slice(source.length - len);
    return [...Array.from({ length: len - source.length }, () => fill), ...source];
}
function alignTrend(trend: ScreenData['trend'], len: number) {
    return {
        weight: alignSeries(trend.weight, len),
        value: alignSeries(trend.value, len),
        carbon: alignSeries(trend.carbon, len)
    };
}
function pickWasteMix(
    live: ScreenData['wasteMix'] | undefined,
    demo: ScreenData['wasteMix'],
    ctx: FieldTracker
) {
    if (
        live?.length &&
        live.some(e => Number(e.weight) > 0 || Number(e.count) > 0 || Number(e.value) > 0)
    ) {
        ctx.markLive('wasteMix');
        return live.map((item, i) => ({
            ...demo[i],
            ...item,
            color: item.color || demo[i]?.color
        }));
    }
    ctx.markDemo('wasteMix');
    return demo;
}
function pickRecycleItems(
    live: ScreenData['recycleItems'] | undefined,
    demo: ScreenData['recycleItems'],
    ctx: FieldTracker
) {
    if (live?.length && live.some(e => Number(e.weight) > 0 || Number(e.value) > 0 || Number(e.carbon) > 0)) {
        ctx.markLive('recycleItems');
        return live.map((item, i) => ({
            ...demo[i],
            ...item,
            color: item.color || demo[i]?.color
        }));
    }
    ctx.markDemo('recycleItems');
    return demo;
}
function pickPointFlow(
    live: ScreenData['pointFlow'] | undefined,
    demo: ScreenData['pointFlow'],
    ctx: FieldTracker
) {
    const out = { ...demo };
    if (!live) {
        ctx.markDemo('pointFlow');
        (['issued', 'transfer', 'donate', 'mall', 'balance'] as const).forEach(key =>
            ctx.markDemo(`pointFlow.${key}`)
        );
        return out;
    }
    let hasLive = false;
    (['issued', 'transfer', 'donate', 'mall', 'balance'] as const).forEach(key => {
        if (Number(live[key]) > 0) {
            out[key] = live[key];
            ctx.markLive(`pointFlow.${key}`);
            hasLive = true;
        } else {
            ctx.markDemo(`pointFlow.${key}`);
        }
    });
    if (hasLive) ctx.markLive('pointFlow');
    else ctx.markDemo('pointFlow');
    return out;
}
function pickOrdersByType(
    live: ScreenData['ordersByType'] | undefined,
    demo: ScreenData['ordersByType'],
    ctx: FieldTracker
) {
    const keys = ['现场回收', '预约上门', '大屏投递', '活动回收'] as const;
    const liveValid = live?.axis?.length && keys.some(k => hasPositive(live[k] || []));
    if (liveValid) {
        ctx.markLive('ordersByType');
        return live!;
    }
    ctx.markDemo('ordersByType');
    return demo;
}
function pickAppointmentFlow(
    live: ScreenData['appointmentFlow'] | undefined,
    demo: ScreenData['appointmentFlow'],
    ctx: FieldTracker
) {
    if (live?.length && live.some(e => Number(e.value) > 0)) {
        ctx.markLive('appointmentFlow');
        return live;
    }
    ctx.markDemo('appointmentFlow');
    return demo;
}
function pickSorterStatus(
    live: ScreenData['sorterStatus'] | undefined,
    demo: ScreenData['sorterStatus'],
    ctx: FieldTracker
) {
    if (live?.length && sum(live.map(e => Number(e.value))) > 0) {
        ctx.markLive('sorterStatus');
        return live;
    }
    ctx.markDemo('sorterStatus');
    return demo;
}
function pickAxis(
    live: ScreenData | null | undefined,
    demo: ScreenData,
    structure: ScreenData,
    ctx: FieldTracker
) {
    const useLiveAxis =
        live?.axis?.length &&
        (sum(live.trend?.weight || []) > 0 ||
            sum(live.participateTrend || []) > 0 ||
            sum(live.deliverTrend || []) > 0 ||
            live.axis.length > 1);
    if (useLiveAxis) {
        ctx.markLive('axis');
        return live!.axis;
    }
    ctx.markDemo('axis');
    return demo.axis.length ? demo.axis : structure.axis;
}
/** 结构 + 演示 + 真实数据按字段/分组合并 */
export function resolveScreenData(live: ScreenData | null | undefined, range: TimeRange): ResolvedScreenData {
    const structure = structureScreen(range);
    const demo = demoScreen(range);
    const ctx = new FieldTracker();
    const axis = pickAxis(live, demo, structure, ctx);
    const kpis = pickKpis(live?.kpis, demo.kpis, ctx);
    const wasteMix = pickWasteMix(live?.wasteMix, demo.wasteMix, ctx);
    const recycleItems = pickRecycleItems(live?.recycleItems, demo.recycleItems, ctx);
    const trend = pickTrend('trend', live?.trend, demo.trend, axis, ctx);
    let yearCarbon = demo.yearCarbon;
    if (live?.yearCarbon?.length && sum(live.yearCarbon) > 0) {
        yearCarbon = live.yearCarbon;
        ctx.markLive('yearCarbon');
    } else {
        ctx.markDemo('yearCarbon');
    }
    const deptRank = pickArray('deptRank', live?.deptRank, demo.deptRank, ctx, hasRankData);
    const classRank = pickArray('classRank', live?.classRank, demo.classRank, ctx, hasRankData);
    const majorRank = pickArray('majorRank', live?.majorRank, demo.majorRank, ctx, hasRankData);
    const pointFlow = pickPointFlow(live?.pointFlow, demo.pointFlow, ctx);
    let participateTrend = alignSeries(demo.participateTrend, axis.length);
    if (live?.participateTrend?.length && sum(live.participateTrend) > 0) {
        participateTrend = alignSeries(live.participateTrend, axis.length);
        ctx.markLive('participateTrend');
    } else {
        ctx.markDemo('participateTrend');
    }
    let deliverTrend = alignSeries(demo.deliverTrend, axis.length);
    if (live?.deliverTrend?.length && sum(live.deliverTrend) > 0) {
        deliverTrend = alignSeries(live.deliverTrend, axis.length);
        ctx.markLive('deliverTrend');
    } else {
        ctx.markDemo('deliverTrend');
    }
    const ordersByType = pickOrdersByType(live?.ordersByType, demo.ordersByType, ctx);
    const appointmentFlow = pickAppointmentFlow(live?.appointmentFlow, demo.appointmentFlow, ctx);
    const sorterStatus = pickSorterStatus(live?.sorterStatus, demo.sorterStatus, ctx);
    const vehicles = pickArray('vehicles', live?.vehicles, demo.vehicles, ctx, v => v.length > 0);
    const sites = pickArray('sites', live?.sites, demo.sites, ctx, v => v.length > 0);
    const data: ScreenData = {
        range,
        axis,
        kpis,
        wasteMix,
        recycleItems,
        trend,
        yearCarbon,
        deptRank,
        classRank,
        majorRank,
        pointFlow,
        participateTrend,
        deliverTrend,
        ordersByType,
        appointmentFlow,
        sorterStatus,
        vehicles,
        sites,
        unavailable: live?.unavailable || []
    };
    return {
        data,
        mode: resolveDataMode(live, ctx),
        demoFields: ctx.listDemo(),
        liveFields: ctx.listLive()
    };
}
/** 接口失败或无 live 数据时的初始展示 */
export function initialScreenData(range: TimeRange = 'month') {
    return resolveScreenData(null, range);
}
export { structureScreen, buildScreenAxis } from './screen-structure';
export { demoScreen } from './screen-demo';
src/modules/report/data/screen-structure.ts
New file
@@ -0,0 +1,162 @@
import type { ScreenData, TimeRange } from './screen';
export const WASTE_COLORS: Record<string, string> = {
    SW62: '#16a34a',
    SW64: '#64748b',
    SW63: '#f59e0b',
    SW61: '#ef4444',
    SW60: '#06b6d4'
};
export const ITEM_COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#a855f7', '#06b6d4', '#ef4444', '#94a3b8'];
const WASTE_ITEMS = [
    { code: 'SW62', name: '可回收物' },
    { code: 'SW64', name: '其他垃圾' },
    { code: 'SW63', name: '大件垃圾' },
    { code: 'SW61', name: '有害垃圾' },
    { code: 'SW60', name: '湿垃圾' }
];
const RECYCLE_NAMES = ['废纸', '塑料', '金属', '织物', '玻璃', '电子产品', '其他可回收'];
/** 与后端 buildTrend 对齐的时间轴 */
export function buildScreenAxis(range: TimeRange): string[] {
    const now = new Date();
    const pad = (n: number) => String(n).padStart(2, '0');
    const fmt = (d: Date) => `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
    if (range === 'year') {
        return Array.from({ length: 12 }, (_, i) => `${i + 1}月`);
    }
    let start: Date;
    if (range === 'today') {
        start = new Date(now);
        start.setDate(start.getDate() - 6);
    } else if (range === 'week') {
        start = new Date(now);
        const day = start.getDay() || 7;
        start.setDate(start.getDate() - day + 1);
    } else if (range === 'quarter') {
        start = new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 1);
    } else {
        start = new Date(now.getFullYear(), now.getMonth(), 1);
    }
    const axis: string[] = [];
    const cursor = new Date(start);
    while (cursor <= now) {
        axis.push(fmt(cursor));
        cursor.setDate(cursor.getDate() + 1);
    }
    return axis;
}
/** 只保证图表/表格结构完整,数值为 0 */
export function structureScreen(range: TimeRange = 'month'): ScreenData {
    const axis = buildScreenAxis(range);
    const orderAxis =
        range === 'year'
            ? axis
            : (() => {
                    const days: string[] = [];
                    const end = new Date();
                    const start = new Date(end);
                    start.setDate(start.getDate() - 6);
                    const pad = (n: number) => String(n).padStart(2, '0');
                    const cursor = new Date(start);
                    while (cursor <= end) {
                        days.push(`${pad(cursor.getMonth() + 1)}-${pad(cursor.getDate())}`);
                        cursor.setDate(cursor.getDate() + 1);
                    }
                    return days;
              })();
    return {
        range,
        axis,
        kpis: {
            todayWeightKg: 0,
            periodWeightTon: 0,
            resourceRate: 0,
            periodValue: 0,
            periodCarbonTon: 0,
            totalWeightTon: 0,
            totalParticipants: 0,
            totalPoints: 0,
            recyclableTon: 0,
            bulkTon: 0,
            elecTon: 0,
            fabricTon: 0,
            yearCarbonTon: 0,
            monthCarbonTon: 0,
            totalCarbonTon: 0,
            greenActions: 0,
            newParticipants: 0,
            participateRate: 0,
            pointsIssued: 0,
            pointsConsumed: 0,
            pointsBalance: 0,
            sorterOnline: 0,
            sorterTotal: 0,
            vehicleOnline: 0,
            vehicleTotal: 0,
            siteOnline: 0,
            siteTotal: 0,
            todayOrders: 0,
            todayAppointments: 0,
            pendingAppointments: 0,
            alerts: 0,
            healthAlerts: 0
        },
        wasteMix: WASTE_ITEMS.map(e => ({
            name: e.name,
            code: e.code,
            weight: 0,
            value: 0,
            count: 0,
            color: WASTE_COLORS[e.code]
        })),
        recycleItems: RECYCLE_NAMES.map((name, i) => ({
            name,
            weight: 0,
            value: 0,
            carbon: 0,
            color: ITEM_COLORS[i]
        })),
        trend: {
            weight: axis.map(() => 0),
            value: axis.map(() => 0),
            carbon: axis.map(() => 0)
        },
        yearCarbon: Array.from({ length: 12 }, () => 0),
        deptRank: [],
        classRank: [],
        majorRank: [],
        pointFlow: { issued: 0, transfer: 0, donate: 0, mall: 0, balance: 0 },
        participateTrend: axis.map(() => 0),
        deliverTrend: axis.map(() => 0),
        ordersByType: {
            axis: orderAxis,
            现场回收: orderAxis.map(() => 0),
            预约上门: orderAxis.map(() => 0),
            大屏投递: orderAxis.map(() => 0),
            活动回收: orderAxis.map(() => 0)
        },
        appointmentFlow: [
            { name: '预约', value: 0 },
            { name: '接单', value: 0 },
            { name: '完成', value: 0 }
        ],
        sorterStatus: [
            { name: '工作中', value: 0, color: '#22c55e' },
            { name: '休息', value: 0, color: '#f59e0b' },
            { name: '异常', value: 0, color: '#ef4444' },
            { name: '离线', value: 0, color: '#94a3b8' }
        ],
        vehicles: [],
        sites: [],
        unavailable: []
    };
}
src/modules/report/data/screen.ts
@@ -40,68 +40,9 @@
    unavailable?: string[];
}
import { structureScreen } from './screen-structure';
/** 零值结构兜底,图表轴与品类名称仍完整 */
export function emptyScreen(range: TimeRange = 'month'): ScreenData {
    return {
        range,
        axis: [],
        kpis: {
            todayWeightKg: 0,
            periodWeightTon: 0,
            resourceRate: 0,
            periodValue: 0,
            periodCarbonTon: 0,
            totalWeightTon: 0,
            totalParticipants: 0,
            totalPoints: 0,
            recyclableTon: 0,
            bulkTon: 0,
            elecTon: 0,
            fabricTon: 0,
            yearCarbonTon: 0,
            monthCarbonTon: 0,
            totalCarbonTon: 0,
            greenActions: 0,
            newParticipants: 0,
            participateRate: 0,
            pointsIssued: 0,
            pointsConsumed: 0,
            pointsBalance: 0,
            sorterOnline: 0,
            sorterTotal: 0,
            vehicleOnline: 0,
            vehicleTotal: 0,
            siteOnline: 0,
            siteTotal: 0,
            todayOrders: 0,
            todayAppointments: 0,
            pendingAppointments: 0,
            alerts: 0,
            healthAlerts: 0
        },
        wasteMix: [],
        recycleItems: [],
        trend: { weight: [], value: [], carbon: [] },
        yearCarbon: Array.from({ length: 12 }, () => 0),
        deptRank: [],
        classRank: [],
        majorRank: [],
        pointFlow: { issued: 0, transfer: 0, donate: 0, mall: 0, balance: 0 },
        participateTrend: [],
        deliverTrend: [],
        ordersByType: { axis: [], 现场回收: [], 预约上门: [], 大屏投递: [], 活动回收: [] },
        appointmentFlow: [
            { name: '预约', value: 0 },
            { name: '接单', value: 0 },
            { name: '完成', value: 0 }
        ],
        sorterStatus: [
            { name: '工作中', value: 0, color: '#22c55e' },
            { name: '休息', value: 0, color: '#f59e0b' },
            { name: '异常', value: 0, color: '#ef4444' },
            { name: '离线', value: 0, color: '#94a3b8' }
        ],
        vehicles: [],
        sites: [],
        unavailable: []
    };
    return structureScreen(range);
}
src/modules/report/hooks/use-screen.ts
@@ -2,10 +2,24 @@
import { useDark } from '@vueuse/core';
import { useTheme } from '/#/theme';
import { useCool } from '/@/cool';
import { emptyScreen, type ScreenData, type TimeRange } from '../data/screen';
import {
    groupDemoFields,
    isDemoField,
    isDemoSection
} from '../data/screen-field-meta';
import {
    initialScreenData,
    resolveScreenData,
    type ScreenDataMode
} from '../data/screen-merge';
import type { ScreenData, TimeRange } from '../data/screen';
const sharedTimeRange = ref<TimeRange>('month');
const screenData = ref<ScreenData>(emptyScreen());
const initial = initialScreenData(sharedTimeRange.value);
const screenData = ref<ScreenData>(initial.data);
const dataMode = ref<ScreenDataMode>(initial.mode);
const demoFields = ref<string[]>(initial.demoFields);
const liveFields = ref<string[]>(initial.liveFields);
const loading = ref(false);
const loadError = ref('');
@@ -21,6 +35,9 @@
    return {
        timeRange: sharedTimeRange,
        data: screenData,
        dataMode,
        demoFields,
        liveFields,
        loading,
        loadError,
        reload: () => loadImpl()
@@ -29,6 +46,13 @@
let inited = false;
let loadImpl: () => Promise<void> = async () => {};
function applyResolved(resolved: ReturnType<typeof resolveScreenData>) {
    screenData.value = resolved.data;
    dataMode.value = resolved.mode;
    demoFields.value = resolved.demoFields;
    liveFields.value = resolved.liveFields;
}
export function useScreenLoader() {
    const { service } = useCool();
@@ -46,17 +70,9 @@
                        method: 'POST',
                        data: payload
                  });
            const fallback = emptyScreen(sharedTimeRange.value);
            screenData.value = {
                ...fallback,
                ...(res || {}),
                kpis: { ...fallback.kpis, ...(res?.kpis || {}) },
                trend: { ...fallback.trend, ...(res?.trend || {}) },
                pointFlow: { ...fallback.pointFlow, ...(res?.pointFlow || {}) },
                ordersByType: { ...fallback.ordersByType, ...(res?.ordersByType || {}) }
            };
            applyResolved(resolveScreenData(res, sharedTimeRange.value));
        } catch (e: any) {
            screenData.value = emptyScreen(sharedTimeRange.value);
            applyResolved(resolveScreenData(null, sharedTimeRange.value));
            loadError.value = e?.message || '驾驶舱接口调用失败';
        } finally {
            loading.value = false;
@@ -72,7 +88,29 @@
    load();
    return { load, loading, loadError };
    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 demoField(field: string) {
        return isDemoField(field, demoFields.value, dataMode.value);
    }
    return {
        dataMode,
        demoSections,
        isDemoSection: demoSection,
        isDemoField: demoField
    };
}
export function useChartTheme() {
src/modules/report/views/screen/carbon.vue
@@ -2,18 +2,18 @@
    <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" />
                    <kpi-card v-bind="item" :demo="isDemoField(item.field)" />
                </el-col>
            </el-row>
            <el-row :gutter="10" class="mid-row">
                <el-col :lg="14" :xs="24">
                    <panel-card title="碳减排趋势" sub="今日看近 7 日;年视图按月,其余按日">
                    <panel-card title="碳减排趋势" sub="今日看近 7 日;年视图按月,其余按日" :demo="carbonTrendDemo">
                        <v-chart :option="trendOption" autoresize class="chart" />
                    </panel-card>
                </el-col>
                <el-col :lg="10" :xs="24">
                    <panel-card title="碳减排来源" sub="按可回收子类拆分">
                    <panel-card title="碳减排来源" sub="按可回收子类拆分" :demo="isDemoSection('recycleItems')">
                        <v-chart :option="sourceOption" autoresize class="chart" />
                    </panel-card>
                </el-col>
@@ -21,7 +21,7 @@
            <el-row :gutter="10">
                <el-col :lg="16" :xs="24">
                    <panel-card title="本年度月度碳减排" sub="日表 carbonTotal 单位 kgCO₂e,展示时 ÷1000 为吨">
                    <panel-card title="本年度月度碳减排" sub="日表 carbonTotal 单位 kgCO₂e,展示时 ÷1000 为吨" :demo="isDemoSection('yearCarbon')">
                        <v-chart :option="yearOption" autoresize class="chart" />
                    </panel-card>
                </el-col>
@@ -47,20 +47,25 @@
import { computed } from 'vue';
import KpiCard from '../../components/kpi-card.vue';
import PanelCard from '../../components/panel-card.vue';
import { useChartTheme, useScreenData } from '../../hooks/use-screen';
import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen';
const { data, timeRange } = useScreenData();
const { isDemoField, isDemoSection } = useScreenFieldSource();
const { textColor, mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme();
const kpis = computed(() => [
    { label: '累计碳减排', value: data.value.kpis.totalCarbonTon, suffix: '吨CO₂e', fixed: 1 },
    { label: '本年度碳减排', value: data.value.kpis.yearCarbonTon, suffix: '吨CO₂e', fixed: 1 },
    { label: '本区间碳减排', value: data.value.kpis.periodCarbonTon, suffix: '吨CO₂e', fixed: 2 },
    { label: '累计资源回收', value: data.value.kpis.totalWeightTon, suffix: '吨', fixed: 1 },
    { label: '累计参与师生', value: data.value.kpis.totalParticipants, suffix: '人', fixed: 0 },
    { label: '累计环保行为', value: data.value.kpis.greenActions, suffix: '次', fixed: 0 }
    { field: 'kpis.totalCarbonTon', label: '累计碳减排', value: data.value.kpis.totalCarbonTon, suffix: '吨CO₂e', fixed: 1 },
    { field: 'kpis.yearCarbonTon', label: '本年度碳减排', value: data.value.kpis.yearCarbonTon, suffix: '吨CO₂e', fixed: 1 },
    { field: 'kpis.periodCarbonTon', label: '本区间碳减排', value: data.value.kpis.periodCarbonTon, suffix: '吨CO₂e', fixed: 2 },
    { field: 'kpis.totalWeightTon', label: '累计资源回收', value: data.value.kpis.totalWeightTon, suffix: '吨', fixed: 1 },
    { field: 'kpis.totalParticipants', label: '累计参与师生', value: data.value.kpis.totalParticipants, suffix: '人', fixed: 0 },
    { field: 'kpis.greenActions', label: '累计环保行为', value: data.value.kpis.greenActions, suffix: '次', fixed: 0 }
]);
const carbonTrendDemo = computed(() =>
    timeRange.value === 'year' ? isDemoSection('yearCarbon') : isDemoSection('trend')
);
const trendOption = computed(() => {
    const isYear = timeRange.value === 'year';
    return {
src/modules/report/views/screen/operation.vue
@@ -3,40 +3,46 @@
        <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}`" />
                    :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}`" />
                    :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},按站点启用`" />
                    :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" :hint="``" />
                <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}`" />
                    :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},不展示隐私体征`" />
                    :hint="`健康异常 ${data.kpis.healthAlerts},不展示隐私体征`"
                    :demo="isDemoField('kpis.alerts')" />
            </el-col>
            <el-col :lg="4" :sm="8" :xs="12">
                <kpi-card label="车辆安全事件" :value="safetyCount" hint="超速 / 急刹等,来自车辆日汇总" />
                <kpi-card label="车辆安全事件" :value="safetyCount" hint="超速 / 急刹等,来自车辆日汇总"
                    :demo="isDemoSection('vehicles')" />
            </el-col>
        </el-row>
        <el-row :gutter="10" class="mid-row">
            <el-col :lg="14" :xs="24">
                <panel-card title="订单趋势" sub="现场 / 预约 / 大屏 / 活动,按天对比">
                <panel-card title="订单趋势" sub="现场 / 预约 / 大屏 / 活动,按天对比" :demo="isDemoSection('ordersByType')">
                    <v-chart :option="orderOption" autoresize class="chart" />
                </panel-card>
            </el-col>
            <el-col :lg="10" :xs="24">
                <panel-card title="预约订单漏斗" sub="新增/待审核 → 已确认/已接单 → 已完成">
                <panel-card title="预约订单漏斗" sub="新增/待审核 → 已确认/已接单 → 已完成" :demo="isDemoSection('appointmentFlow')">
                    <div class="funnel">
                        <div v-for="(item, index) in data.appointmentFlow" :key="item.name" class="funnel__item">
                            <div class="funnel__name">{{ item.name }}</div>
@@ -54,7 +60,7 @@
        <el-row :gutter="10">
            <el-col :lg="8" :xs="24">
                <panel-card title="分拣员状态" sub="来自分拣员日表,只汇总状态,不展示体征明细">
                <panel-card title="分拣员状态" sub="来自分拣员日表,只汇总状态,不展示体征明细" :demo="isDemoSection('sorterStatus')">
                    <div class="status-list">
                        <div v-for="item in data.sorterStatus" :key="item.name" class="status-list__item">
                            <i :style="{ background: item.color }" />
@@ -65,7 +71,7 @@
                </panel-card>
            </el-col>
            <el-col :lg="8" :xs="24">
                <panel-card title="车辆运行" sub="公司领导看运力与安全,不进校领导首页">
                <panel-card title="车辆运行" sub="公司领导看运力与安全,不进校领导首页" :demo="isDemoSection('vehicles')">
                    <div class="table table--vehicle">
                        <div class="table__head">
                            <span>车牌</span>
@@ -85,7 +91,7 @@
                </panel-card>
            </el-col>
            <el-col :lg="8" :xs="24">
                <panel-card title="分拣点吞吐" sub="运营维度才下钻到点位">
                <panel-card title="分拣点吞吐" sub="运营维度才下钻到点位" :demo="isDemoSection('sites')">
                    <div class="table table--site">
                        <div class="table__head">
                            <span>收集点</span>
@@ -112,9 +118,10 @@
import { computed } from 'vue';
import KpiCard from '../../components/kpi-card.vue';
import PanelCard from '../../components/panel-card.vue';
import { useChartTheme, useScreenData } from '../../hooks/use-screen';
import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen';
const { data } = useScreenData();
const { isDemoField, isDemoSection } = useScreenFieldSource();
const { mutedColor, splitColor, tooltip } = useChartTheme();
const safetyCount = computed(() => data.value.vehicles.reduce((s, e) => s + e.overspeed, 0));
src/modules/report/views/screen/overview.vue
@@ -1,38 +1,46 @@
<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" />
                </el-col>
            </el-row>
        <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>
            <el-row :gutter="10" class="mid-row">
                <el-col :lg="10" :xs="24">
                    <panel-card title="垃圾分类结构" sub="学校管理层看结构,不看单次称重">
                        <template #extra>
                            <cl-select-button v-model="mixMetric" :options="mixOptions" small />
                        </template>
                        <v-chart :option="mixOption" autoresize class="chart" />
                    </panel-card>
                </el-col>
                <el-col :lg="14" :xs="24">
                    <panel-card title="回收趋势" :sub="trendHint">
                        <template #extra>
                            <cl-select-button v-model="trendMetric" :options="trendOptions" small />
                        </template>
                        <v-chart :option="trendOption" autoresize class="chart" />
                    </panel-card>
                </el-col>
            </el-row>
        <el-row :gutter="10" class="charts-row">
            <el-col :lg="7" :xs="24">
                <panel-card title="垃圾分类结构" sub="学校管理层看结构,不看单次称重" :demo="isDemoSection('wasteMix')">
                    <template #extra>
                        <cl-select-button v-model="mixMetric" :options="mixOptions" small />
                    </template>
                    <v-chart :option="mixOption" autoresize class="chart" />
                </panel-card>
            </el-col>
            <el-col :lg="10" :xs="24">
                <panel-card title="回收趋势" :sub="trendHint" :demo="isDemoSection('trend')">
                    <template #extra>
                        <cl-select-button v-model="trendMetric" :options="trendOptions" small />
                    </template>
                    <v-chart :option="trendOption" autoresize class="chart" />
                </panel-card>
            </el-col>
            <el-col :lg="7" :xs="24">
                <panel-card title="各院系参与人数" sub="按用户学院名称 collegeName 汇总" :demo="isDemoSection('deptRank')">
                    <v-chart :option="rankOption" autoresize class="chart chart--bar" />
                </panel-card>
            </el-col>
        </el-row>
            <el-row :gutter="10">
                <el-col :span="24">
                    <panel-card title="各院系参与人数" sub="按用户学院名称 collegeName 汇总,人均按参与人数">
                        <v-chart :option="rankOption" autoresize class="chart chart--bar" />
                    </panel-card>
                </el-col>
            </el-row>
        </div>
        <el-row :gutter="10" class="camera-row">
            <el-col :span="24">
                <panel-card title="现场监控" sub="重点站点实时画面,首帧后暂停拉流以节省带宽">
                    <template #extra>
                        <router-link class="camera-more" to="/monitor/device">更多</router-link>
                    </template>
                    <CameraMonitor :limit="6" />
                </panel-card>
            </el-col>
        </el-row>
    </div>
</template>
<script lang="ts" setup>
@@ -41,11 +49,14 @@
});
import { computed, ref } from 'vue';
import CameraMonitor from '../../../monitor/views/cameras.vue';
import KpiCard from '../../components/kpi-card.vue';
import PanelCard from '../../components/panel-card.vue';
import { useChartTheme, useScreenData } from '../../hooks/use-screen';
import ScreenCameraPreview from '../../components/screen-camera-preview.vue';
import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen';
const { data, timeRange } = useScreenData();
const { isDemoField, isDemoSection } = useScreenFieldSource();
const { textColor, mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme();
const mixMetric = ref<'weight' | 'value' | 'count'>('weight');
@@ -64,14 +75,14 @@
];
const kpis = computed(() => [
    { label: '今日回收量', value: data.value.kpis.todayWeightKg, suffix: 'kg', fixed: 1, hint: '实时称重汇总' },
    { label: '本区间回收量', value: data.value.kpis.periodWeightTon, suffix: '吨', fixed: 2, hint: '当前时间范围' },
    { label: '资源化率', value: data.value.kpis.resourceRate, suffix: '%', fixed: 1, hint: '可回收物重量 / 总重量,不含大件和电子' },
    { label: '回收价值', value: data.value.kpis.periodValue, suffix: '元', fixed: 0, hint: '日表估算销售额' },
    { label: '碳减排', value: data.value.kpis.periodCarbonTon, suffix: '吨CO₂e', fixed: 2, hint: '分类重量 × 碳减因数' },
    { label: '累计回收量', value: data.value.kpis.totalWeightTon, suffix: '吨', fixed: 1, hint: '上线以来累计' },
    { label: '累计参与人数', value: data.value.kpis.totalParticipants, suffix: '人', fixed: 0, hint: '去重校园用户' },
    { label: '累计碳积分', value: data.value.kpis.totalPoints, suffix: '分', fixed: 0, hint: '账户余额合计' }
    { field: 'kpis.todayWeightKg', label: '今日回收量', value: data.value.kpis.todayWeightKg, suffix: 'kg', fixed: 1, hint: '实时称重汇总' },
    { field: 'kpis.periodWeightTon', label: '本区间回收量', value: data.value.kpis.periodWeightTon, suffix: '吨', fixed: 2, hint: '当前时间范围' },
    { field: 'kpis.resourceRate', label: '资源化率', value: data.value.kpis.resourceRate, suffix: '%', fixed: 1, hint: '可回收物重量/总重量,不含大件' },
    { field: 'kpis.periodValue', label: '回收价值', value: data.value.kpis.periodValue, suffix: '元', fixed: 0, hint: '日表估算销售额' },
    { field: 'kpis.periodCarbonTon', label: '碳减排', value: data.value.kpis.periodCarbonTon, suffix: '吨CO₂e', fixed: 2, hint: '分类重量 × 碳减因数' },
    { field: 'kpis.totalWeightTon', label: '累计回收量', value: data.value.kpis.totalWeightTon, suffix: '吨', fixed: 1, hint: '上线以来累计' },
    { field: 'kpis.totalParticipants', label: '累计参与人数', value: data.value.kpis.totalParticipants, suffix: '人', fixed: 0, hint: '去重校园用户' },
    { field: 'kpis.totalPoints', label: '累计碳积分', value: data.value.kpis.totalPoints, suffix: '分', fixed: 0, hint: '账户余额合计' }
]);
const mixOption = computed(() => ({
@@ -162,7 +173,8 @@
<style lang="scss" scoped>
.screen-page {
    .kpi-row,
    .mid-row {
    .charts-row,
    .camera-row {
        margin-bottom: 10px;
    }
@@ -172,19 +184,19 @@
        }
    }
    .mid-row {
    .charts-row {
        :deep(.panel-card) {
            min-height: 360px;
            min-height: 380px;
        }
    }
    .chart {
        height: 300px;
        height: 340px;
        width: 100%;
        &--bar {
            height: 320px;
            height: 370px;
        }
    }
}
</style>
</style>
src/modules/report/views/screen/participation.vue
@@ -2,13 +2,13 @@
    <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" />
                    <kpi-card v-bind="item" :demo="isDemoField(item.field)" />
                </el-col>
            </el-row>
            <el-row :gutter="10" class="mid-row">
                <el-col :lg="14" :xs="24">
                    <panel-card title="参与趋势" sub="可在人数与投递次数之间切换">
                    <panel-card title="参与趋势" sub="可在人数与投递次数之间切换" :demo="participationTrendDemo">
                        <template #extra>
                            <cl-select-button v-model="trendKind" :options="trendOptions" small />
                        </template>
@@ -16,7 +16,7 @@
                    </panel-card>
                </el-col>
                <el-col :lg="10" :xs="24">
                    <panel-card title="碳积分流转" sub="第一版用流向,不做 Sankey">
                    <panel-card title="碳积分流转" sub="第一版用流向,不做 Sankey" :demo="isDemoSection('pointFlow')">
                        <div class="flow">
                            <div class="flow__node is-in">
                                <span>发放</span>
@@ -49,7 +49,7 @@
            <el-row :gutter="10">
                <el-col :span="24">
                    <panel-card title="组织排行榜" sub="人均按参与人数;院系不统计参与率">
                    <panel-card title="组织排行榜" sub="人均按参与人数;院系不统计参与率" :demo="rankDemo">
                        <template #extra>
                            <div class="rank-tools">
                                <cl-select-button v-model="rankOrg" :options="orgOptions" small />
@@ -72,9 +72,10 @@
import KpiCard from '../../components/kpi-card.vue';
import PanelCard from '../../components/panel-card.vue';
import { type RankMetric } from '../../data/screen';
import { useChartTheme, useScreenData } from '../../hooks/use-screen';
import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen';
const { data } = useScreenData();
const { isDemoField, isDemoSection } = useScreenFieldSource();
const { textColor, mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme();
const trendKind = ref<'people' | 'times'>('people');
@@ -101,14 +102,24 @@
];
const kpis = computed(() => [
    { label: '参与师生', value: data.value.kpis.totalParticipants, suffix: '人', fixed: 0 },
    { label: '参与率', value: data.value.kpis.participateRate, suffix: '%', fixed: 1, hint: '参与人数 / 参数 student.count.{部门ID}' },
    { label: '本区间新增参与', value: data.value.kpis.newParticipants, suffix: '人', fixed: 0, hint: '区间内出现过的去重用户' },
    { label: '碳积分发放', value: data.value.kpis.pointsIssued, suffix: '分', fixed: 0 },
    { label: '碳积分消费', value: data.value.kpis.pointsConsumed, suffix: '分', fixed: 0 },
    { label: '碳积分余额', value: data.value.kpis.pointsBalance, suffix: '分', fixed: 0 }
    { field: 'kpis.totalParticipants', label: '参与师生', value: data.value.kpis.totalParticipants, suffix: '人', fixed: 0 },
    { field: 'kpis.participateRate', label: '参与率', value: data.value.kpis.participateRate, suffix: '%', fixed: 1, hint: '参与人数 / 参数 student.count.{部门ID}' },
    { field: 'kpis.newParticipants', label: '本区间新增参与', value: data.value.kpis.newParticipants, suffix: '人', fixed: 0, hint: '区间内出现过的去重用户' },
    { field: 'kpis.pointsIssued', label: '碳积分发放', value: data.value.kpis.pointsIssued, suffix: '分', fixed: 0 },
    { field: 'kpis.pointsConsumed', label: '碳积分消费', value: data.value.kpis.pointsConsumed, suffix: '分', fixed: 0 },
    { field: 'kpis.pointsBalance', label: '碳积分余额', value: data.value.kpis.pointsBalance, suffix: '分', fixed: 0 }
]);
const participationTrendDemo = computed(() =>
    trendKind.value === 'people' ? isDemoSection('participateTrend') : isDemoSection('deliverTrend')
);
const rankDemo = computed(() => {
    if (rankOrg.value === 'major') return isDemoSection('majorRank');
    if (rankOrg.value === 'dept') return isDemoSection('deptRank');
    return isDemoSection('classRank');
});
const trendOption = computed(() => ({
    grid: baseGrid(),
    tooltip: { trigger: 'axis', ...tooltip() },
src/modules/report/views/screen/resource.vue
@@ -2,18 +2,18 @@
    <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" />
                    <kpi-card v-bind="item" :demo="isDemoField(item.field)" />
                </el-col>
            </el-row>
            <el-row :gutter="10" class="mid-row">
                <el-col :lg="10" :xs="24">
                    <panel-card title="可回收物品类占比" sub="同一时间范围,看结构">
                    <panel-card title="可回收物品类占比" sub="同一时间范围,看结构" :demo="isDemoSection('recycleItems')">
                        <v-chart :option="pieOption" autoresize class="chart" />
                    </panel-card>
                </el-col>
                <el-col :lg="14" :xs="24">
                    <panel-card title="各类回收量" sub="横向柱状图更适合长品类名">
                    <panel-card title="各类回收量" sub="横向柱状图更适合长品类名" :demo="isDemoSection('recycleItems')">
                        <v-chart :option="weightOption" autoresize class="chart" />
                    </panel-card>
                </el-col>
@@ -21,7 +21,7 @@
            <el-row :gutter="10">
                <el-col :span="24">
                    <panel-card title="回收价值分析" sub="学校最关心:什么垃圾最值钱">
                    <panel-card title="回收价值分析" sub="学校最关心:什么垃圾最值钱" :demo="isDemoSection('recycleItems')">
                        <v-chart :option="valueOption" autoresize class="chart chart--value" />
                    </panel-card>
                </el-col>
@@ -37,17 +37,18 @@
import { computed } from 'vue';
import KpiCard from '../../components/kpi-card.vue';
import PanelCard from '../../components/panel-card.vue';
import { useChartTheme, useScreenData } from '../../hooks/use-screen';
import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen';
const { data } = useScreenData();
const { isDemoField, isDemoSection } = useScreenFieldSource();
const { textColor, mutedColor, splitColor, primary, tooltip } = useChartTheme();
const kpis = computed(() => [
    { label: '回收总量', value: data.value.kpis.periodWeightTon, suffix: '吨', fixed: 2, hint: '全部分类重量合计' },
    { label: '可回收物', value: data.value.kpis.recyclableTon, suffix: '吨', fixed: 2, hint: 'SW62' },
    { label: '大件垃圾', value: data.value.kpis.bulkTon, suffix: '吨', fixed: 2, hint: 'SW63' },
    { label: '电子产品', value: data.value.kpis.elecTon, suffix: '吨', fixed: 2, hint: '可回收子类' },
    { label: '废旧织物', value: data.value.kpis.fabricTon, suffix: '吨', fixed: 2, hint: '可回收子类' }
    { field: 'kpis.periodWeightTon', label: '回收总量', value: data.value.kpis.periodWeightTon, suffix: '吨', fixed: 2, hint: '全部分类重量合计' },
    { field: 'kpis.recyclableTon', label: '可回收物', value: data.value.kpis.recyclableTon, suffix: '吨', fixed: 2, hint: 'SW62' },
    { field: 'kpis.bulkTon', label: '大件垃圾', value: data.value.kpis.bulkTon, suffix: '吨', fixed: 2, hint: 'SW63' },
    { field: 'kpis.elecTon', label: '电子产品', value: data.value.kpis.elecTon, suffix: '吨', fixed: 2, hint: '可回收子类' },
    { field: 'kpis.fabricTon', label: '废旧织物', value: data.value.kpis.fabricTon, suffix: '吨', fixed: 2, hint: '可回收子类' }
]);
const pieOption = computed(() => ({