| New file |
| | |
| | | <template> |
| | | <div class="screen-drill" v-loading="loading"> |
| | | <div v-if="level > 0" class="screen-drill__nav"> |
| | | <el-button text type="primary" size="small" @click="goBack">返回上一级</el-button> |
| | | <span class="screen-drill__crumb">{{ crumb }}</span> |
| | | </div> |
| | | <v-chart |
| | | :option="option" |
| | | :update-options="{ notMerge: true }" |
| | | autoresize |
| | | class="screen-drill__chart" |
| | | :class="{ 'is-drilled': level > 0 }" |
| | | @click="onChartClick" |
| | | /> |
| | | </div> |
| | | </template> |
| | | |
| | | <script lang="ts" setup> |
| | | import { computed, ref, watch } from 'vue'; |
| | | import { ElMessage } from 'element-plus'; |
| | | import { useCool } from '/@/cool'; |
| | | import { chartSeries, pieTooltipFormatter, toPieData } from '../data/screen-chart'; |
| | | import { ITEM_COLORS } from '../data/screen-structure'; |
| | | import { useChartTheme, useScreenData } from '../hooks/use-screen'; |
| | | |
| | | type Metric = 'weight' | 'value' | 'count' | 'carbon'; |
| | | type ChartType = 'pie' | 'bar'; |
| | | |
| | | type SourceItem = { |
| | | name: string; |
| | | code?: string; |
| | | color?: string; |
| | | weight?: number; |
| | | value?: number; |
| | | count?: number; |
| | | carbon?: number; |
| | | [key: string]: any; |
| | | }; |
| | | |
| | | type DrillItem = { |
| | | code?: string; |
| | | name: string; |
| | | value: number; |
| | | color?: string; |
| | | even?: boolean; |
| | | id?: number; |
| | | percent?: number; |
| | | }; |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | type: ChartType; |
| | | source: SourceItem[]; |
| | | metric: Metric; |
| | | unit?: string; |
| | | legend?: boolean; |
| | | center?: [string, string]; |
| | | radius?: [string, string]; |
| | | label?: string; |
| | | nameSuffix?: string; |
| | | evenSplit?: boolean; |
| | | }>(), |
| | | { |
| | | unit: '', |
| | | legend: false, |
| | | center: () => ['50%', '50%'], |
| | | radius: () => ['38%', '62%'], |
| | | label: '{b}\n{d}%', |
| | | nameSuffix: '', |
| | | evenSplit: undefined |
| | | } |
| | | ); |
| | | |
| | | const { service } = useCool(); |
| | | const { timeRange, loading: screenLoading } = useScreenData(); |
| | | const { textColor, mutedColor, splitColor, primary, tooltip } = useChartTheme(); |
| | | |
| | | const loading = ref(false); |
| | | const level = ref(0); |
| | | const categoryCode = ref(''); |
| | | const categoryName = ref(''); |
| | | const categoryColor = ref(''); |
| | | const departmentId = ref<number | null>(null); |
| | | const departmentName = ref(''); |
| | | const drillItems = ref<DrillItem[]>([]); |
| | | const deptCache = ref<DrillItem[]>([]); |
| | | |
| | | const isCarbon = computed(() => props.metric === 'carbon'); |
| | | const evenSplit = computed(() => |
| | | props.evenSplit != null ? props.evenSplit : props.type === 'pie' |
| | | ); |
| | | |
| | | const rootItems = computed<DrillItem[]>(() => |
| | | chartSeries( |
| | | props.source || [], |
| | | e => Number(e?.[props.metric] || 0), |
| | | { carbon: isCarbon.value, evenSplit: evenSplit.value } |
| | | ).map(e => ({ |
| | | code: e.item.code, |
| | | name: e.item.name + (props.nameSuffix || ''), |
| | | value: e.value, |
| | | color: e.item.color, |
| | | even: e.even |
| | | })) |
| | | ); |
| | | |
| | | const rows = computed(() => (level.value === 0 ? rootItems.value : drillItems.value)); |
| | | |
| | | const crumb = computed(() => |
| | | [categoryName.value, level.value >= 2 ? departmentName.value : ''] |
| | | .filter(Boolean) |
| | | .join(' · ') |
| | | ); |
| | | |
| | | function drillColor(i: number) { |
| | | if (i === 0 && categoryColor.value) return categoryColor.value; |
| | | return ITEM_COLORS[i % ITEM_COLORS.length]; |
| | | } |
| | | |
| | | const option = computed(() => { |
| | | const list = rows.value; |
| | | if (props.type === 'pie') { |
| | | return { |
| | | tooltip: { |
| | | trigger: 'item', |
| | | ...tooltip(), |
| | | formatter: pieTooltipFormatter(props.unit) |
| | | }, |
| | | legend: props.legend |
| | | ? { bottom: 0, textStyle: { color: mutedColor.value } } |
| | | : undefined, |
| | | series: [ |
| | | { |
| | | type: 'pie', |
| | | radius: props.radius, |
| | | center: props.center, |
| | | padAngle: 3, |
| | | cursor: level.value >= 2 ? 'default' : 'pointer', |
| | | itemStyle: { borderRadius: 6 }, |
| | | label: { |
| | | color: textColor.value, |
| | | formatter: |
| | | level.value > 0 |
| | | ? `{b}\n{c}${props.unit ? ' ' + props.unit : ''}\n{d}%` |
| | | : props.label |
| | | }, |
| | | data: toPieData( |
| | | list.map(e => ({ |
| | | item: { ...e, color: e.color || primary.value }, |
| | | value: e.value, |
| | | even: !!e.even |
| | | })), |
| | | e => e.name, |
| | | e => e.color |
| | | ) |
| | | } |
| | | ] |
| | | }; |
| | | } |
| | | |
| | | const barRows = [...list].reverse(); |
| | | const total = barRows.reduce((s, e) => s + (e.even ? 0 : e.value), 0); |
| | | return { |
| | | grid: { containLabel: true, left: 8, right: level.value > 0 ? 80 : 24, top: 12, bottom: 8 }, |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | axisPointer: { type: 'shadow' }, |
| | | ...tooltip(), |
| | | formatter: (params: any) => { |
| | | const p = Array.isArray(params) ? params[0] : params; |
| | | const row = barRows[p?.dataIndex]; |
| | | if (!row) return ''; |
| | | const val = row.even ? 0 : row.value; |
| | | const u = props.unit ? ` ${props.unit}` : ''; |
| | | const pct = |
| | | level.value > 0 && total |
| | | ? ` (${((val / total) * 100).toFixed(1)}%)` |
| | | : ''; |
| | | return `${row.name}<br/>${Number(val || 0).toLocaleString()}${u}${pct}`; |
| | | } |
| | | }, |
| | | xAxis: { |
| | | type: 'value', |
| | | name: props.unit, |
| | | nameTextStyle: { color: mutedColor.value }, |
| | | splitLine: { lineStyle: { color: splitColor.value, type: 'dashed' } }, |
| | | axisLabel: { color: mutedColor.value }, |
| | | axisLine: { show: false }, |
| | | axisTick: { show: false } |
| | | }, |
| | | yAxis: { |
| | | type: 'category', |
| | | data: barRows.map(e => e.name), |
| | | axisLabel: { color: textColor.value }, |
| | | axisLine: { show: false }, |
| | | axisTick: { show: false } |
| | | }, |
| | | series: [ |
| | | { |
| | | type: 'bar', |
| | | barWidth: 14, |
| | | cursor: level.value >= 2 ? 'default' : 'pointer', |
| | | label: |
| | | level.value > 0 |
| | | ? { |
| | | show: true, |
| | | position: 'right', |
| | | color: textColor.value, |
| | | fontSize: 11, |
| | | formatter: (p: any) => { |
| | | const val = Number(p.value || 0); |
| | | const pct = total ? ((val / total) * 100).toFixed(1) : '0.0'; |
| | | const u = props.unit ? ` ${props.unit}` : ''; |
| | | return `${val.toLocaleString()}${u} ${pct}%`; |
| | | } |
| | | } |
| | | : { show: false }, |
| | | data: barRows.map(e => ({ |
| | | value: e.value, |
| | | name: e.name, |
| | | code: e.code, |
| | | id: e.id, |
| | | even: e.even, |
| | | itemStyle: { |
| | | borderRadius: [0, 8, 8, 0], |
| | | color: e.color || primary.value |
| | | } |
| | | })) |
| | | } |
| | | ] |
| | | }; |
| | | }); |
| | | |
| | | function reset() { |
| | | level.value = 0; |
| | | categoryCode.value = ''; |
| | | categoryName.value = ''; |
| | | categoryColor.value = ''; |
| | | departmentId.value = null; |
| | | departmentName.value = ''; |
| | | drillItems.value = []; |
| | | deptCache.value = []; |
| | | } |
| | | |
| | | async function requestDrill(deptId?: number) { |
| | | const payload = { |
| | | range: timeRange.value, |
| | | code: categoryCode.value, |
| | | metric: props.metric, |
| | | departmentId: deptId |
| | | }; |
| | | const screen = service.report?.screen; |
| | | return screen?.drill |
| | | ? await screen.drill(payload) |
| | | : await service.request({ |
| | | url: 'admin/report/screen/drill', |
| | | method: 'POST', |
| | | data: payload |
| | | }); |
| | | } |
| | | |
| | | function paint(items: { id: number; name: string; value: number; percent?: number }[]) { |
| | | return (items || []).map((e, i) => ({ |
| | | id: e.id, |
| | | name: e.name, |
| | | value: Number(e.value) || 0, |
| | | percent: e.percent, |
| | | color: drillColor(i), |
| | | even: false, |
| | | code: categoryCode.value |
| | | })); |
| | | } |
| | | |
| | | async function enterDepartment(item: DrillItem) { |
| | | if (!item.code) { |
| | | ElMessage.warning('该类没有可下钻的编码'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | categoryCode.value = item.code; |
| | | categoryName.value = item.name; |
| | | categoryColor.value = item.color || ''; |
| | | const res = await requestDrill(); |
| | | const items = paint(res?.items || []); |
| | | if (!items.length) { |
| | | ElMessage.info('该分类暂无部门数据'); |
| | | return; |
| | | } |
| | | drillItems.value = items; |
| | | deptCache.value = items; |
| | | departmentId.value = null; |
| | | departmentName.value = ''; |
| | | level.value = 1; |
| | | } catch (e: any) { |
| | | ElMessage.error(e?.message || '下钻失败'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | async function enterSite(item: DrillItem) { |
| | | if (item.id == null) return; |
| | | loading.value = true; |
| | | try { |
| | | const res = await requestDrill(item.id); |
| | | const items = paint(res?.items || []); |
| | | if (!items.length) { |
| | | ElMessage.info('该部门暂无站点数据'); |
| | | return; |
| | | } |
| | | departmentId.value = item.id; |
| | | departmentName.value = res?.departmentName || item.name; |
| | | drillItems.value = items; |
| | | level.value = 2; |
| | | } catch (e: any) { |
| | | ElMessage.error(e?.message || '下钻失败'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | async function reloadLevel() { |
| | | if (level.value === 1) { |
| | | loading.value = true; |
| | | try { |
| | | const res = await requestDrill(); |
| | | const items = paint(res?.items || []); |
| | | drillItems.value = items; |
| | | deptCache.value = items; |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } else if (level.value === 2 && departmentId.value != null) { |
| | | loading.value = true; |
| | | try { |
| | | const res = await requestDrill(departmentId.value); |
| | | drillItems.value = paint(res?.items || []); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | } |
| | | |
| | | function onChartClick(params: any) { |
| | | if (params?.componentType !== 'series') return; |
| | | if (loading.value || screenLoading.value) return; |
| | | if (level.value >= 2) return; |
| | | const data = params.data || {}; |
| | | if (data.even) return; |
| | | if (level.value === 0) { |
| | | const hit = |
| | | rootItems.value.find(e => e.code && e.code === data.code) || |
| | | rootItems.value.find(e => e.name === (data.name || params.name)); |
| | | if (!hit || hit.even) return; |
| | | enterDepartment(hit); |
| | | return; |
| | | } |
| | | const hit = |
| | | drillItems.value.find(e => e.id != null && e.id === data.id) || |
| | | drillItems.value.find(e => e.name === (data.name || params.name)); |
| | | if (!hit) return; |
| | | enterSite(hit); |
| | | } |
| | | |
| | | function goBack() { |
| | | if (level.value >= 2) { |
| | | level.value = 1; |
| | | departmentId.value = null; |
| | | departmentName.value = ''; |
| | | if (deptCache.value.length) { |
| | | drillItems.value = deptCache.value; |
| | | return; |
| | | } |
| | | reloadLevel(); |
| | | return; |
| | | } |
| | | reset(); |
| | | } |
| | | |
| | | watch(timeRange, () => reset()); |
| | | watch( |
| | | () => props.metric, |
| | | () => { |
| | | if (level.value > 0) reloadLevel(); |
| | | } |
| | | ); |
| | | watch(screenLoading, (now, prev) => { |
| | | if (prev && !now && level.value > 0) reloadLevel(); |
| | | }); |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .screen-drill { |
| | | position: relative; |
| | | display: flex; |
| | | flex-direction: column; |
| | | width: 100%; |
| | | height: 100%; |
| | | min-height: inherit; |
| | | |
| | | &__nav { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | flex-shrink: 0; |
| | | padding: 0 8px 4px; |
| | | min-height: 28px; |
| | | } |
| | | |
| | | &__crumb { |
| | | font-size: 12px; |
| | | color: var(--el-text-color-secondary); |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | &__chart { |
| | | flex: 1; |
| | | min-height: 0; |
| | | width: 100%; |
| | | height: 100%; |
| | | } |
| | | } |
| | | </style> |
| | |
| | | name: name(row.item), |
| | | value: row.value, |
| | | even: row.even, |
| | | code: (row.item as any)?.code, |
| | | id: (row.item as any)?.id, |
| | | itemStyle: { color: color(row.item) } |
| | | })); |
| | | } |
| | |
| | | })), |
| | | recycleItems: RECYCLE_NAMES.map((name, i) => ({ |
| | | name, |
| | | code: `SW62${String(i + 1).padStart(3, '0')}`, |
| | | weight: 0, |
| | | value: 0, |
| | | carbon: 0, |
| | |
| | | axis: string[]; |
| | | kpis: Record<string, number>; |
| | | wasteMix: { name: string; code?: string; weight: number; value: number; count: number; color: string }[]; |
| | | recycleItems: { name: string; weight: number; value: number; carbon: number; color: string }[]; |
| | | recycleItems: { name: string; code?: string; weight: number; value: number; carbon: number; color: string }[]; |
| | | trend: { weight: number[]; value: number[]; carbon: number[] }; |
| | | yearCarbon: number[]; |
| | | deptRank: any[]; |
| | |
| | | sortable: "custom", |
| | | component: { name: "cl-date-text", props: { format: "YYYY-MM-DD" } } |
| | | }, |
| | | { label: t('部门名称'), prop: "departmentName", minWidth: 140 }, |
| | | { label: t('部门名称'), prop: "departmentName", minWidth: 140, sortable: "custom" }, |
| | | { label: t('总重量'), prop: "weightTotal", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw60重量'), prop: "weightSw60", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw64重量'), prop: "weightSw64", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw61重量'), prop: "weightSw61", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw62重量'), prop: "weightSw62", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw60重量'), prop: "weightSw60", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw63重量'), prop: "weightSw63", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw64重量'), prop: "weightSw64", minWidth: 140, sortable: "custom" }, |
| | | |
| | | { label: t('总称重次数'), prop: "timeTotal", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw60次数'), prop: "timeSw60", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw64次数'), prop: "timeSw64", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw61次数'), prop: "timeSw61", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw62次数'), prop: "timeSw62", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw60次数'), prop: "timeSw60", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw63次数'), prop: "timeSw63", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw64次数'), prop: "timeSw64", minWidth: 140, sortable: "custom" }, |
| | | |
| | | { label: t('总碳排量'), prop: "carbonTotal", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw60碳排量'), prop: "carbonSw60", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw64碳排量'), prop: "carbonSw64", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw61碳排量'), prop: "carbonSw61", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw62碳排量'), prop: "carbonSw62", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw60碳排量'), prop: "carbonSw60", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw63碳排量'), prop: "carbonSw63", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw64碳排量'), prop: "carbonSw64", minWidth: 140, sortable: "custom" }, |
| | | |
| | | { |
| | | label: t('sw62估算销售额'), |
| | | prop: "estimatedSalesSw62", |
| | |
| | | sortable: "custom", |
| | | component: { name: "cl-date-text", props: { format: "YYYY-MM-DD" } } |
| | | }, |
| | | { label: t('站点名称'), prop: "siteName", minWidth: 140 }, |
| | | { label: t('所属单位'), prop: "departmentName", minWidth: 140 }, |
| | | { label: t('站点名称'), prop: "siteName", minWidth: 140, sortable: "custom" }, |
| | | { label: t('所属单位'), prop: "departmentName", minWidth: 140, sortable: "custom" }, |
| | | { label: t('总重量'), prop: "weightTotal", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw60重量'), prop: "weightSw60", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw64重量'), prop: "weightSw64", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw61重量'), prop: "weightSw61", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw62重量'), prop: "weightSw62", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw60重量'), prop: "weightSw60", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw63重量'), prop: "weightSw63", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw64重量'), prop: "weightSw64", minWidth: 140, sortable: "custom" }, |
| | | { label: t('总称重次数'), prop: "timeTotal", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw60次数'), prop: "timeSw60", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw64次数'), prop: "timeSw64", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw61次数'), prop: "timeSw61", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw62次数'), prop: "timeSw62", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw60次数'), prop: "timeSw60", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw63次数'), prop: "timeSw63", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw64次数'), prop: "timeSw64", minWidth: 140, sortable: "custom" }, |
| | | { label: t('总碳排量'), prop: "carbonTotal", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw60碳排量'), prop: "carbonSw60", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw64碳排量'), prop: "carbonSw64", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw61碳排量'), prop: "carbonSw61", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw62碳排量'), prop: "carbonSw62", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw60碳排量'), prop: "carbonSw60", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw63碳排量'), prop: "carbonSw63", minWidth: 140, sortable: "custom" }, |
| | | { label: t('sw64碳排量'), prop: "carbonSw64", minWidth: 140, sortable: "custom" }, |
| | | { |
| | | label: t('sw62估算销售额'), |
| | | prop: "estimatedSalesSw62", |
| | |
| | | </el-col> |
| | | <el-col :lg="10" :xs="24"> |
| | | <panel-card title="碳减排来源" sub="按可回收子类拆分" :demo="isDemoSection('recycleItems')"> |
| | | <v-chart :option="sourceOption" autoresize class="chart" /> |
| | | <screen-drill-chart |
| | | class="chart" |
| | | type="pie" |
| | | :source="data.recycleItems" |
| | | metric="carbon" |
| | | unit="kgCO₂e" |
| | | name-suffix="回收" |
| | | legend |
| | | :center="['50%', '46%']" |
| | | :radius="['36%', '60%']" |
| | | /> |
| | | </panel-card> |
| | | </el-col> |
| | | </el-row> |
| | |
| | | import KpiCard from '../../components/kpi-card.vue'; |
| | | import PanelCard from '../../components/panel-card.vue'; |
| | | import { TIME_RANGE_OPTIONS } from '../../data/screen'; |
| | | import { chartSeries, pieTooltipFormatter, toPieData } from '../../data/screen-chart'; |
| | | import ScreenDrillChart from '../../components/screen-drill-chart.vue'; |
| | | import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen'; |
| | | |
| | | const { data, timeRange } = useScreenData(); |
| | | const { isDemoSection } = useScreenFieldSource(); |
| | | const { textColor, mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme(); |
| | | const { mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme(); |
| | | |
| | | const rangeLabel = computed( |
| | | () => TIME_RANGE_OPTIONS.find(e => e.value === timeRange.value)?.label || '本区间' |
| | |
| | | ] |
| | | })); |
| | | |
| | | const sourceOption = computed(() => ({ |
| | | tooltip: { trigger: 'item', ...tooltip(), formatter: pieTooltipFormatter('kgCO₂e') }, |
| | | legend: { bottom: 0, textStyle: { color: mutedColor.value } }, |
| | | series: [ |
| | | { |
| | | type: 'pie', |
| | | radius: ['36%', '60%'], |
| | | center: ['50%', '46%'], |
| | | padAngle: 3, |
| | | itemStyle: { borderRadius: 6 }, |
| | | label: { color: textColor.value, formatter: '{b}\n{d}%' }, |
| | | data: toPieData( |
| | | chartSeries(data.value.recycleItems, e => e.carbon, { carbon: true }), |
| | | e => e.name + '回收', |
| | | e => e.color |
| | | ) |
| | | } |
| | | ] |
| | | })); |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | |
| | | <template #extra> |
| | | <cl-select-button v-model="mixMetric" :options="mixOptions" small /> |
| | | </template> |
| | | <v-chart :option="mixOption" autoresize class="chart" /> |
| | | <screen-drill-chart |
| | | class="chart" |
| | | type="pie" |
| | | :source="data.wasteMix" |
| | | :metric="mixMetric" |
| | | :unit="mixUnit" |
| | | :center="['50%', '50%']" |
| | | :radius="['38%', '62%']" |
| | | /> |
| | | </panel-card> |
| | | |
| | | <panel-card |
| | |
| | | :demo="isDemoSection('recycleItems')" |
| | | class="dash-card dash-card--flex" |
| | | > |
| | | <v-chart :option="carbonOption" autoresize class="chart" /> |
| | | <screen-drill-chart |
| | | class="chart" |
| | | type="pie" |
| | | :source="data.recycleItems" |
| | | metric="carbon" |
| | | unit="kgCO₂e" |
| | | name-suffix="回收" |
| | | :center="['50%', '50%']" |
| | | :radius="['36%', '60%']" |
| | | /> |
| | | </panel-card> |
| | | </div> |
| | | |
| | |
| | | <template #extra> |
| | | <cl-select-button v-model="itemMetric" :options="itemOptions" small /> |
| | | </template> |
| | | <v-chart :option="itemOption" autoresize class="chart chart--items" /> |
| | | <screen-drill-chart |
| | | class="chart chart--items" |
| | | type="bar" |
| | | :source="data.recycleItems" |
| | | :metric="itemMetric" |
| | | :unit="itemUnit" |
| | | :even-split="false" |
| | | /> |
| | | </panel-card> |
| | | </div> |
| | | </div> |
| | |
| | | import ScreenMapPanel from '../../components/screen-map-panel.vue'; |
| | | import CameraMonitor from '../../../monitor/views/cameras.vue'; |
| | | import { TIME_RANGE_OPTIONS } from '../../data/screen'; |
| | | import { chartSeries, pieTooltipFormatter, toPieData } from '../../data/screen-chart'; |
| | | import ScreenDrillChart from '../../components/screen-drill-chart.vue'; |
| | | import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen'; |
| | | |
| | | const { data, timeRange } = useScreenData(); |
| | | const { isDemoSection } = useScreenFieldSource(); |
| | | const { textColor, mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme(); |
| | | const { mutedColor, splitColor, primary, baseGrid, axisStyle, tooltip } = useChartTheme(); |
| | | |
| | | const mixMetric = ref<'weight' | 'value' | 'count'>('weight'); |
| | | const trendMetric = ref<'weight' | 'value' | 'carbon'>('weight'); |
| | |
| | | return 'kg'; |
| | | }); |
| | | |
| | | /** 垃圾分类结构(饼) */ |
| | | const mixOption = computed(() => ({ |
| | | tooltip: { trigger: 'item', ...tooltip(), formatter: pieTooltipFormatter() }, |
| | | series: [ |
| | | { |
| | | type: 'pie', |
| | | radius: ['38%', '62%'], |
| | | center: ['50%', '50%'], |
| | | padAngle: 3, |
| | | itemStyle: { borderRadius: 6 }, |
| | | label: { color: textColor.value, formatter: '{b}\n{d}%' }, |
| | | data: toPieData( |
| | | chartSeries(data.value.wasteMix, e => e[mixMetric.value]), |
| | | e => e.name, |
| | | e => e.color |
| | | ) |
| | | } |
| | | ] |
| | | })); |
| | | |
| | | /** 碳减排来源(饼):碳值可为负,按绝对值展示减排量 */ |
| | | const carbonOption = computed(() => ({ |
| | | tooltip: { trigger: 'item', ...tooltip(), formatter: pieTooltipFormatter('kgCO₂e') }, |
| | | series: [ |
| | | { |
| | | type: 'pie', |
| | | radius: ['36%', '60%'], |
| | | center: ['50%', '50%'], |
| | | padAngle: 3, |
| | | itemStyle: { borderRadius: 6 }, |
| | | label: { color: textColor.value, formatter: '{b}\n{d}%' }, |
| | | data: toPieData( |
| | | chartSeries(data.value.recycleItems, e => e.carbon, { carbon: true }), |
| | | e => e.name + '回收', |
| | | e => e.color |
| | | ) |
| | | } |
| | | ] |
| | | })); |
| | | const mixUnit = computed(() => { |
| | | if (mixMetric.value === 'value') return '元'; |
| | | if (mixMetric.value === 'count') return '次'; |
| | | return 'kg'; |
| | | }); |
| | | |
| | | /** 回收趋势(折线) */ |
| | | const trendOption = computed(() => ({ |
| | |
| | | ] |
| | | })); |
| | | |
| | | /** 各类回收量(横向条形) */ |
| | | const itemOption = computed(() => { |
| | | const carbon = itemMetric.value === 'carbon'; |
| | | const rows = chartSeries( |
| | | data.value.recycleItems, |
| | | e => Number(e[itemMetric.value] || 0), |
| | | { carbon, evenSplit: false } |
| | | ).reverse(); |
| | | return { |
| | | grid: { containLabel: true, left: 8, right: 24, top: 12, bottom: 8 }, |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | axisPointer: { type: 'shadow' }, |
| | | valueFormatter: (v: number) => `${Number(v || 0).toLocaleString()} ${itemUnit.value}`, |
| | | ...tooltip() |
| | | }, |
| | | xAxis: { |
| | | type: 'value', |
| | | name: itemUnit.value, |
| | | nameTextStyle: { color: mutedColor.value }, |
| | | splitLine: { lineStyle: { color: splitColor.value, type: 'dashed' } }, |
| | | axisLabel: { color: mutedColor.value }, |
| | | axisLine: { show: false }, |
| | | axisTick: { show: false } |
| | | }, |
| | | yAxis: { |
| | | type: 'category', |
| | | data: rows.map(e => e.item.name), |
| | | axisLabel: { color: textColor.value }, |
| | | axisLine: { show: false }, |
| | | axisTick: { show: false } |
| | | }, |
| | | series: [ |
| | | { |
| | | type: 'bar', |
| | | barWidth: 14, |
| | | data: rows.map(e => e.value), |
| | | itemStyle: { |
| | | borderRadius: [0, 8, 8, 0], |
| | | color: (params: any) => rows[params.dataIndex]?.item.color || primary.value |
| | | } |
| | | } |
| | | ] |
| | | }; |
| | | }); |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | |
| | | <template #extra> |
| | | <cl-select-button v-model="mixMetric" :options="mixOptions" small /> |
| | | </template> |
| | | <v-chart :option="mixOption" autoresize class="chart" /> |
| | | <screen-drill-chart |
| | | class="chart" |
| | | type="pie" |
| | | :source="data.wasteMix" |
| | | :metric="mixMetric" |
| | | :unit="mixUnit" |
| | | legend |
| | | :center="['50%', '46%']" |
| | | :radius="['38%', '62%']" |
| | | /> |
| | | </panel-card> |
| | | </el-col> |
| | | <el-col :lg="10" :xs="24"> |
| | |
| | | import PanelCard from '../../components/panel-card.vue'; |
| | | import ScreenCameraPreview from '../../components/screen-camera-preview.vue'; |
| | | import { TIME_RANGE_OPTIONS } from '../../data/screen'; |
| | | import { chartSeries, pieTooltipFormatter, toPieData } from '../../data/screen-chart'; |
| | | import ScreenDrillChart from '../../components/screen-drill-chart.vue'; |
| | | import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen'; |
| | | |
| | | const { data, timeRange } = useScreenData(); |
| | |
| | | { field: 'kpis.totalParticipants', label: '累计参与人数', value: data.value.kpis.totalParticipants, suffix: '人', fixed: 0, hint: '去重校园用户' } |
| | | ]); |
| | | |
| | | const mixOption = computed(() => ({ |
| | | tooltip: { trigger: 'item', ...tooltip(), formatter: pieTooltipFormatter() }, |
| | | legend: { bottom: 0, textStyle: { color: mutedColor.value } }, |
| | | series: [ |
| | | { |
| | | type: 'pie', |
| | | radius: ['38%', '62%'], |
| | | center: ['50%', '46%'], |
| | | padAngle: 3, |
| | | itemStyle: { borderRadius: 6 }, |
| | | label: { color: textColor.value, formatter: '{b}\n{d}%' }, |
| | | data: toPieData( |
| | | chartSeries(data.value.wasteMix, e => e[mixMetric.value]), |
| | | e => e.name, |
| | | e => e.color |
| | | ) |
| | | } |
| | | ] |
| | | })); |
| | | const mixUnit = computed(() => { |
| | | if (mixMetric.value === 'value') return '元'; |
| | | if (mixMetric.value === 'count') return '次'; |
| | | return 'kg'; |
| | | }); |
| | | |
| | | const trendHint = computed(() => { |
| | | const map = { today: '近 7 日', week: '按日', month: '按日', quarter: '按日', year: '按月' }; |
| | |
| | | <el-row :gutter="10" class="mid-row"> |
| | | <el-col :lg="10" :xs="24"> |
| | | <panel-card title="可回收物品类占比" sub="同一时间范围,看结构" :demo="isDemoSection('recycleItems')"> |
| | | <v-chart :option="pieOption" autoresize class="chart" /> |
| | | <screen-drill-chart |
| | | class="chart" |
| | | type="pie" |
| | | :source="data.recycleItems" |
| | | metric="weight" |
| | | unit="kg" |
| | | legend |
| | | :center="['50%', '46%']" |
| | | :radius="['36%', '60%']" |
| | | label="{b} {d}%" |
| | | /> |
| | | </panel-card> |
| | | </el-col> |
| | | <el-col :lg="14" :xs="24"> |
| | | <panel-card title="各类回收量" sub="横向柱状图更适合长品类名" :demo="isDemoSection('recycleItems')"> |
| | | <v-chart :option="weightOption" autoresize class="chart" /> |
| | | <screen-drill-chart |
| | | class="chart" |
| | | type="bar" |
| | | :source="data.recycleItems" |
| | | metric="weight" |
| | | unit="kg" |
| | | :even-split="false" |
| | | /> |
| | | </panel-card> |
| | | </el-col> |
| | | </el-row> |
| | |
| | | import { computed } from 'vue'; |
| | | import KpiCard from '../../components/kpi-card.vue'; |
| | | import PanelCard from '../../components/panel-card.vue'; |
| | | import { chartSeries, pieTooltipFormatter, toPieData } from '../../data/screen-chart'; |
| | | import ScreenDrillChart from '../../components/screen-drill-chart.vue'; |
| | | import { chartSeries } from '../../data/screen-chart'; |
| | | import { useChartTheme, useScreenData, useScreenFieldSource } from '../../hooks/use-screen'; |
| | | |
| | | const { data } = useScreenData(); |
| | | const { isDemoSection } = useScreenFieldSource(); |
| | | const { textColor, mutedColor, splitColor, primary, tooltip } = useChartTheme(); |
| | | const { mutedColor, splitColor, primary, tooltip } = useChartTheme(); |
| | | |
| | | const kpis = computed(() => [ |
| | | { field: 'kpis.periodWeightTon', label: '总量', value: data.value.kpis.periodWeightTon, suffix: '吨', fixed: 2, hint: '全部分类重量合计' }, |
| | |
| | | { field: 'kpis.fabricTon', label: '废旧织物', value: data.value.kpis.fabricTon, suffix: '吨', fixed: 2, hint: '可回收子类' } |
| | | ]); |
| | | |
| | | const pieOption = computed(() => ({ |
| | | tooltip: { trigger: 'item', ...tooltip(), formatter: pieTooltipFormatter('kg') }, |
| | | legend: { bottom: 0, textStyle: { color: mutedColor.value } }, |
| | | series: [ |
| | | { |
| | | type: 'pie', |
| | | radius: ['36%', '60%'], |
| | | center: ['50%', '46%'], |
| | | padAngle: 3, |
| | | itemStyle: { borderRadius: 6 }, |
| | | label: { color: textColor.value, formatter: '{b} {d}%' }, |
| | | data: toPieData( |
| | | chartSeries(data.value.recycleItems, e => e.weight), |
| | | e => e.name, |
| | | e => e.color |
| | | ) |
| | | } |
| | | ] |
| | | })); |
| | | |
| | | const weightRows = computed(() => |
| | | chartSeries(data.value.recycleItems, e => e.weight, { evenSplit: false }).reverse() |
| | | ); |
| | | |
| | | const weightOption = computed(() => ({ |
| | | grid: { containLabel: true, left: 8, right: 28, top: 12, bottom: 8 }, |
| | | tooltip: { trigger: 'axis', ...tooltip() }, |
| | | xAxis: { |
| | | type: 'value', |
| | | name: 'kg', |
| | | splitLine: { lineStyle: { color: splitColor.value, type: 'dashed' } }, |
| | | axisLabel: { color: mutedColor.value }, |
| | | axisLine: { show: false }, |
| | | axisTick: { show: false } |
| | | }, |
| | | yAxis: { |
| | | type: 'category', |
| | | data: weightRows.value.map(e => e.item.name), |
| | | axisLabel: { color: textColor.value }, |
| | | axisLine: { show: false }, |
| | | axisTick: { show: false } |
| | | }, |
| | | series: [ |
| | | { |
| | | type: 'bar', |
| | | barWidth: 16, |
| | | data: weightRows.value.map(e => ({ |
| | | value: e.value, |
| | | itemStyle: { color: e.item.color, borderRadius: [0, 8, 8, 0] } |
| | | })) |
| | | } |
| | | ] |
| | | })); |
| | | |
| | | const valueRows = computed(() => |
| | | chartSeries(data.value.recycleItems, e => e.value, { evenSplit: false }) |