From 17b92e9b60dda6dcc20c748046a5ff3c3958d52e Mon Sep 17 00:00:00 2001
From: wangrong <wangrong@shsening.com>
Date: 星期五, 28 八月 2026 16:19:11 +0800
Subject: [PATCH] 增加首页大屏显示
---
src/modules/report/views/screen/overview.vue | 104 ++-
src/modules/report/components/screen-camera-preview.vue | 148 +++++
src/modules/report/views/screen/carbon.vue | 27
src/modules/report/components/panel-card.vue | 6
src/modules/report/data/screen-merge.ts | 323 +++++++++++
src/modules/monitor/views/cameras.vue | 195 +++++++
src/modules/report/views/screen/participation.vue | 33
src/modules/report/data/screen-demo.ts | 215 +++++++
src/modules/report/views/screen/resource.vue | 21
src/modules/report/data/screen-structure.ts | 162 ++++++
src/modules/report/data/screen.ts | 67 --
src/modules/report/data/screen-field-meta.ts | 158 +++++
src/modules/report/components/kpi-card.vue | 19
src/modules/report/views/screen/operation.vue | 33
src/modules/report/hooks/use-screen.ts | 64 +
src/modules/report/components/screen-shell.vue | 18
16 files changed, 1,422 insertions(+), 171 deletions(-)
diff --git a/src/modules/monitor/views/cameras.vue b/src/modules/monitor/views/cameras.vue
new file mode 100644
index 0000000..19fe750
--- /dev/null
+++ b/src/modules/monitor/views/cameras.vue
@@ -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>
\ No newline at end of file
diff --git a/src/modules/report/components/kpi-card.vue b/src/modules/report/components/kpi-card.vue
index b011f78..ed1c0ce 100644
--- a/src/modules/report/components/kpi-card.vue
+++ b/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>
diff --git a/src/modules/report/components/panel-card.vue b/src/modules/report/components/panel-card.vue
index f101f2c..0dae349 100644
--- a/src/modules/report/components/panel-card.vue
+++ b/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;
diff --git a/src/modules/report/components/screen-camera-preview.vue b/src/modules/report/components/screen-camera-preview.vue
new file mode 100644
index 0000000..210bc90
--- /dev/null
+++ b/src/modules/report/components/screen-camera-preview.vue
@@ -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>
diff --git a/src/modules/report/components/screen-shell.vue b/src/modules/report/components/screen-shell.vue
index 0196816..e990e92 100644
--- a/src/modules/report/components/screen-shell.vue
+++ b/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="褰撳墠鎷夸笉鍒版垨鍙e緞寰呯‘璁ょ殑鎸囨爣" 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);
diff --git a/src/modules/report/data/screen-demo.ts b/src/modules/report/data/screen-demo.ts
new file mode 100644
index 0000000..7674f46
--- /dev/null
+++ b/src/modules/report/data/screen-demo.ts
@@ -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: '娌狝路8K126', km: 86.4, idle: 12, overspeed: 0, status: '鍦ㄧ嚎' },
+ { no: '娌狝路6D882', km: 72.1, idle: 8, overspeed: 1, status: '鍦ㄧ嚎' },
+ { no: '娌狝路3F559', km: 64.8, idle: 18, overspeed: 0, status: '鍦ㄧ嚎' },
+ { no: '娌狝路2H903', km: 58.2, idle: 6, overspeed: 0, status: '鍦ㄧ嚎' },
+ { no: '娌狝路9M217', km: 46.5, idle: 22, overspeed: 2, status: '鍦ㄧ嚎' },
+ { no: '娌狝路5P668', km: 38.6, idle: 0, overspeed: 0, status: '绂荤嚎' },
+ { no: '娌狝路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: '瀹為獙妤糂鍖�', weight: 148.2, online: true },
+ { name: '鐮旂┒鐢熷叕瀵撶珯', weight: 126.8, online: false },
+ { name: '鍗楅棬涓存椂鐐�', weight: 98.4, online: true }
+ ],
+ unavailable: []
+ };
+}
diff --git a/src/modules/report/data/screen-field-meta.ts b/src/modules/report/data/screen-field-meta.ts
new file mode 100644
index 0000000..33d4e63
--- /dev/null
+++ b/src/modules/report/data/screen-field-meta.ts
@@ -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));
+}
diff --git a/src/modules/report/data/screen-merge.ts b/src/modules/report/data/screen-merge.ts
new file mode 100644
index 0000000..3f76e64
--- /dev/null
+++ b/src/modules/report/data/screen-merge.ts
@@ -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';
diff --git a/src/modules/report/data/screen-structure.ts b/src/modules/report/data/screen-structure.ts
new file mode 100644
index 0000000..c35f682
--- /dev/null
+++ b/src/modules/report/data/screen-structure.ts
@@ -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: []
+ };
+}
diff --git a/src/modules/report/data/screen.ts b/src/modules/report/data/screen.ts
index a419dee..658c00a 100644
--- a/src/modules/report/data/screen.ts
+++ b/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);
}
diff --git a/src/modules/report/hooks/use-screen.ts b/src/modules/report/hooks/use-screen.ts
index 0ea549a..a6621c0 100644
--- a/src/modules/report/hooks/use-screen.ts
+++ b/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 || '椹鹃┒鑸辨帴鍙h皟鐢ㄥけ璐�';
} 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() {
diff --git a/src/modules/report/views/screen/carbon.vue b/src/modules/report/views/screen/carbon.vue
index 0d2b026..13110a8 100644
--- a/src/modules/report/views/screen/carbon.vue
+++ b/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鈧俥锛屽睍绀烘椂 梅1000 涓哄惃">
+ <panel-card title="鏈勾搴︽湀搴︾⒊鍑忔帓" sub="鏃ヨ〃 carbonTotal 鍗曚綅 kgCO鈧俥锛屽睍绀烘椂 梅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: '鍚–O鈧俥', fixed: 1 },
- { label: '鏈勾搴︾⒊鍑忔帓', value: data.value.kpis.yearCarbonTon, suffix: '鍚–O鈧俥', fixed: 1 },
- { label: '鏈尯闂寸⒊鍑忔帓', value: data.value.kpis.periodCarbonTon, suffix: '鍚–O鈧俥', 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: '鍚–O鈧俥', fixed: 1 },
+ { field: 'kpis.yearCarbonTon', label: '鏈勾搴︾⒊鍑忔帓', value: data.value.kpis.yearCarbonTon, suffix: '鍚–O鈧俥', fixed: 1 },
+ { field: 'kpis.periodCarbonTon', label: '鏈尯闂寸⒊鍑忔帓', value: data.value.kpis.periodCarbonTon, suffix: '鍚–O鈧俥', 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 {
diff --git a/src/modules/report/views/screen/operation.vue b/src/modules/report/views/screen/operation.vue
index e9c9cbc..42c3164 100644
--- a/src/modules/report/views/screen/operation.vue
+++ b/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));
diff --git a/src/modules/report/views/screen/overview.vue b/src/modules/report/views/screen/overview.vue
index 3b731fe..bab6c1d 100644
--- a/src/modules/report/views/screen/overview.vue
+++ b/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: '鍚–O鈧俥', 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: '鍚–O鈧俥', 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>
\ No newline at end of file
diff --git a/src/modules/report/views/screen/participation.vue b/src/modules/report/views/screen/participation.vue
index 565d355..4653c74 100644
--- a/src/modules/report/views/screen/participation.vue
+++ b/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() },
diff --git a/src/modules/report/views/screen/resource.vue b/src/modules/report/views/screen/resource.vue
index 4afc7d9..30b020a 100644
--- a/src/modules/report/views/screen/resource.vue
+++ b/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(() => ({
--
Gitblit v1.9.1