Commit 8c866f0fdeca662fc5b8a7f4bfc2e3caec0177f2

Authored by 杨鸣坤
1 parent cae35271

feat: 重构SafetyDialog为动态数据驱动的能耗查询页面

Showing 1 changed file with 1006 additions and 228 deletions
@@ -11,159 +11,65 @@ @@ -11,159 +11,65 @@
11 > 11 >
12 <template #header> 12 <template #header>
13 <div class="dialog-header"> 13 <div class="dialog-header">
14 - <span class="title-text">{{ device?.name || '能耗设备1' }}</span>  
15 - <div class="header-right">  
16 - <el-icon :size="16" style="cursor:pointer;color:#409eff;"><Refresh /></el-icon>  
17 - <el-icon :size="16" style="cursor:pointer;color:#409eff;margin-left:6px;"><FullScreen /></el-icon>  
18 - <el-icon :size="16" style="cursor:pointer;color:#409eff;margin-left:6px;" @click="$emit('update:visible', false)"><Close /></el-icon>  
19 - </div> 14 + <span class="title-text">{{ device?.name || dtuSn }} 查询方式:</span>
  15 + <el-radio-group v-model="queryMode" size="small" @change="onModeChange">
  16 + <el-radio-button value="hour">时查询</el-radio-button>
  17 + <el-radio-button value="day">日查询</el-radio-button>
  18 + <el-radio-button value="month">月查询</el-radio-button>
  19 + </el-radio-group>
  20 + <el-date-picker v-if="queryMode === 'hour'" v-model="selectedDate"
  21 + type="date" placeholder="" size="small" style="width:160px;margin-left:8px;" value-format="YYYY-MM-DD" @change="fetchData" />
  22 + <el-date-picker v-if="queryMode === 'day'" v-model="dayRange" type="daterange" size="small"
  23 + style="width:240px;margin-left:8px;" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期"
  24 + :disabled-date="disabledDateFuture" @change="fetchData" />
  25 + <el-date-picker v-if="queryMode === 'range'" v-model="dateRange" type="daterange" size="small"
  26 + style="width:240px;margin-left:8px;" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期"
  27 + :disabled-date="disabledDateFuture" @change="fetchData" />
  28 + <el-button type="primary" size="small" style="margin-left:8px;" @click="fetchData">查询</el-button>
  29 + <div style="flex:1"></div>
20 </div> 30 </div>
21 </template> 31 </template>
22 32
23 - <div class="safety-body">  
24 - <!-- 设备运行状态 -->  
25 - <div class="panel">  
26 - <div class="panel-title">设备运行状态:</div>  
27 - <div class="status-chart-area">  
28 - <div class="status-bar-chart">  
29 - <svg viewBox="0 0 700 100">  
30 - <g font-size="10" fill="#999" text-anchor="middle">  
31 - <text x="50" y="85">04:00</text><text x="180" y="85">08:00</text><text x="310" y="85">12:00</text><text x="440" y="85">16:00</text><text x="570" y="85">20:00</text><text x="650" y="85">25:00</text>  
32 - </g>  
33 - <rect x="200" y="15" width="90" height="55" rx="3" fill="#999" opacity="0.7"/>  
34 - </svg>  
35 - </div>  
36 - <el-date-picker v-model="runDate" type="date" size="small" placeholder="2026-04-28" />  
37 - </div>  
38 - </div>  
39 -  
40 - <!-- 运行状态能耗产量复合 -->  
41 - <div class="panel">  
42 - <div class="panel-title">运行状态能耗产量复合</div>  
43 - <div class="composite-row">  
44 - <div class="composite-left">  
45 - <div class="pie-wrap">  
46 - <svg viewBox="0 0 120 120"><circle cx="60" cy="60" r="48" fill="#e8f4fd" stroke="#ddd"/>  
47 - <circle cx="60" cy="60" r="38" fill="none" stroke="#409eff" stroke-width="12"  
48 - stroke-dasharray="239 239" transform="rotate(-90 60 60)"/>  
49 - <text x="60" y="58" text-anchor="middle" font-size="11" fill="#333">正常</text>  
50 - <text x="60" y="72" text-anchor="middle" font-size="9" fill="#666">100%</text>  
51 - </svg>  
52 - </div>  
53 - <div class="composite-info">  
54 - <div class="ci-row"><span class="ci-dot b"></span>本月耗电</div>  
55 - <div class="ci-row"><span class="ci-dot g"></span>日产量</div>  
56 - </div>  
57 - </div>  
58 - <div class="composite-right">  
59 - <div>待机电能范围:<b>0.00A ≤ I ≤ 0.00A</b></div>  
60 - <div>运行电能范围:<b>I ≥ 0.00A</b></div>  
61 - </div>  
62 - </div>  
63 - </div>  
64 -  
65 - <!-- 运行时长统计 + 健康度/能效统计 -->  
66 - <div class="two-col-row">  
67 - <div class="panel flex-1">  
68 - <div class="panel-title">运行时长统计:</div>  
69 - <div class="bar-legend">  
70 - <span class="leg"><i class="dot o"/>停机</span>  
71 - <span class="leg"><i class="dot g"/>待机</span>  
72 - <span class="leg"><i class="dot b"/>运行</span>  
73 - <span class="leg"><i class="dot gy"/>离线</span>  
74 - </div>  
75 - <div class="runtime-bar-chart">  
76 - <svg viewBox="0 0 600 160">  
77 - <g font-size="9" fill="#999" text-anchor="end">  
78 - <text x="24" y="18">24</text><text x="24" y="46">18</text><text x="24" y="74">12</text>  
79 - <text x="24" y="102">6</text><text x="24" y="130">0</text>  
80 - </g>  
81 - <line x1="30" y1="126" x2="580" y2="126" stroke="#ddd"/>  
82 - <rect x="36" y="86" width="14" height="40" fill="#999" rx="1"/><text x="43" y="141" text-anchor="middle" font-size="7">03-13</text>  
83 - <rect x="54" y="106" width="14" height="20" fill="#999" rx="1"/><text x="61" y="141" text-anchor="middle" font-size="7">03-14</text>  
84 - <rect x="72" y="126" width="14" height="0" rx="1"/><text x="79" y="141" text-anchor="middle" font-size="7">03-15</text>  
85 - <rect x="90" y="126" width="14" height="0" rx="1"/><text x="97" y="141" text-anchor="middle" font-size="7">03-16</text>  
86 - <rect x="108" y="126" width="14" height="0" rx="1"/><text x="115" y="141" text-anchor="middle" font-size="7">03-17</text>  
87 - <rect x="126" y="126" width="14" height="0" rx="1"/><text x="133" y="141" text-anchor="middle" font-size="7">03-18</text>  
88 - <rect x="144" y="126" width="14" height="0" rx="1"/><text x="151" y="141" text-anchor="middle" font-size="7">03-19</text>  
89 - <rect x="162" y="126" width="14" height="0" rx="1"/><text x="169" y="141" text-anchor="middle" font-size="7">03-20</text>  
90 - <rect x="180" y="126" width="14" height="0" rx="1"/><text x="187" y="141" text-anchor="middle" font-size="7">03-21</text>  
91 - <rect x="198" y="126" width="14" height="0" rx="1"/><text x="205" y="141" text-anchor="middle" font-size="7">03-22</text>  
92 - <rect x="216" y="116" width="14" height="10" fill="#999" rx="1"/><text x="223" y="141" text-anchor="middle" font-size="7">03-23</text>  
93 - <rect x="234" y="56" width="14" height="70" fill="#999" rx="1"/><text x="241" y="141" text-anchor="middle" font-size="7">03-24</text>  
94 - <rect x="252" y="46" width="14" height="80" fill="#999" rx="1"/><text x="259" y="141" text-anchor="middle" font-size="7">03-25</text>  
95 - <rect x="270" y="46" width="14" height="80" fill="#999" rx="1"/><text x="277" y="141" text-anchor="middle" font-size="7">03-26</text>  
96 - <rect x="288" y="76" width="14" height="50" fill="#999" rx="1"/><text x="295" y="141" text-anchor="middle" font-size="7">03-27</text>  
97 - <rect x="306" y="96" width="14" height="30" fill="#999" rx="1"/><text x="313" y="141" text-anchor="middle" font-size="7">03-28</text>  
98 - <line x1="330" y1="10" x2="330" y2="130" stroke="#eee" stroke-dasharray="3,3"/>  
99 - <text x="380" y="75" text-anchor="middle" font-size="11" fill="#666" opacity="0.6">2026-04-13 — 2026-04-28</text>  
100 - </svg>  
101 - </div>  
102 - </div>  
103 -  
104 - <div class="right-stats">  
105 - <!-- 健康度 -->  
106 - <div class="stat-card">  
107 - <div class="sc-header">健康度:</div>  
108 - <div class="sc-content">  
109 - <div class="score-circle">  
110 - <svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="42" fill="none" stroke="#eee" stroke-width="8"/>  
111 - <circle cx="50" cy="50" r="42" fill="none" stroke="#e6a23c" stroke-width="8" stroke-dasharray="2 262" transform="rotate(-90 50 50)"/>  
112 - <text x="50" y="53" text-anchor="middle" font-size="18" font-weight="bold" fill="#333">0.00</text>  
113 - </svg>  
114 - </div>  
115 - <div class="sc-detail">  
116 - <div class="sd-row"><span class="sd-label">总评分:</span><span class="sd-val">0.00</span></div>  
117 - <div class="sd-row"><span class="sd-label">月最大需量</span></div>  
118 - <div class="sd-big"><b>0.00Kw</b></div>  
119 - </div>  
120 - <div class="sc-bars">  
121 - <div v-for="(item, i) in healthBars" :key="i" :class="['hb-item', 'hb-'+item.color]">  
122 - {{ item.pct }}% {{ item.label }}  
123 - </div>  
124 - </div> 33 + <div class="safety-body" v-loading="loading">
  34 + <!-- 主内容区:左组合图 + 右饼图 -->
  35 + <div class="main-row">
  36 + <!-- 左侧:运行时长明细(堆叠柱状图+折线双轴) -->
  37 + <div class="combo-panel">
  38 + <div class="panel-title-row">
  39 + <span class="panel-title">运行时长明细</span>
  40 + <div class="legend-right">
  41 + <span class="leg"><i class="dot g"/>运行</span>
  42 + <span class="leg"><i class="dot y"/>待机</span>
  43 + <span class="leg"><i class="dot r"/>停机</span>
  44 + <span class="leg"><i class="dot gy"/>离线</span>
  45 + <span class="leg line-leg"><i class="line-dot"/>用电量</span>
