| New file |
| | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | fixed?: number; |
| | | hint?: string; |
| | | alert?: boolean; |
| | | demo?: boolean; |
| | | }>(); |
| | | |
| | | const numeric = computed(() => Number(props.value) || 0); |
| | |
| | | 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 { |
| | |
| | | color: var(--el-color-danger); |
| | | } |
| | | } |
| | | |
| | | &.is-demo:not(.is-alert) { |
| | | border-style: dashed; |
| | | } |
| | | } |
| | | </style> |
| | |
| | | <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"> |
| | |
| | | defineProps<{ |
| | | title: string; |
| | | sub?: string; |
| | | demo?: boolean; |
| | | }>(); |
| | | </script> |
| | | |
| | |
| | | color: var(--el-text-color-secondary); |
| | | } |
| | | |
| | | &__badge { |
| | | transform: translateY(-1px); |
| | | } |
| | | |
| | | &__body { |
| | | flex: 1; |
| | | min-height: 0; |
| New file |
| | |
| | | <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> |
| | |
| | | |
| | | <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> |
| | |
| | | |
| | | <section v-loading="loading" class="green-screen__body"> |
| | | <slot /> |
| | | <el-collapse v-if="dataMode === 'mixed' && demoSections.length" class="missing"> |
| | | <el-collapse-item title="当前为演示数据的区块" name="demo"> |
| | | <ul> |
| | | <li v-for="item in demoSections" :key="item.section">{{ item.label }}</li> |
| | | </ul> |
| | | </el-collapse-item> |
| | | </el-collapse> |
| | | <el-collapse v-if="unavailable.length" class="missing"> |
| | | <el-collapse-item title="当前拿不到或口径待确认的指标" name="1"> |
| | | <ul> |
| | |
| | | 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); |
| | | |
| New file |
| | |
| | | import type { ScreenData, TimeRange } from './screen'; |
| | | import { ITEM_COLORS, WASTE_COLORS, buildScreenAxis, structureScreen } from './screen-structure'; |
| | | |
| | | function wave(len: number, base: number, amp: number, seed = 0) { |
| | | return Array.from({ length: len }, (_, i) => { |
| | | const v = base + amp * Math.sin((i + seed) * 0.85) + amp * 0.35 * Math.cos((i + seed) * 0.4); |
| | | return Math.max(0, Math.round(v * 10) / 10); |
| | | }); |
| | | } |
| | | |
| | | function intWave(len: number, base: number, amp: number, seed = 0) { |
| | | return wave(len, base, amp, seed).map(v => Math.round(v)); |
| | | } |
| | | |
| | | function scaleByRange(range: TimeRange) { |
| | | const map: Record<TimeRange, number> = { |
| | | today: 0.08, |
| | | week: 0.28, |
| | | month: 1, |
| | | quarter: 2.8, |
| | | year: 1 |
| | | }; |
| | | return map[range] ?? 1; |
| | | } |
| | | |
| | | function buildRank(name: string, org: string | undefined, people: number, weight: number, points: number) { |
| | | return { |
| | | name, |
| | | org, |
| | | weight: Math.round(weight * 10) / 10, |
| | | people, |
| | | rate: 0, |
| | | points: Math.round(points), |
| | | carbon: Math.round(weight * 10) / 10, |
| | | size: people, |
| | | weightPerCapita: Math.round((weight / people) * 100) / 100, |
| | | pointPerCapita: Math.round((points / people) * 10) / 10 |
| | | }; |
| | | } |
| | | |
| | | /** 饱满演示数据,口径与后端结构一致 */ |
| | | export function demoScreen(range: TimeRange = 'month'): ScreenData { |
| | | const base = structureScreen(range); |
| | | const axis = base.axis.length ? base.axis : buildScreenAxis(range); |
| | | const s = scaleByRange(range); |
| | | |
| | | const periodWeightTon = Math.round(18.6 * s * 100) / 100; |
| | | const recyclableTon = Math.round(7.9 * s * 100) / 100; |
| | | const periodCarbonTon = Math.round(2.14 * s * 100) / 100; |
| | | const periodValue = Math.round(12800 * s); |
| | | const totalWeightTon = 126.8; |
| | | const totalCarbonTon = 14.6; |
| | | const totalParticipants = 3842; |
| | | const totalPoints = 96500; |
| | | |
| | | const wasteWeights = [4200, 3100, 980, 420, 2860].map(v => Math.round(v * s)); |
| | | const wasteCounts = [860, 720, 96, 38, 540].map(v => Math.round(v * s)); |
| | | const wasteMix = base.wasteMix.map((item, i) => ({ |
| | | ...item, |
| | | weight: wasteWeights[i], |
| | | count: wasteCounts[i], |
| | | value: i === 0 ? periodValue : 0, |
| | | color: WASTE_COLORS[item.code || ''] || item.color |
| | | })); |
| | | |
| | | const recycleWeights = [1820, 960, 540, 680, 320, 410, 270].map(v => Math.round(v * s)); |
| | | const recycleValues = [4200, 2100, 1800, 960, 480, 3200, 180].map(v => Math.round(v * s)); |
| | | const recycleCarbons = recycleWeights.map(w => Math.round(w * 0.42 * 10) / 10); |
| | | const recycleItems = base.recycleItems.map((item, i) => ({ |
| | | ...item, |
| | | weight: recycleWeights[i], |
| | | value: recycleValues[i], |
| | | carbon: recycleCarbons[i], |
| | | color: ITEM_COLORS[i] |
| | | })); |
| | | |
| | | const trendWeight = wave(axis.length, 620 * s, 180 * s, 1); |
| | | const trendValue = intWave(axis.length, 420 * s, 120 * s, 2); |
| | | const trendCarbon = wave(axis.length, 860 * s, 220 * s, 3); |
| | | |
| | | const yearCarbon = [0.8, 0.9, 1.1, 1.3, 1.5, 1.8, 2.0, 2.2, 1.9, 1.6, 1.2, 0.9]; |
| | | |
| | | const deptRank = [ |
| | | buildRank('环境科学与工程学院', undefined, 486, 2860, 12800), |
| | | buildRank('材料科学与工程学院', undefined, 412, 2410, 10600), |
| | | buildRank('经济管理学院', undefined, 368, 1980, 9200), |
| | | buildRank('计算机学院', undefined, 352, 1760, 8800), |
| | | buildRank('外国语学院', undefined, 286, 1420, 6400), |
| | | buildRank('土木工程学院', undefined, 248, 1180, 5200), |
| | | buildRank('生命科学学院', undefined, 216, 960, 4600), |
| | | buildRank('马克思主义学院', undefined, 168, 720, 3200) |
| | | ]; |
| | | |
| | | const classRank = [ |
| | | buildRank('环科2201', '环境科学与工程学院', 68, 420, 1860), |
| | | buildRank('材料2102', '材料科学与工程学院', 62, 386, 1720), |
| | | buildRank('计科2303', '计算机学院', 58, 352, 1580), |
| | | buildRank('经管2204', '经济管理学院', 54, 318, 1460), |
| | | buildRank('土木2101', '土木工程学院', 48, 286, 1280), |
| | | buildRank('外语2202', '外国语学院', 44, 248, 1120), |
| | | buildRank('生科2301', '生命科学学院', 40, 216, 980), |
| | | buildRank('环工2103', '环境科学与工程学院', 36, 198, 860) |
| | | ]; |
| | | |
| | | const majorRank = [ |
| | | buildRank('环境工程', undefined, 286, 1680, 7600), |
| | | buildRank('材料化学', undefined, 248, 1420, 6400), |
| | | buildRank('软件工程', undefined, 236, 1280, 5800), |
| | | buildRank('工商管理', undefined, 218, 1160, 5200), |
| | | buildRank('英语', undefined, 186, 960, 4200), |
| | | buildRank('土木工程', undefined, 168, 860, 3800), |
| | | buildRank('生物技术', undefined, 142, 720, 3200), |
| | | buildRank('环境科学', undefined, 128, 640, 2800) |
| | | ]; |
| | | |
| | | const issued = Math.round(186000 * Math.max(s, 0.2)); |
| | | const transfer = Math.round(issued * 0.12); |
| | | const donate = Math.round(issued * 0.08); |
| | | const mall = Math.round(issued * 0.46); |
| | | const balance = totalPoints; |
| | | |
| | | const orderAxis = base.ordersByType.axis; |
| | | const ordersByType = { |
| | | axis: orderAxis, |
| | | 现场回收: intWave(orderAxis.length, 18 * s, 8 * s, 4), |
| | | 预约上门: intWave(orderAxis.length, 12 * s, 5 * s, 5), |
| | | 大屏投递: intWave(orderAxis.length, 26 * s, 10 * s, 6), |
| | | 活动回收: intWave(orderAxis.length, 8 * s, 4 * s, 7) |
| | | }; |
| | | |
| | | return { |
| | | ...base, |
| | | axis, |
| | | kpis: { |
| | | todayWeightKg: Math.round(286.5 * Math.max(s, 0.15) * 10) / 10, |
| | | periodWeightTon, |
| | | resourceRate: 42.3, |
| | | periodValue, |
| | | periodCarbonTon, |
| | | totalWeightTon, |
| | | totalParticipants, |
| | | totalPoints, |
| | | recyclableTon, |
| | | bulkTon: Math.round(1.8 * s * 100) / 100, |
| | | elecTon: Math.round(0.96 * s * 100) / 100, |
| | | fabricTon: Math.round(1.24 * s * 100) / 100, |
| | | yearCarbonTon: 18.6, |
| | | monthCarbonTon: periodCarbonTon, |
| | | totalCarbonTon, |
| | | greenActions: Math.round(8620 * Math.max(s, 0.2)), |
| | | newParticipants: Math.round(286 * s), |
| | | participateRate: 31.6, |
| | | pointsIssued: issued, |
| | | pointsConsumed: Math.round(issued * 0.54), |
| | | pointsBalance: balance, |
| | | sorterOnline: 12, |
| | | sorterTotal: 16, |
| | | vehicleOnline: 5, |
| | | vehicleTotal: 7, |
| | | siteOnline: 18, |
| | | siteTotal: 22, |
| | | todayOrders: Math.round(46 * Math.max(s, 0.2)), |
| | | todayAppointments: Math.round(28 * Math.max(s, 0.2)), |
| | | pendingAppointments: Math.round(6 * Math.max(s, 0.2)), |
| | | alerts: 2, |
| | | healthAlerts: 1 |
| | | }, |
| | | wasteMix, |
| | | recycleItems, |
| | | trend: { |
| | | weight: trendWeight, |
| | | value: trendValue, |
| | | carbon: trendCarbon |
| | | }, |
| | | yearCarbon, |
| | | deptRank, |
| | | classRank, |
| | | majorRank, |
| | | pointFlow: { issued, transfer, donate, mall, balance }, |
| | | participateTrend: intWave(axis.length, 86 * s, 28 * s, 8), |
| | | deliverTrend: intWave(axis.length, 168 * s, 42 * s, 9), |
| | | ordersByType, |
| | | appointmentFlow: [ |
| | | { name: '预约', value: Math.round(18 * Math.max(s, 0.2)) }, |
| | | { name: '接单', value: Math.round(14 * Math.max(s, 0.2)) }, |
| | | { name: '完成', value: Math.round(11 * Math.max(s, 0.2)) } |
| | | ], |
| | | sorterStatus: [ |
| | | { name: '工作中', value: 12, color: '#22c55e' }, |
| | | { name: '休息', value: 2, color: '#f59e0b' }, |
| | | { name: '异常', value: 1, color: '#ef4444' }, |
| | | { name: '离线', value: 1, color: '#94a3b8' } |
| | | ], |
| | | vehicles: [ |
| | | { no: '沪A·8K126', km: 86.4, idle: 12, overspeed: 0, status: '在线' }, |
| | | { no: '沪A·6D882', km: 72.1, idle: 8, overspeed: 1, status: '在线' }, |
| | | { no: '沪A·3F559', km: 64.8, idle: 18, overspeed: 0, status: '在线' }, |
| | | { no: '沪A·2H903', km: 58.2, idle: 6, overspeed: 0, status: '在线' }, |
| | | { no: '沪A·9M217', km: 46.5, idle: 22, overspeed: 2, status: '在线' }, |
| | | { no: '沪A·5P668', km: 38.6, idle: 0, overspeed: 0, status: '离线' }, |
| | | { no: '沪A·1R441', km: 24.3, idle: 0, overspeed: 0, status: '离线' } |
| | | ], |
| | | sites: [ |
| | | { name: '学生公寓一号站', weight: 286.5, online: true }, |
| | | { name: '图书馆回收点', weight: 248.2, online: true }, |
| | | { name: '第一食堂收集点', weight: 216.8, online: true }, |
| | | { name: '体育馆回收站', weight: 186.4, online: true }, |
| | | { name: '行政楼收集点', weight: 162.6, online: true }, |
| | | { name: '实验楼B区', weight: 148.2, online: true }, |
| | | { name: '研究生公寓站', weight: 126.8, online: false }, |
| | | { name: '南门临时点', weight: 98.4, online: true } |
| | | ], |
| | | unavailable: [] |
| | | }; |
| | | } |
| New file |
| | |
| | | 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)); |
| | | } |
| New file |
| | |
| | | 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'; |
| New file |
| | |
| | | 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: [] |
| | | }; |
| | | } |
| | |
| | | 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); |
| | | } |
| | |
| | | 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(''); |
| | | |
| | |
| | | return { |
| | | timeRange: sharedTimeRange, |
| | | data: screenData, |
| | | dataMode, |
| | | demoFields, |
| | | liveFields, |
| | | loading, |
| | | loadError, |
| | | reload: () => loadImpl() |
| | |
| | | |
| | | 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(); |
| | |
| | | method: 'POST', |
| | | data: payload |
| | | }); |
| | | const fallback = emptyScreen(sharedTimeRange.value); |
| | | screenData.value = { |
| | | ...fallback, |
| | | ...(res || {}), |
| | | kpis: { ...fallback.kpis, ...(res?.kpis || {}) }, |
| | | trend: { ...fallback.trend, ...(res?.trend || {}) }, |
| | | pointFlow: { ...fallback.pointFlow, ...(res?.pointFlow || {}) }, |
| | | ordersByType: { ...fallback.ordersByType, ...(res?.ordersByType || {}) } |
| | | }; |
| | | applyResolved(resolveScreenData(res, sharedTimeRange.value)); |
| | | } catch (e: any) { |
| | | screenData.value = emptyScreen(sharedTimeRange.value); |
| | | applyResolved(resolveScreenData(null, sharedTimeRange.value)); |
| | | loadError.value = e?.message || '驾驶舱接口调用失败'; |
| | | } finally { |
| | | loading.value = false; |
| | |
| | | |
| | | 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() { |
| | |
| | | <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> |
| | |
| | | |
| | | <el-row :gutter="10"> |
| | | <el-col :lg="16" :xs="24"> |
| | | <panel-card title="本年度月度碳减排" sub="日表 carbonTotal 单位 kgCO₂e,展示时 ÷1000 为吨"> |
| | | <panel-card title="本年度月度碳减排" sub="日表 carbonTotal 单位 kgCO₂e,展示时 ÷1000 为吨" :demo="isDemoSection('yearCarbon')"> |
| | | <v-chart :option="yearOption" autoresize class="chart" /> |
| | | </panel-card> |
| | | </el-col> |
| | |
| | | import { computed } from 'vue'; |
| | | import KpiCard from '../../components/kpi-card.vue'; |
| | | import PanelCard from '../../components/panel-card.vue'; |
| | | import { useChartTheme, useScreenData } from '../../hooks/use-screen'; |
| | | import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen'; |
| | | |
| | | const { data, timeRange } = useScreenData(); |
| | | const { isDemoField, isDemoSection } = useScreenFieldSource(); |
| | | const { textColor, mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme(); |
| | | |
| | | const kpis = computed(() => [ |
| | | { label: '累计碳减排', value: data.value.kpis.totalCarbonTon, suffix: '吨CO₂e', fixed: 1 }, |
| | | { label: '本年度碳减排', value: data.value.kpis.yearCarbonTon, suffix: '吨CO₂e', fixed: 1 }, |
| | | { label: '本区间碳减排', value: data.value.kpis.periodCarbonTon, suffix: '吨CO₂e', fixed: 2 }, |
| | | { label: '累计资源回收', value: data.value.kpis.totalWeightTon, suffix: '吨', fixed: 1 }, |
| | | { label: '累计参与师生', value: data.value.kpis.totalParticipants, suffix: '人', fixed: 0 }, |
| | | { label: '累计环保行为', value: data.value.kpis.greenActions, suffix: '次', fixed: 0 } |
| | | { field: 'kpis.totalCarbonTon', label: '累计碳减排', value: data.value.kpis.totalCarbonTon, suffix: '吨CO₂e', fixed: 1 }, |
| | | { field: 'kpis.yearCarbonTon', label: '本年度碳减排', value: data.value.kpis.yearCarbonTon, suffix: '吨CO₂e', fixed: 1 }, |
| | | { field: 'kpis.periodCarbonTon', label: '本区间碳减排', value: data.value.kpis.periodCarbonTon, suffix: '吨CO₂e', fixed: 2 }, |
| | | { field: 'kpis.totalWeightTon', label: '累计资源回收', value: data.value.kpis.totalWeightTon, suffix: '吨', fixed: 1 }, |
| | | { field: 'kpis.totalParticipants', label: '累计参与师生', value: data.value.kpis.totalParticipants, suffix: '人', fixed: 0 }, |
| | | { field: 'kpis.greenActions', label: '累计环保行为', value: data.value.kpis.greenActions, suffix: '次', fixed: 0 } |
| | | ]); |
| | | |
| | | const carbonTrendDemo = computed(() => |
| | | timeRange.value === 'year' ? isDemoSection('yearCarbon') : isDemoSection('trend') |
| | | ); |
| | | |
| | | const trendOption = computed(() => { |
| | | const isYear = timeRange.value === 'year'; |
| | | return { |
| | |
| | | <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> |
| | |
| | | |
| | | <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 }" /> |
| | |
| | | </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> |
| | |
| | | </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> |
| | |
| | | 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)); |
| | |
| | | <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" /> |
| | | <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="学校管理层看结构,不看单次称重"> |
| | | <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="14" :xs="24"> |
| | | <panel-card title="回收趋势" :sub="trendHint"> |
| | | <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-row :gutter="10" class="camera-row"> |
| | | <el-col :span="24"> |
| | | <panel-card title="各院系参与人数" sub="按用户学院名称 collegeName 汇总,人均按参与人数"> |
| | | <v-chart :option="rankOption" autoresize class="chart chart--bar" /> |
| | | <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> |
| | |
| | | }); |
| | | |
| | | 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'); |
| | |
| | | ]; |
| | | |
| | | const kpis = computed(() => [ |
| | | { label: '今日回收量', value: data.value.kpis.todayWeightKg, suffix: 'kg', fixed: 1, hint: '实时称重汇总' }, |
| | | { label: '本区间回收量', value: data.value.kpis.periodWeightTon, suffix: '吨', fixed: 2, hint: '当前时间范围' }, |
| | | { label: '资源化率', value: data.value.kpis.resourceRate, suffix: '%', fixed: 1, hint: '可回收物重量 / 总重量,不含大件和电子' }, |
| | | { label: '回收价值', value: data.value.kpis.periodValue, suffix: '元', fixed: 0, hint: '日表估算销售额' }, |
| | | { label: '碳减排', value: data.value.kpis.periodCarbonTon, suffix: '吨CO₂e', fixed: 2, hint: '分类重量 × 碳减因数' }, |
| | | { label: '累计回收量', value: data.value.kpis.totalWeightTon, suffix: '吨', fixed: 1, hint: '上线以来累计' }, |
| | | { label: '累计参与人数', value: data.value.kpis.totalParticipants, suffix: '人', fixed: 0, hint: '去重校园用户' }, |
| | | { label: '累计碳积分', value: data.value.kpis.totalPoints, suffix: '分', fixed: 0, hint: '账户余额合计' } |
| | | { field: 'kpis.todayWeightKg', label: '今日回收量', value: data.value.kpis.todayWeightKg, suffix: 'kg', fixed: 1, hint: '实时称重汇总' }, |
| | | { field: 'kpis.periodWeightTon', label: '本区间回收量', value: data.value.kpis.periodWeightTon, suffix: '吨', fixed: 2, hint: '当前时间范围' }, |
| | | { field: 'kpis.resourceRate', label: '资源化率', value: data.value.kpis.resourceRate, suffix: '%', fixed: 1, hint: '可回收物重量/总重量,不含大件' }, |
| | | { field: 'kpis.periodValue', label: '回收价值', value: data.value.kpis.periodValue, suffix: '元', fixed: 0, hint: '日表估算销售额' }, |
| | | { field: 'kpis.periodCarbonTon', label: '碳减排', value: data.value.kpis.periodCarbonTon, suffix: '吨CO₂e', fixed: 2, hint: '分类重量 × 碳减因数' }, |
| | | { field: 'kpis.totalWeightTon', label: '累计回收量', value: data.value.kpis.totalWeightTon, suffix: '吨', fixed: 1, hint: '上线以来累计' }, |
| | | { field: 'kpis.totalParticipants', label: '累计参与人数', value: data.value.kpis.totalParticipants, suffix: '人', fixed: 0, hint: '去重校园用户' }, |
| | | { field: 'kpis.totalPoints', label: '累计碳积分', value: data.value.kpis.totalPoints, suffix: '分', fixed: 0, hint: '账户余额合计' } |
| | | ]); |
| | | |
| | | const mixOption = computed(() => ({ |
| | |
| | | <style lang="scss" scoped> |
| | | .screen-page { |
| | | .kpi-row, |
| | | .mid-row { |
| | | .charts-row, |
| | | .camera-row { |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | .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; |
| | | } |
| | | } |
| | | } |
| | |
| | | <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> |
| | |
| | | </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> |
| | |
| | | |
| | | <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 /> |
| | |
| | | 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'); |
| | |
| | | ]; |
| | | |
| | | 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() }, |
| | |
| | | <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> |
| | |
| | | |
| | | <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> |
| | |
| | | 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(() => ({ |