wr_1023
2026-09-03 974c13543c4534d4783eb521a5ba2af68f8b007b
修复首页内容
9个文件已修改
2个文件已添加
671 ■■■■■ 已修改文件
src/modules/helper/config.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/components/screen-map-panel.vue 267 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/data/screen.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/carbon.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/map.vue 356 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/operation.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/overview.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/participation.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/resource.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/plugins/github/config.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/helper/config.ts
@@ -7,11 +7,11 @@
            index: 'https://cool-js.com',
            api: 'https://service.cool-js.com/api'
        },
        toolbar: {
            h5: false,
            order: 1,
            component: import('./components/ai-code/btn.vue')
        },
        // toolbar: {
        //     h5: false,
        //     order: 1,
        //     component: import('./components/ai-code/btn.vue')
        // },
        pages: [
            {
                path: '/helper/ai-code',
src/modules/report/components/screen-map-panel.vue
New file
@@ -0,0 +1,267 @@
<template>
    <div class="map-panel">
        <!-- 顶部:图层切换 + 实时统计 -->
        <div class="map-panel__bar">
            <cl-select-button v-model="layer" :options="layerOptions" small />
            <div class="map-panel__stat">
                <span class="map-panel__pulse" :style="{ background: statColor }" />
                <strong>{{ list.length }}</strong>
                <span class="map-panel__unit">{{ statUnit }}</span>
                <span class="map-panel__update">{{ lastUpdate }}</span>
            </div>
        </div>
        <!-- 地图主体 -->
        <div class="map-panel__map">
            <tdt-map :center="center" :zoom="zoom" :controls="controls" :loadConfig="MAP_LOAD">
                <tdt-marker
                    v-for="item in list"
                    :key="keyOf(item)"
                    :position="pos(item)"
                    :icon="iconOf(item)"
                    :title="nameOf(item)"
                />
            </tdt-map>
        </div>
        <!-- 图例 -->
        <div class="map-panel__legend">
            <div v-for="l in activeLegends" :key="l.name" class="map-panel__legend-item">
                <i :style="{ background: l.color }" />{{ l.name }}
            </div>
        </div>
    </div>
</template>
<script lang="ts" setup>
defineOptions({
    name: 'screen-map-panel'
});
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { TdtMap, TdtMarker, type LngLat } from 'vue-tianditu2';
import {
    DEFAULT_CENTER,
    MAP_LOAD,
    SORTER_ICON,
    VEHICLE_ICON,
    formatTime,
    siteIcon,
    useMapsApi
} from '../../maps/hooks/use-map';
type LayerKey = 'site' | 'vehicle' | 'sorter';
/** 三图层统一配置:取数 / 唯一键 / 名称 / 图标 / 图例 */
const LAYERS: Record<
    LayerKey,
    {
        unit: string;
        color: string;
        load: () => Promise<any[]>;
        key: (e: any) => string;
        name: (e: any) => string;
        icon: (e: any) => any;
        legends: { name: string; color: string }[];
    }
> = {
    site: {
        unit: '个站点',
        color: '#16a34a',
        load: () => api.sites(),
        key: (e: any) => e.id,
        name: (e: any) => e.businessName,
        icon: (e: any) => siteIcon(e.siteType),
        legends: [
            { name: '收集点', color: '#16a34a' },
            { name: '压缩站', color: '#f59e0b' }
        ]
    },
    vehicle: {
        unit: '辆在线',
        color: '#7c3aed',
        load: () => api.vehicleOnline(),
        key: (e: any) => e.vehicleNo,
        name: (e: any) => e.vehicleNo,
        icon: () => VEHICLE_ICON,
        legends: [{ name: '在线车辆', color: '#7c3aed' }]
    },
    sorter: {
        unit: '人在线',
        color: '#2563eb',
        load: () => api.sorterOnline(),
        key: (e: any) => e.idCard,
        name: (e: any) => e.name || e.idCard,
        icon: () => SORTER_ICON,
        legends: [{ name: '在线分拣员', color: '#2563eb' }]
    }
};
const api = useMapsApi();
const layer = ref<LayerKey>('site');
const list = ref<any[]>([]);
const center = ref<LngLat>(DEFAULT_CENTER);
const zoom = ref(12);
const lastUpdate = ref('');
const controls = ['Zoom', 'Scale'];
const layerOptions = [
    { label: '站点', value: 'site' },
    { label: '车辆', value: 'vehicle' },
    { label: '分拣员', value: 'sorter' }
];
const cfg = computed(() => LAYERS[layer.value]);
const activeLegends = computed(() => cfg.value.legends);
const statColor = computed(() => cfg.value.color);
const statUnit = computed(() => cfg.value.unit);
function pos(item: any): LngLat {
    return [Number(item.longitude), Number(item.latitude)];
}
function keyOf(item: any) {
    return cfg.value.key(item);
}
function nameOf(item: any) {
    return cfg.value.name(item);
}
function iconOf(item: any) {
    return cfg.value.icon(item);
}
async function load() {
    const res = await cfg.value.load();
    list.value = Array.isArray(res) ? res : [];
    lastUpdate.value = formatTime(new Date());
    if (list.value[0]) {
        center.value = pos(list.value[0]);
        zoom.value = 13;
    }
}
// 车辆 / 分拣员为实时位置,30s 轮询;站点是静态台账,不轮询
let timer: number | undefined;
function startPolling() {
    stopPolling();
    if (layer.value !== 'site') {
        timer = window.setInterval(load, 30000);
    }
}
function stopPolling() {
    if (timer) {
        clearInterval(timer);
        timer = undefined;
    }
}
watch(layer, () => {
    load();
    startPolling();
});
onMounted(async () => {
    await load();
    startPolling();
});
onBeforeUnmount(stopPolling);
</script>
<style lang="scss" scoped>
.map-panel {
    display: flex;
    flex-direction: column;
    height: 100%;
    min-height: 0;
    &__bar {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 12px;
        padding: 0 4px 8px;
        flex-shrink: 0;
    }
    &__stat {
        display: flex;
        align-items: baseline;
        gap: 6px;
        font-size: 13px;
        color: var(--el-text-color-secondary);
        strong {
            font-size: 22px;
            font-weight: 700;
            color: var(--el-text-color-primary);
        }
    }
    &__pulse {
        width: 8px;
        height: 8px;
        border-radius: 50%;
        align-self: center;
        animation: map-pulse 1.6s ease-in-out infinite;
    }
    &__unit {
        font-size: 12px;
    }
    &__update {
        margin-left: 4px;
        font-size: 11px;
        color: var(--el-text-color-secondary);
        opacity: 0.75;
    }
    &__map {
        flex: 1;
        min-height: 0;
        border-radius: 8px;
        overflow: hidden;
        > * {
            width: 100%;
            height: 100%;
        }
    }
    &__legend {
        display: flex;
        align-items: center;
        gap: 16px;
        padding-top: 8px;
        flex-shrink: 0;
    }
    &__legend-item {
        display: flex;
        align-items: center;
        gap: 6px;
        font-size: 12px;
        color: var(--el-text-color-secondary);
        i {
            width: 8px;
            height: 8px;
            border-radius: 50%;
        }
    }
}
@keyframes map-pulse {
    0%,
    100% {
        opacity: 1;
        transform: scale(1);
    }
    50% {
        opacity: 0.4;
        transform: scale(0.75);
    }
}
</style>
src/modules/report/data/screen.ts
@@ -1,5 +1,5 @@
export type TimeRange = 'today' | 'week' | 'month' | 'quarter' | 'year';
export type ScreenKey = 'overview' | 'resource' | 'carbon' | 'participation' | 'operation';
export type ScreenKey = 'overview' | 'resource' | 'carbon' | 'participation' | 'operation' | 'map';
export type RankMetric = 'weight' | 'people' | 'pointPerCapita' | 'weightPerCapita' | 'carbon';
/** 首页菜单固定为 /report/screen/index,子页通过 ?page= 切换,避免离开首页入口 */
@@ -10,14 +10,16 @@
    { 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: '运营公司' }
    { key: 'operation', index: '05', label: '回收运营监控', audience: '运营公司' },
    { key: 'map', index: '06', 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';
    if (raw === 'index') return 'map';    //if (raw === 'index') return 'overview';
    return SCREEN_NAV.some(e => e.key === raw) ? (raw as ScreenKey) : 'map';
    //return SCREEN_NAV.some(e => e.key === raw) ? (raw as ScreenKey) : 'overview';
}
export const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [
src/modules/report/views/screen/carbon.vue
@@ -146,7 +146,7 @@
</script>
<style lang="scss" scoped>
@import '../../styles/screen-kpi.scss';
@use '../../styles/screen-kpi' as *;
.screen-page {
    .mid-row {
src/modules/report/views/screen/index.vue
@@ -12,6 +12,7 @@
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import ScreenShell from '../../components/screen-shell.vue';
import Map from './map.vue';
import Overview from './overview.vue';
import Resource from './resource.vue';
import Carbon from './carbon.vue';
@@ -29,11 +30,14 @@
    resource: Resource,
    carbon: Carbon,
    participation: Participation,
    operation: Operation
    operation: Operation,
    map: Map
};
/** 停留在 /report/screen/index,用 query.page(或动态路由 :page)切换子页,不再强制跳走 */
const currentKey = computed<ScreenKey>(() =>
    resolveScreenKey(route.params.page as string, route.query.page as string)
);
const currentKey = computed<ScreenKey>(() =>{
    const key = resolveScreenKey(route.params.page as string, route.query.page as string);
    console.log('[screen] params.page=', route.params.page, 'query.page=', route.query.page, '=> key=', key);
    return key;
});
</script>
src/modules/report/views/screen/map.vue
New file
@@ -0,0 +1,356 @@
<template>
    <div class="screen-page dash">
        <!-- KPI 指标条 -->
        <div class="kpi-grid" style="--kpi-cols: 8">
            <kpi-card v-for="item in kpis" :key="item.label" v-bind="item" />
        </div>
        <!-- 主体三栏:左图表 / 中地图 / 右图表 -->
        <div class="dash-body">
            <!-- 左栏 -->
            <div class="dash-col dash-col--left">
                <panel-card
                    title="垃圾分类结构"
                    :demo="isDemoSection('wasteMix')"
                    class="dash-card dash-card--flex"
                >
                    <template #extra>
                        <cl-select-button v-model="mixMetric" :options="mixOptions" small />
                    </template>
                    <v-chart :option="mixOption" autoresize class="chart" />
                </panel-card>
                <panel-card
                    title="碳减排来源"
                    :demo="isDemoSection('recycleItems')"
                    class="dash-card dash-card--flex"
                >
                    <v-chart :option="carbonOption" autoresize class="chart" />
                </panel-card>
            </div>
            <!-- 中栏:地图主视觉 -->
            <div class="dash-col dash-col--center">
                <panel-card title="全域态势一张图" sub="站点 / 车辆 / 分拣员实时分布" class="dash-card dash-card--map">
                    <screen-map-panel />
                </panel-card>
            </div>
            <!-- 右栏 -->
            <div class="dash-col dash-col--right">
                <panel-card
                    title="回收趋势"
                    :demo="isDemoSection('trend')"
                    class="dash-card dash-card--flex"
                >
                    <template #extra>
                        <cl-select-button v-model="trendMetric" :options="trendOptions" small />
                    </template>
                    <v-chart :option="trendOption" autoresize class="chart" />
                </panel-card>
                <panel-card
                    title="各类回收量"
                    :demo="isDemoSection('recycleItems')"
                    class="dash-card dash-card--flex"
                >
                    <template #extra>
                        <cl-select-button v-model="itemMetric" :options="itemOptions" small />
                    </template>
                    <v-chart :option="itemOption" autoresize class="chart" />
                </panel-card>
            </div>
        </div>
        <!-- 底部:现场监控 -->
        <div class="dash-foot">
            <panel-card title="现场监控" sub="重点站点实时画面,首帧后暂停拉流以节省带宽">
                <template #extra>
                    <router-link class="camera-more" to="/monitor/device">更多</router-link>
                </template>
                <CameraMonitor :limit="6" />
            </panel-card>
        </div>
    </div>
</template>
<script lang="ts" setup>
defineOptions({
    name: 'report-screen-map'
});
import { computed, ref } from 'vue';
import KpiCard from '../../components/kpi-card.vue';
import PanelCard from '../../components/panel-card.vue';
import ScreenMapPanel from '../../components/screen-map-panel.vue';
import CameraMonitor from '../../../monitor/views/cameras.vue';
import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen';
const { data } = useScreenData();
const { isDemoSection } = useScreenFieldSource();
const { textColor, mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme();
const mixMetric = ref<'weight' | 'value' | 'count'>('weight');
const trendMetric = ref<'weight' | 'value' | 'carbon'>('weight');
const itemMetric = ref<'weight' | 'value' | 'carbon'>('weight');
const mixOptions = [
    { label: '重量', value: 'weight' },
    { label: '价值', value: 'value' },
    { label: '数量', value: 'count' }
];
const trendOptions = [
    { label: '重量', value: 'weight' },
    { label: '价值', value: 'value' },
    { label: '碳减排', value: 'carbon' }
];
const itemOptions = [
    { label: '重量', value: 'weight' },
    { label: '价值', value: 'value' },
    { label: '碳减排', value: 'carbon' }
];
/** 顶部 KPI:取总览域核心 8 项 */
const kpis = computed(() => [
    { field: 'kpis.todayWeightKg', label: '今日回收量', value: data.value.kpis.todayWeightKg, suffix: 'kg', fixed: 1 },
    { field: 'kpis.periodWeightTon', label: '本区间回收量', value: data.value.kpis.periodWeightTon, suffix: '吨', fixed: 2 },
    { field: 'kpis.resourceRate', label: '资源化率', value: data.value.kpis.resourceRate, suffix: '%', fixed: 1 },
    { field: 'kpis.periodValue', label: '回收价值', value: data.value.kpis.periodValue, suffix: '元', fixed: 0 },
    { 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.totalPoints', label: '累计碳积分', value: data.value.kpis.totalPoints, suffix: '分', fixed: 0 }
]);
const trendUnit = computed(() => {
    if (trendMetric.value === 'value') return '元';
    if (trendMetric.value === 'carbon') return 'kgCO₂e';
    return 'kg';
});
const itemUnit = computed(() => {
    if (itemMetric.value === 'value') return '元';
    if (itemMetric.value === 'carbon') return 'kgCO₂e';
    return 'kg';
});
/** 垃圾分类结构(饼) */
const mixOption = computed(() => ({
    tooltip: { trigger: 'item', formatter: '{b}<br/>{c} ({d}%)', ...tooltip() },
    series: [
        {
            type: 'pie',
            radius: ['38%', '62%'],
            center: ['50%', '50%'],
            padAngle: 3,
            itemStyle: { borderRadius: 6 },
            label: { color: textColor.value, formatter: '{b}\n{d}%' },
            data: data.value.wasteMix.map(e => ({
                name: e.name,
                value: e[mixMetric.value],
                itemStyle: { color: e.color }
            }))
        }
    ]
}));
/** 碳减排来源(饼) */
const carbonOption = computed(() => ({
    tooltip: { trigger: 'item', formatter: '{b}<br/>{c} kgCO₂e ({d}%)', ...tooltip() },
    series: [
        {
            type: 'pie',
            radius: ['36%', '60%'],
            center: ['50%', '50%'],
            padAngle: 3,
            itemStyle: { borderRadius: 6 },
            label: { color: textColor.value, formatter: '{b}\n{d}%' },
            data: data.value.recycleItems.map(e => ({
                name: e.name + '回收',
                value: e.carbon,
                itemStyle: { color: e.color }
            }))
        }
    ]
}));
/** 回收趋势(折线) */
const trendOption = computed(() => ({
    grid: baseGrid(),
    tooltip: { trigger: 'axis', ...tooltip() },
    xAxis: { type: 'category', data: data.value.axis, ...axisStyle() },
    yAxis: {
        type: 'value',
        name: trendUnit.value,
        nameTextStyle: { color: mutedColor.value },
        splitLine: { lineStyle: { color: splitColor.value, type: 'dashed' } },
        axisLabel: { color: mutedColor.value },
        axisLine: { show: false },
        axisTick: { show: false }
    },
    series: [
        {
            type: 'line',
            smooth: true,
            showSymbol: false,
            data: data.value.trend[trendMetric.value],
            itemStyle: { color: primary.value },
            areaStyle: { color: primary.value, opacity: 0.12 }
        }
    ]
}));
/** 各类回收量(横向条形) */
const itemOption = computed(() => {
    // 反转让数值最大的排在顶部
    const rows = [...data.value.recycleItems].reverse();
    return {
        grid: { containLabel: true, left: 8, right: 24, top: 12, bottom: 8 },
        tooltip: {
            trigger: 'axis',
            axisPointer: { type: 'shadow' },
            valueFormatter: (v: number) => `${Number(v || 0).toLocaleString()} ${itemUnit.value}`,
            ...tooltip()
        },
        xAxis: {
            type: 'value',
            name: itemUnit.value,
            nameTextStyle: { color: mutedColor.value },
            splitLine: { lineStyle: { color: splitColor.value, type: 'dashed' } },
            axisLabel: { color: mutedColor.value },
            axisLine: { show: false },
            axisTick: { show: false }
        },
        yAxis: {
            type: 'category',
            data: rows.map(e => e.name),
            axisLabel: { color: textColor.value },
            axisLine: { show: false },
            axisTick: { show: false }
        },
        series: [
            {
                type: 'bar',
                barWidth: 14,
                data: rows.map(e => Number(e[itemMetric.value] || 0)),
                itemStyle: {
                    borderRadius: [0, 8, 8, 0],
                    // 沿用各类自身配色,与左侧碳减排饼图视觉一致
                    color: (params: any) => rows[params.dataIndex]?.color || primary.value
                }
            }
        ]
    };
});
</script>
<style lang="scss" scoped>
@use '../../styles/screen-kpi' as *;
.dash {
    display: flex;
    flex-direction: column;
    gap: 10px;
    height: 100%;
    min-height: 0;
    overflow: auto;
    /* 主体三栏 */
    &-body {
        display: grid;
        grid-template-columns: 24% 46% 30%;
        gap: 10px;
        flex: 1;
        min-height: 420px;
    }
    &-col {
        display: flex;
        flex-direction: column;
        gap: 10px;
        min-width: 0;
        min-height: 0;
    }
    &-card {
        min-height: 0;
        /* 左右栏图表卡均分高度 */
        &--flex {
            flex: 1;
            display: flex;
            flex-direction: column;
            :deep(.panel-card__body) {
                flex: 1;
                min-height: 0;
            }
        }
        &--map {
            flex: 1;
            display: flex;
            flex-direction: column;
            :deep(.panel-card__body) {
                flex: 1;
                min-height: 0;
                padding: 10px;
            }
        }
    }
    /* 底部监控:压缩后给 CameraMonitor 一个紧凑高度 */
    &-foot {
        flex-shrink: 0;
        display: flex;
        flex-direction: column;
        height: clamp(200px, 20vh, 260px);
        :deep(.panel-card) {
            flex: 1;
            display: flex;
            flex-direction: column;
            min-height: 0;
        }
        :deep(.panel-card__head) {
            padding-top: 6px;
            padding-bottom: 6px;
        }
        :deep(.panel-card__body) {
            flex: 1;
            min-height: 0;
            overflow: hidden;
            padding: 6px;
        }
        .camera-more {
            font-size: 12px;
            color: var(--el-color-primary);
            text-decoration: none;
        }
    }
    .chart {
        height: 100%;
        min-height: 180px;
        width: 100%;
    }
}
/* 窄屏兜底:三栏改单栏纵向 */
@media (max-width: 1200px) {
    .dash-body {
        grid-template-columns: 1fr;
        min-height: 0;
        .dash-col--center {
            min-height: 420px;
        }
    }
}
</style>
src/modules/report/views/screen/operation.vue
@@ -168,7 +168,7 @@
</script>
<style lang="scss" scoped>
@import '../../styles/screen-kpi.scss';
@use '../../styles/screen-kpi' as *;
.screen-page {
    .mid-row {
src/modules/report/views/screen/overview.vue
@@ -169,7 +169,7 @@
</script>
<style lang="scss" scoped>
@import '../../styles/screen-kpi.scss';
@use '../../styles/screen-kpi' as *;
.screen-page {
    .charts-row,
src/modules/report/views/screen/participation.vue
@@ -179,7 +179,7 @@
</script>
<style lang="scss" scoped>
@import '../../styles/screen-kpi.scss';
@use '../../styles/screen-kpi' as *;
.screen-page {
    .mid-row {
src/modules/report/views/screen/resource.vue
@@ -129,7 +129,7 @@
</script>
<style lang="scss" scoped>
@import '../../styles/screen-kpi.scss';
@use '../../styles/screen-kpi' as *;
.screen-page {
    .mid-row {
src/plugins/github/config.ts
@@ -2,8 +2,8 @@
export default (): ModuleConfig => {
    return {
        toolbar: {
            component: import('./components/code.vue')
        }
        // toolbar: {
        //     component: import('./components/code.vue')
        // }
    };
};