store.vue 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116
  1. <template>
  2. <div class="energy-storage-container">
  3. <el-row :gutter="20">
  4. <el-col :span="5" :xs="24">
  5. <div class="head-container">
  6. <el-input v-model="searchKeyword" placeholder="搜索区域/设施" clearable size="small"
  7. prefix-icon="el-icon-search" @input="filterTree"
  8. />
  9. </div>
  10. <div class="head-container tree-container">
  11. <el-tree ref="treeRef" :data="treeData" :props="treeProps" :expand-on-click-node="false"
  12. :filter-node-method="filterNode" node-key="id" :default-expanded-keys="defaultExpandedKeys"
  13. highlight-current @node-click="handleNodeClick"
  14. >
  15. <span class="custom-tree-node" slot-scope="{ node, data }">
  16. <span class="tree-label"><i :class="getTreeIcon(data)" class="tree-icon"></i>{{ node.label }}</span>
  17. <el-tag v-if="data.facsCategory === 'C'" size="mini" effect="plain" class="tree-tag storage-tag"
  18. >储能</el-tag>
  19. </span>
  20. </el-tree>
  21. </div>
  22. </el-col>
  23. <el-col :span="19" :xs="24">
  24. <div class="main-content">
  25. <div class="content-header">
  26. <div class="header-title">
  27. <h2 class="page-title"><i class="el-icon-data-analysis"></i>储能分析<span class="title-divider"
  28. >|</span><span class="current-selection">{{ selectedLabel }}</span></h2>
  29. </div>
  30. <div class="header-actions">
  31. <el-radio-group v-model="activeTab" size="small" @change="handleTabChange">
  32. <el-radio-button label="charge"><i class="el-icon-s-data"></i> 充放电分析</el-radio-button>
  33. </el-radio-group>
  34. </div>
  35. </div>
  36. <!-- 充放电分析Tab -->
  37. <div v-show="activeTab === 'charge'" class="tab-content">
  38. <div class="filter-bar">
  39. <div class="filter-left">
  40. <el-radio-group v-model="chargeAnalysis.timeDimension" size="small" @change="handleChargeTimeChange">
  41. <el-radio-button label="hour">按小时</el-radio-button>
  42. <el-radio-button label="day">按天</el-radio-button>
  43. </el-radio-group>
  44. </div>
  45. <div class="filter-right">
  46. <!-- 新增:设施筛选 -->
  47. <el-select
  48. v-model="chargeAnalysis.selectedFacs"
  49. placeholder="全部设施"
  50. size="small"
  51. clearable
  52. @change="loadChargeAnalysisData"
  53. class="facs-select"
  54. >
  55. <el-option label="全部设施" value=""/>
  56. <el-option
  57. v-for="facs in filteredFacsList"
  58. :key="facs.facsCode"
  59. :label="facs.facsName"
  60. :value="facs.facsCode"
  61. />
  62. </el-select>
  63. <el-date-picker v-if="chargeAnalysis.timeDimension === 'hour'" v-model="chargeAnalysis.selectedDate"
  64. type="date" placeholder="选择日期" value-format="yyyy-MM-dd" size="small"
  65. @change="loadChargeAnalysisData" class="date-picker"
  66. />
  67. <el-date-picker v-else v-model="chargeAnalysis.dateRange" type="daterange" range-separator="至"
  68. start-placeholder="开始日期" end-placeholder="结束日期" value-format="yyyy-MM-dd"
  69. size="small" @change="loadChargeAnalysisData" :picker-options="datePickerOptions"
  70. class="date-picker"
  71. />
  72. </div>
  73. </div>
  74. <div class="summary-cards" v-loading="chargeAnalysis.summaryLoading">
  75. <el-row :gutter="16">
  76. <el-col :span="8">
  77. <div class="stat-card charge-summary-card">
  78. <div class="card-icon"><i class="el-icon-upload2"></i></div>
  79. <div class="card-content"><span class="card-label">总充电量</span><span class="card-value"
  80. >{{ formatNumber(chargeAnalysis.summary.totalCharge) }}<small>kW·h</small></span></div>
  81. </div>
  82. </el-col>
  83. <el-col :span="8">
  84. <div class="stat-card discharge-summary-card">
  85. <div class="card-icon"><i class="el-icon-download"></i></div>
  86. <div class="card-content"><span class="card-label">总放电量</span><span class="card-value"
  87. >{{ formatNumber(chargeAnalysis.summary.totalDischarge) }}<small>kW·h</small></span></div>
  88. </div>
  89. </el-col>
  90. <el-col :span="8">
  91. <div class="stat-card net-card">
  92. <div class="card-icon"><i class="el-icon-sort"></i></div>
  93. <div class="card-content"><span class="card-label">净充电量</span><span class="card-value"
  94. :class="{ 'positive': chargeAnalysis.summary.netCharge >= 0, 'negative': chargeAnalysis.summary.netCharge < 0 }"
  95. >{{
  96. chargeAnalysis.summary.netCharge >= 0 ? '+' : ''
  97. }}{{ formatNumber(chargeAnalysis.summary.netCharge) }}<small>kW·h</small></span></div>
  98. </div>
  99. </el-col>
  100. </el-row>
  101. </div>
  102. <div class="chart-section">
  103. <div class="section-header"><h3 class="section-title"><i class="el-icon-data-line"></i>充放电趋势</h3>
  104. <div class="chart-controls">
  105. <el-radio-group v-model="chargeAnalysis.chartType" size="mini">
  106. <el-radio-button label="bar"><i class="el-icon-s-data"></i></el-radio-button>
  107. <el-radio-button label="line"><i class="el-icon-data-line"></i></el-radio-button>
  108. </el-radio-group>
  109. </div>
  110. </div>
  111. <div class="chart-wrapper" v-loading="chargeAnalysis.chartLoading">
  112. <div ref="chargeChartRef" class="chart-canvas"></div>
  113. </div>
  114. </div>
  115. <div class="table-section">
  116. <div class="section-header"><h3 class="section-title"><i class="el-icon-document"></i>充放电明细</h3>
  117. <el-button-group>
  118. <el-button :type="chargeAnalysis.viewMode === 'table' ? 'primary' : ''" size="mini"
  119. icon="el-icon-menu" @click="chargeAnalysis.viewMode = 'table'"
  120. >表格
  121. </el-button>
  122. <el-button :type="chargeAnalysis.viewMode === 'chart' ? 'primary' : ''" size="mini"
  123. icon="el-icon-s-data" @click="chargeAnalysis.viewMode = 'chart'"
  124. >图表
  125. </el-button>
  126. </el-button-group>
  127. </div>
  128. <el-table v-if="chargeAnalysis.viewMode === 'table'" v-loading="chargeAnalysis.tableLoading"
  129. :data="chargeAnalysis.tableData" class="data-table" max-height="400" border show-summary
  130. :summary-method="getChargeSummaries"
  131. >
  132. <el-table-column type="index" label="序号" width="60" align="center"/>
  133. <el-table-column v-if="chargeAnalysis.timeDimension === 'hour'" label="时间" prop="time" width="100"
  134. align="center"
  135. >
  136. <template slot-scope="scope">{{ formatHourTime(scope.row.time) }}</template>
  137. </el-table-column>
  138. <el-table-column v-else label="日期" prop="date" width="120" align="center"/>
  139. <el-table-column label="区域" min-width="120" show-overflow-tooltip>
  140. <template slot-scope="scope">
  141. {{ scope.row.areaShortName || scope.row.areaName || getAreaName(scope.row.areaCode) }}
  142. </template>
  143. </el-table-column>
  144. <el-table-column label="设施" min-width="120" show-overflow-tooltip>
  145. <template slot-scope="scope">{{ scope.row.facsName || getFacsName(scope.row.facsCode) }}</template>
  146. </el-table-column>
  147. <el-table-column label="充电电量(kW·h)" prop="chargeElecQuantity" width="140" align="right">
  148. <template slot-scope="scope"><span class="charge-value">{{
  149. formatNumber(scope.row.chargeElecQuantity)
  150. }}</span></template>
  151. </el-table-column>
  152. <el-table-column label="放电电量(kW·h)" prop="dischargeElecQuantity" width="140" align="right">
  153. <template slot-scope="scope"><span class="discharge-value"
  154. >{{ formatNumber(scope.row.dischargeElecQuantity) }}</span></template>
  155. </el-table-column>
  156. </el-table>
  157. <div v-else class="detail-chart-wrapper" v-loading="chargeAnalysis.tableLoading">
  158. <div ref="chargeDetailChartRef" class="detail-chart-canvas"></div>
  159. </div>
  160. </div>
  161. </div>
  162. </div>
  163. </el-col>
  164. </el-row>
  165. </div>
  166. </template>
  167. <script>
  168. import { listElecStoreHour, listElecStoreDay, getDayStatistics } from '@/api/mgr/elecStoreHour'
  169. import { areaTreeByFacsCategory } from '@/api/basecfg/area'
  170. import { listAllFacs } from '@/api/basecfg/emsfacs'
  171. import * as echarts from 'echarts'
  172. export default {
  173. name: 'EnergyStorageAnalysis',
  174. data() {
  175. const today = new Date()
  176. const lastWeek = new Date()
  177. lastWeek.setDate(lastWeek.getDate() - 7)
  178. return {
  179. searchKeyword: '',
  180. treeData: [],
  181. treeProps: { children: 'children', label: 'label' },
  182. defaultExpandedKeys: ['-1'],
  183. selectedLabel: '全部',
  184. selectedNode: null,
  185. queryParams: { areaCode: null, facsCode: null },
  186. activeTab: 'charge',
  187. areaMap: {},
  188. facsMap: {},
  189. facsList: [], // 新增:设施列表
  190. chargeAnalysis: {
  191. timeDimension: 'day',
  192. selectedDate: this.formatDate(today),
  193. dateRange: [this.formatDate(lastWeek), this.formatDate(today)],
  194. chartType: 'bar',
  195. viewMode: 'table',
  196. summaryLoading: false,
  197. chartLoading: false,
  198. tableLoading: false,
  199. summary: { totalCharge: 0, totalDischarge: 0, netCharge: 0 },
  200. tableData: [],
  201. chartData: [],
  202. selectedFacs: '' // 新增:选中的设施
  203. },
  204. chargeChartInstance: null,
  205. chargeDetailChartInstance: null,
  206. datePickerOptions: {
  207. shortcuts: [{
  208. text: '最近一周', onClick(picker) {
  209. const end = new Date()
  210. const start = new Date()
  211. start.setTime(start.getTime() - 3600 * 1000 * 24 * 7)
  212. picker.$emit('pick', [start, end])
  213. }
  214. }, {
  215. text: '最近一个月', onClick(picker) {
  216. const end = new Date()
  217. const start = new Date()
  218. start.setTime(start.getTime() - 3600 * 1000 * 24 * 30)
  219. picker.$emit('pick', [start, end])
  220. }
  221. }]
  222. },
  223. datetimePickerOptions: {
  224. shortcuts: [{
  225. text: '最近24小时', onClick(picker) {
  226. const end = new Date()
  227. const start = new Date()
  228. start.setTime(start.getTime() - 24 * 60 * 60 * 1000)
  229. picker.$emit('pick', [start, end])
  230. }
  231. }, {
  232. text: '最近3天', onClick(picker) {
  233. const end = new Date()
  234. const start = new Date()
  235. start.setTime(start.getTime() - 3 * 24 * 60 * 60 * 1000)
  236. picker.$emit('pick', [start, end])
  237. }
  238. }]
  239. }
  240. }
  241. },
  242. computed: {
  243. // 新增:根据选中的区域过滤设施列表
  244. filteredFacsList() {
  245. if (!this.queryParams.areaCode) {
  246. // 未选择区域时,显示所有设施
  247. return this.facsList
  248. }
  249. // 选择了区域时,只显示该区域下的设施
  250. return this.facsList.filter(facs => facs.refArea === this.queryParams.areaCode)
  251. }
  252. },
  253. watch: {
  254. 'chargeAnalysis.chartType'() {
  255. this.renderChargeChart()
  256. },
  257. 'chargeAnalysis.viewMode'(val) {
  258. if (val === 'chart') {
  259. this.$nextTick(() => {
  260. this.renderChargeDetailChart()
  261. })
  262. }
  263. }
  264. },
  265. created() {
  266. this.initBaseData()
  267. },
  268. mounted() {
  269. window.addEventListener('resize', this.handleResize)
  270. },
  271. beforeDestroy() {
  272. window.removeEventListener('resize', this.handleResize)
  273. this.disposeCharts()
  274. },
  275. methods: {
  276. async initBaseData() {
  277. try {
  278. await Promise.all([this.loadTreeData(), this.loadFacsList()])
  279. this.loadChargeAnalysisData()
  280. } catch (error) {
  281. console.error('初始化数据失败', error)
  282. }
  283. },
  284. async loadFacsList() {
  285. try {
  286. const { data } = await listAllFacs({ facsCategory: 'C' })
  287. this.facsList = data || [] // 保存设施列表用于下拉筛选
  288. this.facsMap = {}
  289. this.facsList.forEach(facs => {
  290. this.facsMap[facs.facsCode] = facs.facsName
  291. })
  292. } catch (error) {
  293. console.error('加载设施列表失败', error)
  294. }
  295. },
  296. formatNumber(value) {
  297. if (value === null || value === undefined) return '0.00'
  298. const num = parseFloat(value)
  299. if (isNaN(num)) return '0.00'
  300. return num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
  301. },
  302. formatDate(date) {
  303. if (!date) return ''
  304. const d = new Date(date)
  305. return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
  306. },
  307. formatTime(time) {
  308. if (!time) return '-'
  309. if (typeof time === 'string') {
  310. return time.includes(' ') ? time.split(' ')[1] : time
  311. }
  312. const d = new Date(time)
  313. return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
  314. },
  315. formatHourTime(time) {
  316. if (!time) return '-'
  317. return typeof time === 'string' ? time.substring(0, 5) : time
  318. },
  319. getAreaName(areaCode) {
  320. return !areaCode ? '-' : (this.areaMap[areaCode] || areaCode)
  321. },
  322. getFacsName(facsCode) {
  323. return !facsCode ? '-' : (this.facsMap[facsCode] || facsCode)
  324. },
  325. async loadTreeData() {
  326. try {
  327. const response = await areaTreeByFacsCategory('C', '', false)
  328. this.treeData = [{ id: '-1', label: '全部', children: response.data || [] }]
  329. this.defaultExpandedKeys = ['-1']
  330. this.buildAreaMap(response.data || [])
  331. this.$nextTick(() => {
  332. if (this.$refs.treeRef) {
  333. this.$refs.treeRef.setCurrentKey('-1')
  334. }
  335. })
  336. } catch (error) {
  337. console.error('加载树形数据失败', error)
  338. }
  339. },
  340. buildAreaMap(nodes) {
  341. nodes.forEach(node => {
  342. if (node.id && node.label) {
  343. this.areaMap[node.id] = node.label
  344. }
  345. if (node.children && node.children.length > 0) {
  346. this.buildAreaMap(node.children)
  347. }
  348. })
  349. },
  350. getTreeIcon(data) {
  351. if (data.facsCategory === 'C') return 'el-icon-s-grid'
  352. if (data.id === '-1') return 'el-icon-s-home'
  353. return 'el-icon-office-building'
  354. },
  355. filterTree() {
  356. if (this.$refs.treeRef) {
  357. this.$refs.treeRef.filter(this.searchKeyword)
  358. }
  359. },
  360. filterNode(value, data) {
  361. if (!value) return true
  362. return data.label && data.label.indexOf(value) !== -1
  363. },
  364. handleNodeClick(data) {
  365. this.selectedNode = data
  366. // 重置设施筛选
  367. this.chargeAnalysis.selectedFacs = ''
  368. if (data.id === '-1') {
  369. this.queryParams.areaCode = null
  370. this.queryParams.facsCode = null
  371. this.selectedLabel = '全部'
  372. } else if (data.facsCategory === 'C') {
  373. this.queryParams.facsCode = data.id
  374. this.queryParams.areaCode = null
  375. this.selectedLabel = data.label
  376. // 如果点击的是设施节点,自动选中该设施
  377. this.chargeAnalysis.selectedFacs = data.id
  378. } else {
  379. this.queryParams.areaCode = data.id
  380. this.queryParams.facsCode = null
  381. this.selectedLabel = data.label
  382. }
  383. this.refreshCurrentTabData()
  384. },
  385. handleTabChange() {
  386. this.refreshCurrentTabData()
  387. },
  388. refreshCurrentTabData() {
  389. if (this.activeTab === 'charge') {
  390. this.loadChargeAnalysisData()
  391. }
  392. },
  393. handleChargeTimeChange() {
  394. this.loadChargeAnalysisData()
  395. },
  396. async loadChargeAnalysisData() {
  397. this.chargeAnalysis.summaryLoading = true
  398. this.chargeAnalysis.tableLoading = true
  399. this.chargeAnalysis.chartLoading = true
  400. try {
  401. if (this.chargeAnalysis.timeDimension === 'hour') {
  402. await this.loadHourlyChargeData()
  403. } else {
  404. await this.loadDailyChargeData()
  405. }
  406. this.$nextTick(() => {
  407. this.renderChargeChart()
  408. if (this.chargeAnalysis.viewMode === 'chart') {
  409. this.renderChargeDetailChart()
  410. }
  411. })
  412. } catch (error) {
  413. console.error('加载充放电分析数据失败', error)
  414. this.$message.error('加载数据失败')
  415. } finally {
  416. this.chargeAnalysis.summaryLoading = false
  417. this.chargeAnalysis.tableLoading = false
  418. this.chargeAnalysis.chartLoading = false
  419. }
  420. },
  421. async loadHourlyChargeData() {
  422. const date = this.chargeAnalysis.selectedDate
  423. if (!date) return
  424. const { data: statisticsData } = await getDayStatistics(date)
  425. if (statisticsData && statisticsData.daySum) {
  426. this.chargeAnalysis.summary = {
  427. totalCharge: parseFloat(statisticsData.daySum.chargeElecQuantity) || 0,
  428. totalDischarge: parseFloat(statisticsData.daySum.dischargeElecQuantity) || 0,
  429. netCharge: (parseFloat(statisticsData.daySum.chargeElecQuantity) || 0) - (parseFloat(statisticsData.daySum.dischargeElecQuantity) || 0)
  430. }
  431. }
  432. const params = {
  433. date: date,
  434. areaCode: this.queryParams.areaCode || undefined,
  435. facsCode: this.queryParams.facsCode || undefined
  436. }
  437. // 新增:如果选择了设施筛选,优先使用筛选的设施
  438. if (this.chargeAnalysis.selectedFacs) {
  439. params.facsCode = this.chargeAnalysis.selectedFacs
  440. }
  441. const { rows } = await listElecStoreHour(params)
  442. this.chargeAnalysis.tableData = (rows || []).map(item => ({
  443. ...item,
  444. areaName: item.areaName || this.getAreaName(item.areaCode),
  445. facsName: item.facsName || this.getFacsName(item.facsCode)
  446. }))
  447. if (statisticsData && statisticsData.hourSum) {
  448. this.chargeAnalysis.chartData = statisticsData.hourSum.map(item => ({
  449. time: item.time,
  450. chargeElecQuantity: parseFloat(item.chargeElecQuantity) || 0,
  451. dischargeElecQuantity: parseFloat(item.dischargeElecQuantity) || 0
  452. }))
  453. }
  454. },
  455. async loadDailyChargeData() {
  456. const [startDate, endDate] = this.chargeAnalysis.dateRange || []
  457. if (!startDate || !endDate) return
  458. const params = {
  459. startRecTime: startDate,
  460. endRecTime: endDate,
  461. areaCode: this.queryParams.areaCode || '-1',
  462. facsSubCategory: ''
  463. }
  464. // 新增:如果选择了设施,添加facsCode参数
  465. if (this.chargeAnalysis.selectedFacs) {
  466. params.facsCode = this.chargeAnalysis.selectedFacs
  467. }
  468. const { data } = await listElecStoreDay(params)
  469. const list = data || []
  470. this.chargeAnalysis.tableData = list.map(item => ({
  471. ...item,
  472. areaName: item.areaName || this.getAreaName(item.areaCode),
  473. facsName: item.facsName || this.getFacsName(item.facsCode)
  474. }))
  475. this.chargeAnalysis.chartData = list
  476. let totalCharge = 0
  477. let totalDischarge = 0
  478. list.forEach(item => {
  479. totalCharge += parseFloat(item.chargeElecQuantity) || 0
  480. totalDischarge += parseFloat(item.dischargeElecQuantity) || 0
  481. })
  482. this.chargeAnalysis.summary = { totalCharge, totalDischarge, netCharge: totalCharge - totalDischarge }
  483. },
  484. renderChargeChart() {
  485. if (!this.$refs.chargeChartRef || this.chargeAnalysis.chartData.length === 0) return
  486. if (this.chargeChartInstance) {
  487. this.chargeChartInstance.dispose()
  488. }
  489. this.chargeChartInstance = echarts.init(this.$refs.chargeChartRef)
  490. const isHourly = this.chargeAnalysis.timeDimension === 'hour'
  491. const xAxisData = this.chargeAnalysis.chartData.map(item => isHourly ? this.formatHourTime(item.time) : item.date)
  492. const chargeData = this.chargeAnalysis.chartData.map(item => parseFloat(item.chargeElecQuantity) || 0)
  493. const dischargeData = this.chargeAnalysis.chartData.map(item => parseFloat(item.dischargeElecQuantity) || 0)
  494. const option = {
  495. tooltip: {
  496. trigger: 'axis',
  497. backgroundColor: 'rgba(50, 50, 50, 0.9)',
  498. borderColor: 'transparent',
  499. textStyle: { color: '#fff' }
  500. },
  501. legend: { data: ['充电电量', '放电电量'], top: 10 },
  502. grid: { left: '3%', right: '4%', bottom: '15%', top: '15%', containLabel: true },
  503. xAxis: {
  504. type: 'category',
  505. data: xAxisData,
  506. axisLine: { lineStyle: { color: '#e0e0e0' } },
  507. axisTick: { show: false },
  508. axisLabel: { color: '#666', fontSize: 11, rotate: xAxisData.length > 15 ? 45 : 0 }
  509. },
  510. yAxis: {
  511. type: 'value',
  512. name: 'kW·h',
  513. nameTextStyle: { color: '#999', fontSize: 11 },
  514. axisLine: { show: false },
  515. axisTick: { show: false },
  516. splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } }
  517. },
  518. dataZoom: xAxisData.length > 20 ? [{ type: 'slider', start: 0, end: 80, height: 20, bottom: '2%' }] : [],
  519. series: [{
  520. name: '充电电量',
  521. type: this.chargeAnalysis.chartType,
  522. data: chargeData,
  523. smooth: true,
  524. itemStyle: { color: '#67c23a', borderRadius: this.chargeAnalysis.chartType === 'bar' ? [4, 4, 0, 0] : 0 },
  525. lineStyle: this.chargeAnalysis.chartType === 'line' ? { width: 3, color: '#67c23a' } : undefined,
  526. barWidth: '35%',
  527. barMaxWidth: 40
  528. }, {
  529. name: '放电电量',
  530. type: this.chargeAnalysis.chartType,
  531. data: dischargeData,
  532. smooth: true,
  533. itemStyle: { color: '#e6a23c', borderRadius: this.chargeAnalysis.chartType === 'bar' ? [4, 4, 0, 0] : 0 },
  534. lineStyle: this.chargeAnalysis.chartType === 'line' ? { width: 3, color: '#e6a23c' } : undefined,
  535. barWidth: '35%',
  536. barMaxWidth: 40
  537. }]
  538. }
  539. this.chargeChartInstance.setOption(option)
  540. },
  541. renderChargeDetailChart() {
  542. if (!this.$refs.chargeDetailChartRef || this.chargeAnalysis.tableData.length === 0) return
  543. if (this.chargeDetailChartInstance) {
  544. this.chargeDetailChartInstance.dispose()
  545. }
  546. this.chargeDetailChartInstance = echarts.init(this.$refs.chargeDetailChartRef)
  547. const aggregatedMap = new Map()
  548. this.chargeAnalysis.tableData.forEach(item => {
  549. const key = item.facsName || item.areaName || '未知'
  550. if (aggregatedMap.has(key)) {
  551. const existing = aggregatedMap.get(key)
  552. existing.charge += parseFloat(item.chargeElecQuantity) || 0
  553. existing.discharge += parseFloat(item.dischargeElecQuantity) || 0
  554. } else {
  555. aggregatedMap.set(key, {
  556. name: key,
  557. charge: parseFloat(item.chargeElecQuantity) || 0,
  558. discharge: parseFloat(item.dischargeElecQuantity) || 0
  559. })
  560. }
  561. })
  562. const aggregatedData = Array.from(aggregatedMap.values())
  563. const option = {
  564. tooltip: {
  565. trigger: 'axis',
  566. backgroundColor: 'rgba(50, 50, 50, 0.9)',
  567. borderColor: 'transparent',
  568. textStyle: { color: '#fff' }
  569. },
  570. legend: { data: ['充电电量', '放电电量'], top: 10 },
  571. grid: { left: '3%', right: '4%', bottom: '10%', top: '15%', containLabel: true },
  572. xAxis: {
  573. type: 'category',
  574. data: aggregatedData.map(item => item.name),
  575. axisLine: { lineStyle: { color: '#e0e0e0' } },
  576. axisTick: { show: false },
  577. axisLabel: { color: '#666', fontSize: 11 }
  578. },
  579. yAxis: {
  580. type: 'value',
  581. name: 'kW·h',
  582. nameTextStyle: { color: '#999', fontSize: 11 },
  583. axisLine: { show: false },
  584. axisTick: { show: false },
  585. splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } }
  586. },
  587. series: [{
  588. name: '充电电量',
  589. type: 'bar',
  590. data: aggregatedData.map(item => item.charge),
  591. itemStyle: { color: '#67c23a', borderRadius: [4, 4, 0, 0] },
  592. barWidth: '30%'
  593. }, {
  594. name: '放电电量',
  595. type: 'bar',
  596. data: aggregatedData.map(item => item.discharge),
  597. itemStyle: { color: '#e6a23c', borderRadius: [4, 4, 0, 0] },
  598. barWidth: '30%'
  599. }]
  600. }
  601. this.chargeDetailChartInstance.setOption(option)
  602. },
  603. getChargeSummaries({ columns, data }) {
  604. const sums = []
  605. columns.forEach((column, index) => {
  606. if (index === 0) {
  607. sums[index] = '合计'
  608. return
  609. }
  610. if (column.property === 'chargeElecQuantity') {
  611. sums[index] = this.formatNumber(data.reduce((sum, item) => sum + (parseFloat(item.chargeElecQuantity) || 0), 0))
  612. } else if (column.property === 'dischargeElecQuantity') {
  613. sums[index] = this.formatNumber(data.reduce((sum, item) => sum + (parseFloat(item.dischargeElecQuantity) || 0), 0))
  614. } else {
  615. sums[index] = ''
  616. }
  617. })
  618. return sums
  619. },
  620. handleResize() {
  621. if (this.chargeChartInstance) this.chargeChartInstance.resize()
  622. if (this.chargeDetailChartInstance) this.chargeDetailChartInstance.resize()
  623. },
  624. disposeCharts() {
  625. [this.chargeChartInstance, this.chargeDetailChartInstance].forEach(chart => {
  626. if (chart) {
  627. chart.dispose()
  628. }
  629. })
  630. this.chargeChartInstance = null
  631. this.chargeDetailChartInstance = null
  632. }
  633. }
  634. }
  635. </script>
  636. <style lang="scss" scoped>
  637. .energy-storage-container {
  638. padding: 20px;
  639. background: #f5f7fa;
  640. min-height: calc(100vh - 84px);
  641. .head-container {
  642. background: #fff;
  643. padding: 15px;
  644. border-radius: 8px;
  645. margin-bottom: 15px;
  646. box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  647. &.tree-container {
  648. max-height: calc(100vh - 200px);
  649. overflow-y: auto;
  650. &::-webkit-scrollbar {
  651. width: 6px;
  652. }
  653. &::-webkit-scrollbar-track {
  654. background: #f1f1f1;
  655. border-radius: 3px;
  656. }
  657. &::-webkit-scrollbar-thumb {
  658. background: #c1c1c1;
  659. border-radius: 3px;
  660. }
  661. ::v-deep .el-tree {
  662. background: transparent;
  663. .el-tree-node__content {
  664. height: 40px;
  665. padding: 0 8px;
  666. &:hover {
  667. background-color: #f5f7fa;
  668. }
  669. }
  670. .el-tree-node.is-current > .el-tree-node__content {
  671. background-color: #ecf5ff;
  672. color: #409eff;
  673. .tree-icon {
  674. color: #409eff !important;
  675. }
  676. }
  677. .custom-tree-node {
  678. flex: 1;
  679. display: flex;
  680. align-items: center;
  681. justify-content: space-between;
  682. font-size: 14px;
  683. .tree-label {
  684. display: flex;
  685. align-items: center;
  686. .tree-icon {
  687. margin-right: 8px;
  688. font-size: 16px;
  689. color: #909399;
  690. }
  691. }
  692. .tree-tag.storage-tag {
  693. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  694. color: #fff;
  695. border: none;
  696. }
  697. }
  698. }
  699. }
  700. }
  701. .main-content {
  702. background: #fff;
  703. border-radius: 12px;
  704. padding: 24px;
  705. box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
  706. min-height: calc(100vh - 124px);
  707. .content-header {
  708. display: flex;
  709. justify-content: space-between;
  710. align-items: center;
  711. margin-bottom: 24px;
  712. padding-bottom: 20px;
  713. border-bottom: 2px solid #f0f2f5;
  714. .page-title {
  715. font-size: 22px;
  716. font-weight: 700;
  717. color: #303133;
  718. margin: 0;
  719. display: flex;
  720. align-items: center;
  721. i {
  722. margin-right: 12px;
  723. color: #409eff;
  724. font-size: 26px;
  725. }
  726. .title-divider {
  727. margin: 0 16px;
  728. color: #dcdfe6;
  729. font-weight: 300;
  730. }
  731. .current-selection {
  732. color: #409eff;
  733. font-size: 18px;
  734. font-weight: 500;
  735. }
  736. }
  737. .header-actions {
  738. ::v-deep .el-radio-button__inner {
  739. padding: 10px 20px;
  740. font-weight: 500;
  741. i {
  742. margin-right: 6px;
  743. }
  744. }
  745. ::v-deep .el-radio-button__orig-radio:checked + .el-radio-button__inner {
  746. background: #409eff;
  747. border-color: #409eff;
  748. }
  749. }
  750. }
  751. .tab-content {
  752. animation: fadeIn 0.3s ease;
  753. }
  754. .section-header {
  755. display: flex;
  756. justify-content: space-between;
  757. align-items: center;
  758. margin-bottom: 16px;
  759. .section-title {
  760. font-size: 16px;
  761. font-weight: 600;
  762. color: #303133;
  763. margin: 0;
  764. display: flex;
  765. align-items: center;
  766. i {
  767. margin-right: 10px;
  768. color: #409eff;
  769. font-size: 20px;
  770. }
  771. }
  772. }
  773. .stat-card {
  774. background: #fff;
  775. border-radius: 12px;
  776. padding: 20px;
  777. display: flex;
  778. align-items: center;
  779. border: 1px solid #ebeef5;
  780. transition: all 0.3s ease;
  781. position: relative;
  782. overflow: hidden;
  783. &::before {
  784. content: '';
  785. position: absolute;
  786. top: 0;
  787. left: 0;
  788. width: 4px;
  789. height: 100%;
  790. }
  791. &:hover {
  792. transform: translateY(-4px);
  793. box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
  794. }
  795. .card-icon {
  796. width: 56px;
  797. height: 56px;
  798. border-radius: 12px;
  799. display: flex;
  800. align-items: center;
  801. justify-content: center;
  802. margin-right: 16px;
  803. flex-shrink: 0;
  804. i {
  805. font-size: 26px;
  806. color: #fff;
  807. }
  808. }
  809. .card-content {
  810. flex: 1;
  811. display: flex;
  812. flex-direction: column;
  813. .card-label {
  814. font-size: 13px;
  815. color: #909399;
  816. margin-bottom: 6px;
  817. font-weight: 500;
  818. }
  819. .card-value {
  820. font-size: 24px;
  821. font-weight: 700;
  822. color: #303133;
  823. line-height: 1.2;
  824. &.positive {
  825. color: #67c23a;
  826. }
  827. &.negative {
  828. color: #f56c6c;
  829. }
  830. small {
  831. font-size: 13px;
  832. font-weight: 400;
  833. color: #909399;
  834. margin-left: 4px;
  835. }
  836. }
  837. }
  838. &.charge-summary-card {
  839. &::before {
  840. background: #67c23a;
  841. }
  842. .card-icon {
  843. background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
  844. }
  845. }
  846. &.discharge-summary-card {
  847. &::before {
  848. background: #e6a23c;
  849. }
  850. .card-icon {
  851. background: linear-gradient(135deg, #e6a23c 0%, #f5c069 100%);
  852. }
  853. }
  854. &.net-card {
  855. &::before {
  856. background: #667eea;
  857. }
  858. .card-icon {
  859. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  860. }
  861. }
  862. &.avg-card {
  863. &::before {
  864. background: #409eff;
  865. }
  866. .card-icon {
  867. background: linear-gradient(135deg, #409eff 0%, #53a8ff 100%);
  868. }
  869. }
  870. &.max-card {
  871. &::before {
  872. background: #f56c6c;
  873. }
  874. .card-icon {
  875. background: linear-gradient(135deg, #f56c6c 0%, #f89898 100%);
  876. }
  877. }
  878. &.min-card {
  879. &::before {
  880. background: #00d4ff;
  881. }
  882. .card-icon {
  883. background: linear-gradient(135deg, #00d4ff 0%, #00f2fe 100%);
  884. }
  885. }
  886. &.count-card {
  887. &::before {
  888. background: #f093fb;
  889. }
  890. .card-icon {
  891. background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
  892. }
  893. }
  894. }
  895. .summary-cards {
  896. margin-bottom: 24px;
  897. }
  898. .filter-bar {
  899. display: flex;
  900. justify-content: space-between;
  901. align-items: center;
  902. margin-bottom: 20px;
  903. padding: 16px 20px;
  904. background: #f5f7fa;
  905. border-radius: 10px;
  906. .filter-left {
  907. display: flex;
  908. align-items: center;
  909. .filter-label {
  910. font-size: 14px;
  911. color: #606266;
  912. margin-right: 12px;
  913. }
  914. }
  915. .filter-right {
  916. display: flex;
  917. align-items: center;
  918. gap: 12px;
  919. .facs-select {
  920. width: 160px;
  921. }
  922. .date-picker {
  923. width: 240px;
  924. }
  925. .datetime-picker {
  926. width: 360px;
  927. }
  928. .device-select {
  929. width: 180px;
  930. }
  931. }
  932. }
  933. .chart-section {
  934. margin-bottom: 24px;
  935. .chart-wrapper {
  936. background: #fff;
  937. border-radius: 8px;
  938. border: 1px solid #ebeef5;
  939. padding: 16px;
  940. .chart-canvas {
  941. height: 320px;
  942. width: 100%;
  943. &.capacity-chart {
  944. height: 350px;
  945. }
  946. }
  947. }
  948. .chart-controls {
  949. ::v-deep .el-radio-button__inner {
  950. padding: 6px 12px;
  951. }
  952. }
  953. }
  954. .table-section {
  955. .data-table {
  956. border-radius: 8px;
  957. ::v-deep .el-table__header th {
  958. background: #f5f7fa;
  959. color: #606266;
  960. font-weight: 600;
  961. }
  962. .charge-value {
  963. color: #67c23a;
  964. font-weight: 600;
  965. }
  966. .discharge-value {
  967. color: #e6a23c;
  968. font-weight: 600;
  969. }
  970. }
  971. .detail-chart-wrapper {
  972. background: #fff;
  973. border-radius: 8px;
  974. padding: 16px;
  975. border: 1px solid #ebeef5;
  976. .detail-chart-canvas {
  977. height: 350px;
  978. width: 100%;
  979. }
  980. }
  981. }
  982. }
  983. }
  984. @keyframes fadeIn {
  985. from {
  986. opacity: 0;
  987. transform: translateY(10px);
  988. }
  989. to {
  990. opacity: 1;
  991. transform: translateY(0);
  992. }
  993. }
  994. @media (max-width: 768px) {
  995. .energy-storage-container {
  996. padding: 10px;
  997. .main-content {
  998. padding: 16px;
  999. .content-header {
  1000. flex-direction: column;
  1001. align-items: flex-start;
  1002. gap: 16px;
  1003. }
  1004. .filter-bar {
  1005. flex-direction: column;
  1006. gap: 12px;
  1007. .filter-right {
  1008. flex-wrap: wrap;
  1009. width: 100%;
  1010. .facs-select,
  1011. .date-picker,
  1012. .datetime-picker,
  1013. .device-select {
  1014. width: 100%;
  1015. }
  1016. }
  1017. }
  1018. }
  1019. }
  1020. }
  1021. </style>