125 </div> 46 </div>
126 </div> 47 </div>
127 -  
128 - <!-- 能效统计 -->  
129 - <div class="stat-card">  
130 - <div class="sc-header">能效统计:</div>  
131 - <div class="sc-content">  
132 - <div class="score-circle small">  
133 - <svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="42" fill="none" stroke="#eee" stroke-width="8"/>  
134 - <text x="50" y="53" text-anchor="middle" font-size="18" font-weight="bold" fill="#333">0</text>  
135 - </svg>  
136 - </div>  
137 - <div class="ef-bars">  
138 - <div v-for="(item, i) in effBars" :key="i" class="eb-item">  
139 - <div class="eb-bar-wrap"><div :class="['eb-fill', 'fill-' + item.color]" :style="{width:item.val+'%'}"></div></div>  
140 - <div class="eb-label">{{ item.val }}<br/>{{ item.label }}</div>  
141 - </div> 48 + <div class="combo-canvas-wrap" ref="comboWrapRef" @mousemove="onComboMouseMove" @mouseleave="onComboMouseLeave">
  49 + <canvas ref="comboCanvasRef"></canvas>
  50 + <div v-if="comboHover.show" class="combo-tooltip" :style="{ left: comboHover.x + 'px', top: comboHover.y + 'px' }">
  51 + <div class="ctt-row" v-for="(row, i) in comboTtRows" :key="i">
  52 + <span class="ctt-label" :style="{ color: row.color }"><i v-if="row.dot" :class="['dot', row.dot]"></i><i v-if="row.isLine" class="line-dot-sm"></i>{{ row.label }}</span>
  53 + <span class="ctt-val" :class="{ 'ctt-highlight': row.highlight }">{{ row.value }}</span>
142 </div> 54 </div>
143 - <div class="ef-bottom">日能效曲线</div>  
144 </div> 55 </div>
145 </div> 56 </div>
146 </div> 57 </div>
147 - </div>  
148 58
149 - <!-- 产量分析 + 日投入产出 -->  
150 - <div class="two-col-row">  
151 - <div class="panel flex-1">  
152 - <div class="panel-title">产量分析:</div>  
153 - <div class="prod-bars">  
154 - <div v-for="(item, i) in prodData" :key="i" class="prod-item">  
155 - <div :class="['pb-dot', item.color]"></div>  
156 - <div>{{ item.label }}</div>  
157 - <div class="pb-val">{{ item.val }}</div> 59 + <!-- 右侧:运行时长统计(饼图+总数据) -->
  60 + <div class="stat-panel">
  61 + <div class="panel-title">运行时长统计</div>
  62 + <div class="stat-duration">{{ apiData.summary.totalDurationFormatted }}</div>
  63 + <div class="pie-canvas-wrap">
  64 + <canvas ref="pieCanvasRef" @mousemove="onPieMouseMove" @mouseleave="onPieMouseLeave"></canvas>
  65 + <div v-if="pieHover.show" class="pie-tooltip" :style="{ left: pieHover.x + 'px', top: pieHover.y + 'px' }">
  66 + <div class="ptt-row"><span class="ptt-label">状态</span><span class="ptt-val" :style="{ color: STATUS_COLORS[pieTtData.status] }"><i :class="['dot', statusDotClass(pieTtData.status)]"></i>{{ statusLabel(pieTtData.status) }}</span></div>
  67 + <div class="ptt-row"><span class="ptt-label">占比</span><span class="ptt-val ptt-highlight">{{ pieTtData.percent }}%</span></div>
158 </div> 68 </div>
159 </div> 69 </div>
160 - <div class="prod-line">日产量曲线</div>  
161 - </div>  
162 - <div class="panel flex-1">  
163 - <div class="panel-title">日投入产出、日生产效率</div>  
164 - <div class="io-legend">  
165 - <span class="leg"><i class="dot b"/>收入产出比</span>  
166 - <span class="leg"><i class="dot g"/>生产效率</span> 70 + <div class="stat-kwh-area">
  71 + <div class="sk-label">总用电量</div>
  72 + <div class="sk-value">{{ formatKwh(apiData.summary.totalKwh) }}Kw.h</div>
