wangrong
7 天以前 5ebdf612b8291106370bba5e79996b28a7452e67
首页图表做下钻
9个文件已修改
1个文件已添加
746 ■■■■■ 已修改文件
src/modules/report/components/screen-drill-chart.vue 426 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/data/screen-chart.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/data/screen-structure.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/data/screen.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/dailydepartment.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/dailysite.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/carbon.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/map.vue 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/overview.vue 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/views/screen/resource.vue 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/modules/report/components/screen-drill-chart.vue
New file
@@ -0,0 +1,426 @@
<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>
src/modules/report/data/screen-chart.ts
@@ -50,6 +50,8 @@
        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) }
    }));
}
src/modules/report/data/screen-structure.ts
@@ -120,6 +120,7 @@
        })),
        recycleItems: RECYCLE_NAMES.map((name, i) => ({
            name,
            code: `SW62${String(i + 1).padStart(3, '0')}`,
            weight: 0,
            value: 0,
            carbon: 0,
src/modules/report/data/screen.ts
@@ -35,7 +35,7 @@
    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[];
src/modules/report/views/dailydepartment.vue
@@ -461,25 +461,28 @@
            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('sw61次数'), prop: "timeSw61", minWidth: 140, sortable: "custom" },
        { label: t('sw62次数'), prop: "timeSw62", minWidth: 140, sortable: "custom" },
        { label: t('sw63次数'), prop: "timeSw63", 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('总碳排量'), 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",
src/modules/report/views/dailysite.vue
@@ -464,26 +464,26 @@
            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('sw61次数'), prop: "timeSw61", minWidth: 140, sortable: "custom" },
        { label: t('sw62次数'), prop: "timeSw62", minWidth: 140, sortable: "custom" },
        { label: t('sw63次数'), prop: "timeSw63", 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('总碳排量'), 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",
src/modules/report/views/screen/carbon.vue
@@ -12,7 +12,17 @@
                </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>
@@ -46,12 +56,12 @@
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 || '本区间'
@@ -127,25 +137,6 @@
    ]
}));
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>
src/modules/report/views/screen/map.vue
@@ -17,7 +17,15 @@
                    <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
@@ -25,7 +33,16 @@
                    :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>
@@ -57,7 +74,14 @@
                    <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>
@@ -85,12 +109,12 @@
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');
@@ -141,45 +165,11 @@
    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(() => ({
@@ -207,51 +197,6 @@
    ]
}));
/** 各类回收量(横向条形) */
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>
src/modules/report/views/screen/overview.vue
@@ -10,7 +10,16 @@
                    <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">
@@ -52,7 +61,7 @@
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();
@@ -88,25 +97,11 @@
    { 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: '按月' };
src/modules/report/views/screen/resource.vue
@@ -7,12 +7,29 @@
            <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>
@@ -35,12 +52,13 @@
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: '全部分类重量合计' },
@@ -50,59 +68,6 @@
    { 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 })