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