167 </div> 73 </div>
168 </div> 74 </div>
169 </div> 75 </div>
@@ -172,94 +78,966 @@ @@ -172,94 +78,966 @@
172 </template> 78 </template>
173 79
174 <script setup> 80 <script setup>
175 -import { ref } from 'vue'  
176 -import { Refresh, FullScreen, Close } from '@element-plus/icons-vue'  
177 -  
178 -defineProps({ visible: Boolean, device: Object })  
179 -defineEmits(['update:visible'])  
180 -  
181 -const runDate = ref('')  
182 -const healthBars = [  
183 - { pct: 0.00, label: '自愈率', color: 'orange' },  
184 - { pct: 0.00, label: '电平平衡率', color: 'green' },  
185 - { pct: 0.00, label: '总故障数', color: 'red' },  
186 - { pct: 0.00, label: '电压不平衡', color: 'blue' }  
187 -]  
188 -const effBars = [  
189 - { val: 0.00, label: '日累计运行时长', color: 'orange' },  
190 - { val: 0.00, label: '日累计能耗', color: 'green' },  
191 - { val: 0.00, label: '月累计运行时长', color: 'red' },  
192 - { val: 0.00, label: '月累计能耗', color: 'blue' }  
193 -]  
194 -const prodData = [  
195 - { label: '日累计产量', val: '0.00', color: 'o' },  
196 - { label: '月累计产量', val: '0.00', color: 'g' },  
197 - { label: '日投入产出', val: '0.00', color: 'r' },  
198 - { label: '日生产效率', val: '0.00', color: 'b' }  
199 -] 81 +import { ref, reactive, computed, watch, nextTick, onBeforeUnmount } from 'vue'
  82 +import { Download, Close } from '@element-plus/icons-vue'
  83 +
  84 +const props = defineProps({ visible: Boolean, device: Object })
  85 +const emit = defineEmits(['update:visible'])
  86 +
  87 +// ==================== 查询模式 ====================
  88 +// 近一周默认范围
  89 +const today = new Date().toISOString().slice(0, 10)
  90 +const weekAgo = new Date(Date.now() - 6 * 86400000).toISOString().slice(0, 10)
  91 +const queryMode = ref('hour')
  92 +const selectedDate = ref(today)
  93 +const dayRange = ref([weekAgo, today])
  94 +const dateRange = ref(null)
  95 +
  96 +// 禁用未来日期
  97 +function disabledDateFuture(time) {
  98 + return time.getTime() > Date.now()
  99 +}
  100 +
  101 +function onModeChange() {
  102 + if (queryMode.value === 'day' || queryMode.value === 'range') {
  103 + // 切到日/范围模式,使用默认日期范围立即查询
  104 + nextTick(() => fetchData())
  105 + } else {
  106 + fetchData()
  107 + }
  108 +}
  109 +
  110 +const dtuSn = computed(() => props.device?._raw?.dtuSn || props.device?.name || '')
  111 +
  112 +// ==================== API 数据 ====================
  113 +const loading = ref(false)
  114 +const apiData = reactive({
  115 + summary: { totalKwh: 0, totalDurationSeconds: 0, statusStats: [], totalDurationFormatted: '0秒' },
  116 + detailList: []
  117 +})
  118 +
  119 +// ==================== 工具函数 ====================
  120 +const STATUS_MAP = { 0: '离线', 1: '停机', 2: '运行', 3: '待机' }
  121 +function statusLabel(s) { return STATUS_MAP[s] || '未知' }
  122 +function statusDotClass(s) { return { 0: 'gy', 1: 'r', 2: 'g', 3: 'y' }[s] || 'gy' }
  123 +
  124 +const STATUS_COLORS = { 0: '#909399', 1: '#e74c3c', 2: '#67c23a', 3: '#c5d94e' }
  125 +const STATUS_COLORS_HOVER = { 0: '#7a7d82', 1: '#d63a3a', 2: '#4cae4c', 3: '#b8cc38' }
  126 +
  127 +function formatKwh(v) {
  128 + if (!v && v !== 0) return '0'
  129 + return Number(v).toFixed(v % 1 === 0 ? 0 : 2)
  130 +}
  131 +function formatDuration(seconds) {
  132 + if (!seconds && seconds !== 0) return '0秒'
  133 + seconds = Number(seconds)
  134 + if (seconds <= 0) return '0秒'
  135 + const h = Math.floor(seconds / 3600)
  136 + const m = Math.floor((seconds % 3600) / 60)
  137 + const s = seconds % 60
  138 + let str = ''
  139 + if (h > 0) str += h + '时'
  140 + if (m > 0) str += m + '分'
  141 + if (s > 0 || !str) str += s + '秒'
  142 + return str
  143 +}
  144 +function formatHours(seconds) {
  145 + if (!seconds && seconds !== 0) return '0'
  146 + const h = seconds / 3600
  147 + return h.toFixed(2).replace(/\.?0+$/, '') + '时'
  148 +}
  149 +
  150 +// ==================== 获取当前 kwhList(扁平化) ====================
  151 +const flatKwhList = computed(() => {
  152 + const dl = apiData.detailList
  153 + if (!dl || !dl.length) return []
  154 + // type=1: detailList[0].kwhList 每项已有 0/1/2/3 秒数 + value 用电量
  155 + if (dl[0] && dl[0].kwhList) return dl[0].kwhList
  156 + // type=2/3: detailList 每项只有 statusStats[{status,durationSeconds}] + totalKwh,需要转换
  157 + return dl.map(item => {
  158 + const row = { date: item.date || '', value: item.totalKwh || 0 }
  159 + ;(item.statusStats || []).forEach(s => {
  160 + row[s.status] = s.durationSeconds || 0
  161 + })
  162 + // 确保 0/1/2/3 都存在
  163 + for (let k = 0; k <= 3; k++) row[k] = row[k] ?? 0
  164 + return row
  165 + })
  166 +})
  167 +
  168 +// ==================== 获取 X轴 标签 ====================
  169 +function getXLabel(item, idx) {
  170 + if (queryMode.value === 'hour') {
  171 + return item.date || (idx + 1) + '时'
  172 + }
  173 + // day / range / month 都按日期显示
  174 + return item.date || (idx + 1).toString()
  175 +}
  176 +
  177 +// ==================== Canvas refs ====================
  178 +const comboCanvasRef = ref(null)
  179 +const comboWrapRef = ref(null)
  180 +const pieCanvasRef = ref(null)
  181 +
  182 +let resizeObserver = null
  183 +let canvasRAF = null
  184 +
  185 +// ==================== 组合图 Hover ====================
  186 +const comboHover = reactive({ show: false, x: 0, y: 0, index: -1 })
  187 +let comboBarRects = []
  188 +
  189 +const comboTtRows = computed(() => {
  190 + if (!comboHover.show || comboHover.index < 0) return []
  191 + const list = flatKwhList.value
  192 + const item = list[comboHover.index]
  193 + if (!item) return []
  194 + const rows = []
  195 + // X轴标签
  196 + rows.push({ label: getXLabel(item, comboHover.index), value: '', color: '#333', highlight: true })
  197 + // 各状态时长 - 时查询优先使用接口返回的 Formatted
  198 + const isHourMode = queryMode.value === 'hour'
  199 + const statuses = [
  200 + { key: 2, dot: 'g', label: '运行' },
  201 + { key: 3, dot: 'y', label: '待机' },
  202 + { key: 1, dot: 'r', label: '停机' },
  203 + { key: 0, dot: 'gy', label: '离线' }
  204 + ]
  205 + statuses.forEach(s => {
  206 + const sec = item[s.key] ?? 0
  207 + if (sec > 0) {
  208 + const totalSec = (item['0']||0)+(item['1']||0)+(item['2']||0)+(item['3']||0)
  209 + const pctStr = totalSec > 0 ? ` (${(sec/totalSec*100).toFixed(1)}%)` : ''
  210 + if (isHourMode && item[s.key + 'Formatted']) {
  211 + rows.push({ label: s.label, value: item[s.key + 'Formatted'] + pctStr, color: STATUS_COLORS[s.key], dot: s.dot })
  212 + } else {
  213 + rows.push({ label: s.label, value: formatHours(sec) + pctStr, color: STATUS_COLORS[s.key], dot: s.dot })
  214 + }
  215 + }
  216 + })
  217 + // 用电量
  218 + const kwhVal = item.value ?? 0
  219 + rows.push({ label: '用电量', value: kwhVal + ' Kw.h', color: '#409eff', isLine: true, highlight: true })
  220 + return rows
  221 +})
  222 +
  223 +function onComboMouseMove(e) {
  224 + const canvas = comboCanvasRef.value
  225 + if (!canvas) return
  226 + const rect = canvas.getBoundingClientRect()
  227 + const mx = e.clientX - rect.left
  228 + const my = e.clientY - rect.top
  229 +
  230 + let hoveredIdx = -1
  231 + for (let i = 0; i < comboBarRects.length; i++) {
  232 + const br = comboBarRects[i]
  233 + if (mx >= br.x && mx <= br.x + br.w && my >= br.y && my <= br.y + br.h) {
  234 + hoveredIdx = i
  235 + break
  236 + }
  237 + }
  238 +
  239 + if (hoveredIdx !== comboHover.index) {
  240 + comboHover.index = hoveredIdx
  241 + comboHover.show = hoveredIdx >= 0
  242 + if (hoveredIdx >= 0) {
  243 + const ttW = 160
  244 + const ttH = comboTtRows.value.length * 28 + 12
  245 + let tx = mx + 10
  246 + let ty = my - ttH - 8
  247 + if (tx + ttW > rect.width - 6) tx = mx - ttW - 8
  248 + if (ty < 6) ty = my + 14
  249 + comboHover.x = Math.max(4, tx)
  250 + comboHover.y = Math.max(4, ty)
  251 + }
  252 + drawComboChart()
  253 + }
  254 +}
  255 +
  256 +function onComboMouseLeave() {
  257 + comboHover.show = false
  258 + comboHover.index = -1
  259 + drawComboChart()
  260 +}
  261 +
  262 +// ==================== 饼图 Hover ====================
  263 +const pieHover = reactive({ show: false, x: 0, y: 0, index: -1 })
  264 +const pieTtData = computed(() => {
  265 + if (!pieHover.show || pieHover.index < 0) return { status: '-', percent: '-' }
  266 + const stats = apiData.summary.statusStats || []
  267 + const item = stats[pieHover.index]
  268 + if (!item) return { status: '-', percent: '-' }
  269 + return { status: item.status, percent: (item.percent || 0).toFixed(2) }
  270 +})
  271 +let pieAngleRanges = []
  272 +
  273 +function onPieMouseMove(e) {
  274 + const canvas = pieCanvasRef.value
  275 + if (!canvas) return
  276 + const rect = canvas.getBoundingClientRect()
  277 + const mx = e.clientX - rect.left
  278 + const my = e.clientY - rect.top
  279 + const scaleX = canvas.width / rect.width
  280 + const scaleY = canvas.height / rect.height
  281 + const px = mx * scaleX
  282 + const py = my * scaleY
  283 +
  284 + let hoveredIdx = -1
  285 + for (let i = pieAngleRanges.length - 1; i >= 0; i--) {
  286 + const seg = pieAngleRanges[i]
  287 + const dx = px - seg.cx
  288 + const dy = py - seg.cy
  289 + const dist = Math.sqrt(dx * dx + dy * dy)
  290 + if (dist > seg.radius) continue
  291 + let mouseAngle = Math.atan2(dy, dx)
  292 + function inRange(a, sa, ea) {
  293 + let na = a - sa, nea = ea - sa
  294 + if (nea < 0) nea += Math.PI * 2
  295 + if (na < 0) na += Math.PI * 2
  296 + return na >= 0 && na <= nea
  297 + }
  298 + if (inRange(mouseAngle, seg.startAngle, seg.endAngle)) {
  299 + hoveredIdx = i
  300 + break
  301 + }
  302 + }
  303 +
  304 + if (hoveredIdx !== pieHover.index) {
  305 + pieHover.index = hoveredIdx
  306 + pieHover.show = hoveredIdx >= 0
  307 + if (hoveredIdx >= 0) {
  308 + const ttW = 120
  309 + let tx = mx + 10, ty = my - 54
  310 + if (tx + ttW > rect.width - 4) tx = mx - ttW - 8
  311 + if (ty < 4) ty = my + 14
  312 + pieHover.x = Math.max(4, tx)
  313 + pieHover.y = Math.max(4, ty)
  314 + }
  315 + drawPieChart()
  316 + }
  317 +}
  318 +
  319 +function onPieMouseLeave() {
  320 + pieHover.show = false
  321 + pieHover.index = -1
  322 + drawPieChart()
  323 +}
  324 +
  325 +// ==================== 接口调用 ====================
  326 +async function fetchData() {
  327 + if (!dtuSn.value) return
  328 + loading.value = true
  329 + try {
  330 + let type = 1
  331 + let sd = selectedDate.value
  332 + let ed = ''
  333 +
  334 + if (queryMode.value === 'hour') {
  335 + type = 1; sd = selectedDate.value
  336 + } else if (queryMode.value === 'day') {
  337 + type = 2
  338 + if (!dayRange.value || !dayRange.value.length) return
  339 + sd = dayRange.value[0]; ed = dayRange.value[1]
  340 + } else if (queryMode.value === 'range') {
  341 + if (!dateRange.value || !dateRange.value.length) return
  342 + type = 2; sd = dateRange.value[0]; ed = dateRange.value[1]
  343 + } else if (queryMode.value === 'month') {
  344 + type = 3
  345 + const now = new Date()
  346 + sd = now.getFullYear() + '-01-01'
  347 + }
  348 +
  349 + let url = `/api/energy/runtimeDetail?dtuSn=${dtuSn.value}&startDate=${sd}&type=${type}`
  350 + if (ed) url += '&endDate=' + ed
  351 +
  352 + const res = await fetch(url)
  353 + const data = await res.json()
  354 + if (data.code === 200) {
  355 + Object.assign(apiData.summary, data.summary || {})
  356 + apiData.detailList = data.detailList || []
  357 + await nextTick()
  358 + drawAllCharts()
  359 + }
  360 + } catch (err) {
  361 + console.error('获取能耗详情失败:', err)
  362 + } finally {
  363 + loading.value = false
  364 + }
  365 +}
  366 +
  367 +function exportData() {
  368 + alert('导出功能开发中')
  369 +}
  370 +
  371 +// ==================== 生命周期 & Observer ====================
  372 +watch(() => props.visible, async (val) => {
  373 + if (val) {
  374 + await nextTick()
  375 + initCanvasObserver()
  376 + fetchData()
  377 + } else {
  378 + destroyCanvasObserver()
  379 + }
  380 +})
  381 +
  382 +onBeforeUnmount(() => destroyCanvasObserver())
  383 +
  384 +function initCanvasObserver() {
  385 + destroyCanvasObserver()
  386 + const dialogBody = document.querySelector('.safety-dialog .el-dialog__body')
  387 + if (!dialogBody) return
  388 + resizeObserver = new ResizeObserver(() => {
  389 + if (canvasRAF) cancelAnimationFrame(canvasRAF)
  390 + canvasRAF = requestAnimationFrame(drawAllCharts)
  391 + })
  392 + resizeObserver.observe(dialogBody)
  393 +}
  394 +
  395 +function destroyCanvasObserver() {
  396 + if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null }
  397 + if (canvasRAF) { cancelAnimationFrame(canvasRAF); canvasRAF = null }
  398 +}
  399 +
  400 +// ==================== Canvas 基础工具 ====================
  401 +function getDpr() { return window.devicePixelRatio || 1 }
  402 +
  403 +function setupCanvas(canvas, w, h) {
  404 + const dpr = getDpr()
  405 + canvas.width = w * dpr
  406 + canvas.height = h * dpr
  407 + canvas.style.width = w + 'px'
  408 + canvas.style.height = h + 'px'
  409 + const ctx = canvas.getContext('2d')
  410 + ctx.scale(dpr, dpr)
  411 + return ctx
  412 +}
  413 +
  414 +function drawAllCharts() {
  415 + drawComboChart()
  416 + drawPieChart()
  417 +}
  418 +
  419 +// ==================== 1. 组合图:堆叠柱状图 + 折线双轴 ====================
  420 +function drawComboChart() {
  421 + const canvas = comboCanvasRef.value
  422 + if (!canvas) return
  423 + const wrap = comboWrapRef.value
  424 + const w = wrap ? wrap.clientWidth : 800
  425 + const h = 652
  426 + const ctx = setupCanvas(canvas, w, h)
  427 +
  428 + ctx.fillStyle = '#fff'
  429 + ctx.fillRect(0, 0, w, h)
  430 +
  431 + const list = flatKwhList.value
  432 + comboBarRects = []
  433 +
  434 + if (!list.length) {
  435 + ctx.fillStyle = '#999'
  436 + ctx.font = '14px sans-serif'
  437 + ctx.textAlign = 'center'
  438 + ctx.fillText('暂无数据', w / 2, h / 2)
  439 + return
  440 + }
  441 +
  442 + // 布局参数
  443 + const padLeft = 48
  444 + const padRight = 58 // 右侧 Y 轴(用电量)
  445 + const padTop = 32
  446 + const padBottom = 36
  447 + const chartW = w - padLeft - padRight
  448 + const chartH = h - padTop - padBottom
  449 +
  450 + // 计算左侧Y轴最大值(时长,秒 -> 小时)
  451 + const isHourQuery = queryMode.value === 'hour'
  452 + const defaultMaxSec = isHourQuery ? 7200 : 3600 // 时查询默认2小时,其他默认1小时
  453 + const maxSec = Math.max(...list.map(item => {
  454 + return (item['0']||0) + (item['1']||0) + (item['2']||0) + (item['3']||0)
  455 + }), defaultMaxSec)
  456 + const maxHours = maxSec / 3600
  457 + const niceMaxHours = calcNiceMax(maxHours)
  458 +
  459 + // 计算右侧Y轴最大值(用电量)
  460 + const maxKwh = Math.max(...list.map(d => d.value || 0), 10)
  461 + const niceMaxKwh = calcNiceMax(maxKwh)
  462 +
  463 + // ========== 左侧Y轴标签(时长) ==========
  464 + ctx.fillStyle = '#666'
  465 + ctx.font = '11px sans-serif'
  466 + ctx.textAlign = 'right'
  467 + const leftSteps = 5
  468 + for (let i = 0; i <= leftSteps; i++) {
  469 + const val = (niceMaxHours / leftSteps) * i
  470 + const y = padTop + chartH - (val / niceMaxHours) * chartH
  471 + ctx.fillText(val.toFixed(2).replace(/\.?0+$/, '') + '时', padLeft - 6, y + 4)
  472 + // 网格线
  473 + ctx.strokeStyle = '#f0f0f0'
  474 + ctx.lineWidth = 0.5
  475 + ctx.beginPath()
  476 + ctx.moveTo(padLeft, y)
  477 + ctx.lineTo(w - padRight, y)
  478 + ctx.stroke()
  479 + }
  480 +
  481 + // ========== 右侧Y轴标签(用电量) ==========
  482 + ctx.textAlign = 'left'
  483 + const rightSteps = 5
  484 + for (let i = 0; i <= rightSteps; i++) {
  485 + const val = (niceMaxKwh / rightSteps) * i
  486 + const y = padTop + chartH - (val / niceMaxKwh) * chartH
  487 + ctx.fillText(val.toFixed(val % 1 === 0 ? 0 : 1) + 'Kw/h', w - padRight + 6, y + 4)
  488 + }
  489 +
  490 + // ========== X轴线 ==========
  491 + ctx.strokeStyle = '#ddd'
  492 + ctx.lineWidth = 1
  493 + ctx.beginPath()
  494 + ctx.moveTo(padLeft, padTop + chartH)
  495 + ctx.lineTo(w - padRight, padTop + chartH)
  496 + ctx.stroke()
  497 +
  498 + // ========== 柱状图参数 ==========
  499 + const barCount = list.length
  500 + const gapRatio = 0.25
  501 + const barW = Math.min(60, Math.max(6, (chartW / barCount) * (1 - gapRatio)))
  502 + const gap = (chartW / barCount) * gapRatio
  503 + const hoverIdx = comboHover.index
  504 +
  505 + // 折线数据点缓存
  506 + const linePoints = []
  507 +
  508 + list.forEach((item, i) => {
  509 + const x = padLeft + (i * (chartW / barCount)) + gap / 2
  510 +
  511 + // --- 堆叠柱 ---
  512 + const stackOrder = [2, 3, 1, 0] // 运行、待机、停机、离线
  513 + let curY = padTop + chartH
  514 + let totalSec = 0
  515 +
  516 + stackOrder.forEach(statusKey => {
  517 + const sec = item[statusKey] || 0
  518 + if (sec <= 0) return
  519 + totalSec += sec
  520 + const barH = (sec / 3600 / niceMaxHours) * chartH
  521 + curY -= barH
  522 +
  523 + const isHover = (i === hoverIdx)
  524 +
  525 + if (isHover) {
  526 + const hw = barW + 4
  527 + const hx = x - 2
  528 + ctx.save()
  529 + ctx.shadowColor = 'rgba(0,0,0,0.15)'
  530 + ctx.shadowBlur = 8
  531 + ctx.shadowOffsetY = 2
  532 + ctx.fillStyle = STATUS_COLORS_HOVER[statusKey] || STATUS_COLORS[statusKey]
  533 + ctx.beginPath()
  534 + ctx.roundRect(hx, curY, hw, barH, 2)
  535 + ctx.fill()
  536 + ctx.restore()
  537 + } else {
  538 + ctx.fillStyle = STATUS_COLORS[statusKey]
  539 + ctx.globalAlpha = 0.85
  540 + ctx.fillRect(x, curY, barW, barH)
  541 + ctx.globalAlpha = 1
  542 + }
  543 + })
  544 +
  545 + // 存储碰撞矩形(整个堆叠柱区域)
  546 + const fullH = (totalSec / 3600 / niceMaxHours) * chartH
  547 + comboBarRects.push({
  548 + x, y: padTop + chartH - fullH, w: barW, h: fullH,
  549 + totalSec, kwh: item.value || 0
  550 + })
  551 +
  552 + // --- X轴标签 ---
  553 + const lbl = getXLabel(item, i)
  554 + ctx.fillStyle = i === hoverIdx ? '#409eff' : '#666'
  555 + ctx.font = `${i === hoverIdx ? 'bold ' : ''}10px sans-serif`
  556 + ctx.textAlign = 'center'
  557 + // 旋转或截断长标签
  558 + const maxLblW = barW + gap - 2
  559 + ctx.save()
  560 + const txtW = ctx.measureText(lbl).width
  561 + if (txtW > maxLblW && maxLblW > 20) {
  562 + // 截断显示
  563 + let shortLbl = lbl
  564 + while (ctx.measureText(shortLbl + '...').width > maxLblW && shortLbl.length > 2) {
  565 + shortLbl = shortLbl.slice(0, -1)
  566 + }
  567 + ctx.fillText(shortLbl + '...', x + barW / 2, padTop + chartH + 16)
  568 + } else {
  569 + ctx.fillText(lbl, x + barW / 2, padTop + chartH + 16)
  570 + }
  571 + ctx.restore()
  572 +
  573 + // --- 折线数据点 ---
  574 + const kwhVal = item.value || 0
  575 + const ly = padTop + chartH - (kwhVal / niceMaxKwh) * chartH
  576 + linePoints.push({ x: x + barW / 2, y: ly, val: kwhVal })
  577 + })
  578 +
  579 + // ========== 绘制折线(用电量) ==========
  580 + if (linePoints.length > 0) {
  581 + // 折线
  582 + ctx.strokeStyle = '#409eff'
  583 + ctx.lineWidth = 1.8
  584 + ctx.beginPath()
  585 + linePoints.forEach((pt, i) => {
  586 + if (i === 0) ctx.moveTo(pt.x, pt.y)
  587 + else ctx.lineTo(pt.x, pt.y)
  588 + })
  589 + ctx.stroke()
  590 +
  591 + // 数据点圆圈
  592 + linePoints.forEach((pt, i) => {
  593 + const isHoverPt = (i === hoverIdx)
  594 + ctx.beginPath()
  595 + ctx.arc(pt.x, pt.y, isHoverPt ? 4.5 : 3, 0, Math.PI * 2)
  596 + ctx.fillStyle = '#fff'
  597 + ctx.fill()
  598 + ctx.strokeStyle = '#409eff'
  599 + ctx.lineWidth = isHoverPt ? 2 : 1.5
  600 + ctx.stroke()
  601 +
  602 + // hover 时显示数值
  603 + if (isHoverPt) {
  604 + ctx.fillStyle = '#409eff'
  605 + ctx.font = 'bold 11px sans-serif'
  606 + ctx.textAlign = 'center'
  607 + ctx.fillText(pt.val + '', pt.x, pt.y - 8)
  608 + }
  609 + })
  610 + }
  611 +
  612 + // "Duration" 提示文字
  613 + ctx.fillStyle = '#bbb'
  614 + ctx.font = '10px sans-serif'
  615 + ctx.textAlign = 'left'
  616 + ctx.fillText('Duration', padLeft, 20)
  617 + ctx.textAlign = 'right'
  618 + ctx.fillText('用电量', w - padRight, 20)
  619 +}
  620 +
  621 +function calcNiceMax(val) {
  622 + if (val <= 0) return 10
  623 + const mag = Math.pow(10, Math.floor(Math.log10(val)))
  624 + const norm = val / mag
  625 + let nice
  626 + if (norm <= 1) nice = 1
  627 + else if (norm <= 2) nice = 2
  628 + else if (norm <= 5) nice = 5
  629 + else nice = 10
  630 + return nice * mag
  631 +}
  632 +
  633 +// ==================== 2. 实心饼图 + 外部标签 ====================
  634 +function drawPieChart() {
  635 + const canvas = pieCanvasRef.value
  636 + if (!canvas) return
  637 + const wrap = canvas.parentElement
  638 + const w = wrap.clientWidth
  639 + const h = 435
  640 + const ctx = setupCanvas(canvas, w, h)
  641 +
  642 + ctx.clearRect(0, 0, w, h)
  643 +
  644 + const stats = apiData.summary.statusStats || []
  645 + pieAngleRanges = []
  646 +
  647 + if (!stats.length) {
  648 + ctx.fillStyle = '#999'
  649 + ctx.font = '13px sans-serif'
  650 + ctx.textAlign = 'center'
  651 + ctx.textBaseline = 'middle'
  652 + ctx.fillText('暂无数据', w / 2, h / 2)
  653 + return
  654 + }
  655 +
  656 + const cx = w * 0.46
  657 + const cy = h * 0.50
  658 + const radius = Math.min(cx, cy) - 22
  659 + const MIN_SLICE_DEG = 2
  660 +
  661 + // 计算角度
  662 + const totalPct = stats.reduce((sum, s) => sum + (s.percent || 0), 0)
  663 + const sliceInfos = stats.map((si, i) => {
  664 + const pct = si.percent || 0
  665 + const deg = pct > 0 ? (pct / totalPct) * 360 : MIN_SLICE_DEG
  666 + return { index: i, status: si.status, percent: pct, deg }
  667 + })
  668 +
  669 + const hasNonZero = sliceInfos.some(si => si.percent > 0)
  670 + if (!hasNonZero && sliceInfos.length > 0) {
  671 + const eqDeg = 360 / sliceInfos.length
  672 + sliceInfos.forEach(si => si.deg = eqDeg)
  673 + } else if (hasNonZero) {
  674 + const usedByNonZero = sliceInfos.filter(si => si.percent > 0).reduce((s, si) => s + si.deg, 0)
  675 + const zeroCount = sliceInfos.filter(si => si.percent === 0).length
  676 + const remaining = Math.max(0, 360 - usedByNonZero - zeroCount * MIN_SLICE_DEG)
  677 + sliceInfos.forEach(si => {
  678 + if (si.percent > 0) {
  679 + si.deg = si.deg + (si.deg / usedByNonZero) * remaining
  680 + }
  681 + })
  682 + }
  683 +
  684 + let startAngle = -Math.PI / 2
  685 + sliceInfos.forEach(si => {
  686 + si.startAngle = startAngle
  687 + si.sliceAngle = (si.deg / 180) * Math.PI
  688 + si.endAngle = startAngle + si.sliceAngle
  689 + si.midAngle = startAngle + si.sliceAngle / 2
  690 + startAngle = si.endAngle
  691 + })
  692 +
  693 + // 碰撞信息
  694 + sliceInfos.forEach(si => {
  695 + pieAngleRanges.push({
  696 + index: si.index, status: si.status,
  697 + cx, cy, radius, innerR: 0,
  698 + startAngle: si.startAngle, endAngle: si.endAngle
  699 + })
  700 + })
  701 +
  702 + // 第一遍:画扇区
  703 + sliceInfos.forEach(si => {
  704 + if (si.index === pieHover.index) return
  705 + ctx.beginPath()
  706 + ctx.moveTo(cx, cy)
  707 + ctx.arc(cx, cy, radius, si.startAngle, si.endAngle)
  708 + ctx.closePath()
  709 + ctx.fillStyle = STATUS_COLORS[si.status] || '#ccc'
  710 + ctx.globalAlpha = 0.85
  711 + ctx.fill()
  712 + ctx.globalAlpha = 1
  713 + })
  714 +
  715 + // 第二遍:hover 扇区放大
  716 + if (pieHover.index >= 0) {
  717 + const si = sliceInfos.find(s => s.index === pieHover.index)
  718 + if (si && si.percent >= 0) {
  719 + const expandR = radius + 5
  720 + const offsetDist = 6
  721 + const ox = cx + Math.cos(si.midAngle) * offsetDist
  722 + const oy = cy + Math.sin(si.midAngle) * offsetDist
  723 + ctx.save()
  724 + ctx.shadowColor = 'rgba(0,0,0,0.25)'
  725 + ctx.shadowBlur = 12
  726 + ctx.shadowOffsetY = 3
  727 + ctx.beginPath()
  728 + ctx.moveTo(ox, oy)
  729 + ctx.arc(ox, oy, expandR, si.startAngle, si.endAngle)
  730 + ctx.closePath()
  731 + ctx.fillStyle = STATUS_COLORS_HOVER[si.status] || STATUS_COLORS[si.status] || '#ccc'
  732 + ctx.fill()
  733 + ctx.restore()
  734 + }
  735 + }
  736 +
  737 + // 第三遍:引导线 + 标签
  738 + sliceInfos.forEach(si => {
  739 + const isHover = (si.index === pieHover.index)
  740 + const { midAngle, status: s, percent: pct } = si
  741 +
  742 + const lsX = cx + Math.cos(midAngle) * radius
  743 + const lsY = cy + Math.sin(midAngle) * radius
  744 + const elbowLen = 14
  745 + const elbX = cx + Math.cos(midAngle) * (radius + elbowLen)
  746 + const elbY = cy + Math.sin(midAngle) * (radius + elbowLen)
  747 + const textDir = midAngle > Math.PI / 2 && midAngle <= Math.PI * 1.5 ? -1 : 1
  748 + const textLen = 34
  749 + const leX = elbX + textDir * textLen
  750 + const leY = elbY
  751 +
  752 + ctx.strokeStyle = isHover ? '#666' : '#aaa'
  753 + ctx.lineWidth = isHover ? 1.2 : 0.8
  754 + ctx.beginPath()
  755 + ctx.moveTo(lsX, lsY)
  756 + ctx.lineTo(elbX, elbY)
  757 + ctx.lineTo(leX, leY)
  758 + ctx.stroke()
  759 +
  760 + // 扇区内小标签(如 57.11% 运行:7:59时)
  761 + const innerLabelRadius = radius * 0.62
  762 + const ilx = cx + Math.cos(midAngle) * innerLabelRadius
  763 + const ily = cy + Math.sin(midAngle) * innerLabelRadius
  764 +
  765 + // 百分比大字
  766 + ctx.fillStyle = '#fff'
  767 + ctx.font = `bold ${Math.min(13, radius * 0.12)}px sans-serif`
  768 + ctx.textAlign = 'center'
  769 + ctx.textBaseline = 'middle'
  770 + ctx.fillText(pct.toFixed(pct % 1 === 0 ? 0 : 1) + '%', ilx, ily - 6)
  771 +
  772 + // 状态+时长小字(在百分比下方)
  773 + const statItem = stats.find(st => st.status === s)
  774 + const durText = statItem ? statusLabel(s) + ':' + statItem.durationFormatted : statusLabel(s)
  775 + ctx.font = `${Math.min(9, radius * 0.08)}px sans-serif`
  776 + ctx.fillStyle = 'rgba(255,255,255,0.88)'
  777 + ctx.fillText(durText, ilx, ily + 8)
  778 +
  779 + // 外部引导线末端标签(图例)
  780 + const labelText = statusLabel(s)
  781 + const tx = leX + textDir * 4
  782 + const ty = leY + (isHover ? -2 : 4)
  783 +
  784 + // 图例色块
  785 + ctx.fillStyle = STATUS_COLORS[s] || '#ccc'
  786 + const boxSize = 8
  787 + const boxTy = ty - boxSize / 2
  788 + if (textDir > 0) {
  789 + ctx.fillRect(tx, boxTy, boxSize, boxSize)
  790 + } else {
  791 + ctx.fillRect(tx - boxSize, boxTy, boxSize, boxSize)
  792 + }
  793 +
  794 + ctx.fillStyle = isHover ? '#333' : '#555'
  795 + ctx.font = `${isHover ? 'bold ' : ''}11px sans-serif`
  796 + ctx.textAlign = textDir > 0 ? 'left' : 'right'
  797 + ctx.textBaseline = 'middle'
  798 + const labelTx = textDir > 0 ? tx + boxSize + 4 : tx - boxSize - 4
  799 + ctx.fillText(labelText, labelTx, ty)
  800 + })
  801 +}
200 </script> 802 </script>
201 803
202 <style scoped> 804 <style scoped>
203 -.safety-dialog :deep(.el-dialog) { max-height: 92vh; display:flex;flex-direction:column; }  
204 -.safety-dialog :deep(.el-dialog__header){padding:10px 20px;border-bottom:1px solid #e8e8e8;margin:0;flex-shrink:0; }  
205 -.safety-dialog :deep(.el-dialog__body){overflow-y:auto;flex:1;padding:12px;}  
206 -.dialog-header{display:flex;align-items:center;justify-content:space-between;}  
207 -.title-text{font-size:15px;font-weight:bold;color:#333;}  
208 -.header-right{display:flex;align-items:center;}  
209 -  
210 -.safety-body .panel{background:#fff;border:1px solid #eee;border-radius:6px;padding:14px;margin-bottom:12px;}  
211 -.panel-title{font-size:13px;font-weight:bold;color:#333;margin-bottom:10px;}  
212 -.flex-1{flex:1;}  
213 -.two-col-row{display:flex;gap:12px;}  
214 -  
215 -.status-chart-area{display:flex;align-items:center;justify-content:space-between;gap:12px;}  
216 -.status-bar-chart{flex:1;}  
217 -.status-bar-chart svg{width:100%;height:auto;}  
218 -  
219 -.composite-row{display:flex;justify-content:space-between;align-items:center;gap:16px;}  
220 -.composite-left{display:flex;align-items:center;gap:16px;}  
221 -.pie-wrap svg{width:120px;height:120px;}  
222 -.composite-info{font-size:12px;color:#666;display:flex;gap:16px;}  
223 -.ci-row{display:flex;align-items:center;gap:4px;}  
224 -.ci-dot{display:inline-block;width:10px;height:10px;border-radius:2px;}  
225 -.ci-dot.b{background:#409eff;}.ci-dot.g{background:#67c23a;}  
226 -.composite-right{font-size:12px;color:#666;line-height:2;}  
227 -  
228 -.bar-legend{display:flex;gap:14px;font-size:11px;color:#666;margin-bottom:6px;}  
229 -.leg{display:flex;align-items:center;gap:3px;}  
230 -.dot{display:inline-block;width:10px;height:10px;border-radius:2px;}  
231 -.dot.o{background:#f56c6c;}.dot.g{background:#67c23a;}.dot.b{background:#409eff;}.dot.gy{background:#909399;}  
232 -.runtime-bar-chart{overflow-x:auto;}  
233 -  
234 -.right-stats{width:280px;display:flex;flex-direction:column;gap:12px;}  
235 -.stat-card{background:#fff;border:1px solid #eee;border-radius:6px;padding:12px;}  
236 -.sc-header{font-size:13px;font-weight:bold;color:#333;margin-bottom:8px;}  
237 -.sc-content{display:flex;align-items:flex-start;gap:10px;}  
238 -.score-circle svg{width:90px;height:90px;flex-shrink:0;}  
239 -.score-circle.small svg{width:76px;height:76px;}  
240 -.sc-detail{flex:1;font-size:11px;color:#666;}  
241 -.sd-row{margin-bottom:2px;}  
242 -.sd-val{color:#e6a23c;font-weight:bold;font-size:13px;}  
243 -.sd-big{margin-top:4px;color:#409eff;font-size:16px;}  
244 -.sc-bars,.ef-bars{flex:1;display:flex;flex-direction:column;gap:4px;}  
245 -.hb-item{font-size:10px;padding:3px 6px;border-radius:3px;text-align:center;}  
246 -.hb-orange{background:#fff3e0;color:#e65100;}  
247 -.hb-green{background:#e8f5e9;color:#2e7d32;}  
248 -.hb-red{background:#ffebee;color:#c62828;}  
249 -.hb-blue{background:#e3f2fd;color:#1565c0;}  
250 -.eb-item{display:flex;align-items:center;gap:6px;font-size:10px;}  
251 -.eb-bar-wrap{width:50px;height:8px;background:#f0f0f0;border-radius:2px;overflow:hidden;}  
252 -.eb-fill{height:100%;border-radius:2px;}  
253 -.fill-orange{background:#f56c6c;}.fill-green{background:#67c23a;}  
254 -.fill-red{background:#f56c6c;}.fill-blue{background:#409eff;}  
255 -.eb-label{color:#666;white-space:nowrap;}  
256 -.ef-bottom{text-align:center;font-size:11px;color:#999;margin-top:4px;}  
257 -  
258 -.prod-bars{display:flex;gap:24px;margin-bottom:8px;}  
259 -.pb-dot{width:12px;height:12px;border-radius:2px;margin-right:4px;}  
260 -.pb-dot.o{background:#f56c6c;}.pb-dot.g{background:#67c23a;}.pb-dot.r{background:#e74c3c;}.pb-dot.b{background:#409eff;}  
261 -.prod-item{display:flex;align-items:center;gap:4px;font-size:12px;color:#666;}  
262 -.pb-val{font-weight:bold;color:#333;margin-left:4px;}  
263 -.prod-line{text-align:center;font-size:11px;color:#999;}  
264 -.io-legend{display:flex;gap:14px;font-size:11px;color:#666;} 805 +.safety-dialog :deep(.el-dialog) {
  806 + max-height: 94vh;
  807 + display: flex;
  808 + flex-direction: column;
  809 +}
  810 +.safety-dialog :deep(.el-dialog__header) {
  811 + padding: 10px 18px;
  812 + border-bottom: 1px solid #e8e8e8;
  813 + margin: 0;
  814 + flex-shrink: 0;
  815 +}
  816 +.safety-dialog :deep(.el-dialog__body) {
  817 + overflow: hidden;
  818 + flex: 1;
  819 + padding: 12px 14px;
  820 +}
  821 +.dialog-header {
  822 + display: flex;
  823 + align-items: center;
  824 +}
  825 +.title-text {
  826 + font-size: 14px;
  827 + font-weight: bold;
  828 + color: #333;
  829 + margin-right: 6px;
  830 + white-space: nowrap;
  831 +}
  832 +
  833 +.safety-body {
  834 + height: 100%;
  835 + display: flex;
  836 + flex-direction: column;
  837 +}
  838 +
  839 +/* ===== 主布局:左组合图 + 右统计 ===== */
  840 +.main-row {
  841 + display: grid;
  842 + grid-template-columns: 1fr 300px;
  843 + gap: 14px;
  844 + flex: 1;
  845 + min-height: 0;
  846 +}
  847 +
  848 +/* ===== 左侧组合图面板 ===== */
  849 +.combo-panel {
  850 + background: #fff;
  851 + border: 1px solid #e8e8e8;
  852 + border-radius: 6px;
  853 + padding: 12px 16px;
  854 + display: flex;
  855 + flex-direction: column;
  856 + min-height: 0;
  857 +}
  858 +.panel-title-row {
  859 + display: flex;
  860 + align-items: center;
  861 + gap: 10px;
  862 + margin-bottom: 6px;
  863 + flex-shrink: 0;
  864 +}
  865 +.panel-title {
  866 + font-size: 14px;
  867 + font-weight: bold;
  868 + color: #333;
  869 +}
  870 +.duration-hint {
  871 + font-size: 12px;
  872 + color: #999;
  873 +}
  874 +.legend-right {
  875 + display: flex;
  876 + align-items: center;
  877 + gap: 10px;
  878 + margin-left: auto;
  879 + flex-wrap: wrap;
  880 +}
  881 +.leg {
  882 + display: inline-flex;
  883 + align-items: center;
  884 + gap: 3px;
  885 + font-size: 11px;
  886 + color: #666;
  887 +}
  888 +.line-leg { gap: 2px; }
  889 +
  890 +.combo-canvas-wrap {
  891 + position: relative;
  892 + flex: 1;
  893 + min-height: 0;
  894 +}
  895 +.combo-canvas-wrap canvas {
  896 + display: block;
  897 + width: 100%;
  898 + height: 100%;
  899 +}
  900 +
  901 +/* ===== 右侧统计面板 ===== */
  902 +.stat-panel {
  903 + background: #fff;
  904 + border: 1px solid #e8e8e8;
  905 + border-radius: 6px;
  906 + padding: 12px 14px;
  907 + display: flex;
  908 + flex-direction: column;
  909 + align-items: center;
  910 +}
  911 +.stat-duration {
  912 + font-size: 12px;
  913 + color: #999;
  914 + margin-bottom: 4px;
  915 +}
  916 +.pie-canvas-wrap {
  917 + width: 100%;
  918 + position: relative;
  919 + flex: 1;
  920 + min-height: 0;
  921 +}
  922 +.pie-canvas-wrap canvas {
  923 + display: block;
  924 + width: 100%;
  925 + height: 100%;
  926 +}
  927 +.stat-kwh-area {
  928 + position: relative;
  929 + top: -150px;
  930 + text-align: center;
  931 + flex-shrink: 0;
  932 +}
  933 +.sk-label {
  934 + font-size: 12px;
  935 + color: #888;
  936 + margin-bottom: 2px;
  937 +}
  938 +.sk-value {
  939 + font-size: 18px;
  940 + font-weight: bold;
  941 + color: #333;
  942 +}
  943 +
  944 +/* ===== 图例圆点 & 线点 ===== */
  945 +.dot {
  946 + display: inline-block;
  947 + width: 10px;
  948 + height: 10px;
  949 + border-radius: 2px;
  950 + flex-shrink: 0;
  951 +}
  952 +.dot.g { background: #67c23a; }
  953 +.dot.r { background: #e74c3c; }
  954 +.dot.y { background: #c5d94e; }
  955 +.dot.gy { background: #909399; }
  956 +.line-dot {
  957 + display: inline-block;
  958 + width: 12px;
  959 + height: 2px;
  960 + background: #409eff;
  961 + vertical-align: middle;
  962 + border-radius: 1px;
  963 +}
  964 +.line-dot-sm {
  965 + display: inline-block;
  966 + width: 8px;
  967 + height: 2px;
  968 + background: #409eff;
  969 + vertical-align: middle;
  970 + border-radius: 1px;
  971 + margin-right: 3px;
  972 +}
  973 +
  974 +/* ===== Tooltip: 组合图 ===== */
  975 +.combo-tooltip {
  976 + position: absolute;
  977 + background: rgba(30, 40, 55, 0.95);
  978 + border-radius: 6px;
  979 + padding: 8px 14px;
  980 + min-width: 150px;
  981 + z-index: 200;
  982 + pointer-events: none;
  983 + box-shadow: 0 6px 20px rgba(0,0,0,0.3);
  984 +}
  985 +.combo-tooltip::after {
  986 + content: '';
  987 + position: absolute;
  988 + bottom: -7px;
  989 + left: 20px;
  990 + border-left: 7px solid transparent;
  991 + border-right: 7px solid transparent;
  992 + border-top: 7px solid rgba(30, 40, 55, 0.95);
  993 +}
  994 +.ctt-row {
  995 + display: flex;
  996 + align-items: center;
  997 + justify-content: space-between;
  998 + gap: 12px;
  999 + line-height: 1.9;
  1000 + font-size: 12px;
  1001 +}
  1002 +.ctt-label {
  1003 + color: #aab2c0;
  1004 + flex-shrink: 0;
  1005 + display: flex;
  1006 + align-items: center;
  1007 + gap: 3px;
  1008 +}
  1009 +.ctt-val { color: #eef1f7; font-weight: 500; }
  1010 +.ctt-highlight { font-weight: bold; }
  1011 +
  1012 +/* ===== Tooltip: 饼图 ===== */
  1013 +.pie-tooltip {
  1014 + position: absolute;
  1015 + background: rgba(30, 40, 55, 0.95);
  1016 + border-radius: 6px;
  1017 + padding: 8px 14px;
  1018 + min-width: 120px;
  1019 + z-index: 200;
  1020 + pointer-events: none;
  1021 + box-shadow: 0 4px 16px rgba(0,0,0,0.25);
  1022 +}
  1023 +.pie-tooltip::after {
  1024 + content: '';
  1025 + position: absolute;
  1026 + bottom: -7px;
  1027 + left: 18px;
  1028 + border-left: 7px solid transparent;
  1029 + border-right: 7px solid transparent;
  1030 + border-top: 7px solid rgba(30, 40, 55, 0.95);
  1031 +}
  1032 +.ptt-row {
  1033 + display: flex;
  1034 + align-items: center;
  1035 + justify-content: space-between;
  1036 + gap: 12px;
  1037 + line-height: 1.9;
  1038 + font-size: 12px;
  1039 +}
  1040 +.ptt-label { color: #aab2c0; flex-shrink: 0; }
  1041 +.ptt-val { color: #eef1f7; font-weight: 500; display: flex; align-items: center; gap: 4px; }
  1042 +.ptt-highlight { font-weight: bold; }
265 </style> 1043 </style>