Просмотр исходного кода

解决图表的企微部门转译问题

QuYueTing 11 часов назад
Родитель
Сommit
223491195c
1 измененных файлов с 807 добавлено и 0 удалено
  1. 807 0
      fhKeeper/formulahousekeeper/timesheet/src/views/dashboard/index.vue

+ 807 - 0
fhKeeper/formulahousekeeper/timesheet/src/views/dashboard/index.vue

@@ -0,0 +1,807 @@
+<template>
+  <div class="dashboard-container" ref="dashboardContainer">
+    <!-- 顶部标题和日期选择 -->
+    <div class="dashboard-header">
+      <h2 class="dashboard-title">工时看板</h2>
+      <el-date-picker
+        v-model="selectedMonth"
+        type="month"
+        placeholder="选择年月"
+        value-format="yyyy-MM"
+        :clearable="false"
+        @change="onMonthChange"
+        size="small"
+        style="width: 160px;"
+      ></el-date-picker>
+    </div>
+
+    <!-- 四个图表区域 -->
+    <div class="charts-grid">
+      <!-- 图表1:项目工时排名前十 -->
+      <div class="chart-card">
+        <div class="chart-title">
+          <span>项目工时排名前十</span>
+          <el-tooltip content="保存为图片" placement="top">
+            <el-button
+              class="save-img-btn"
+              type="text"
+              icon="el-icon-download"
+              :disabled="loading1 || top10Data.length === 0"
+              @click="saveChartImage(chart1, '项目工时排名前十')"
+            ></el-button>
+          </el-tooltip>
+        </div>
+        <div v-if="loading1" class="chart-loading">
+          <i class="el-icon-loading"></i> 加载中...
+        </div>
+        <div v-else-if="top10Data.length === 0" class="chart-empty">暂无数据</div>
+        <div v-show="!loading1 && top10Data.length > 0" ref="chart1" class="chart-body"></div>
+      </div>
+
+      <!-- 图表2:工时前三项目部门分配 -->
+      <div class="chart-card">
+        <div class="chart-title">
+          <span>工时前三项目部门分配表</span>
+          <div class="chart-title-actions">
+            <el-radio-group
+              v-model="top3PieMode"
+              size="mini"
+              :disabled="loading2 || top3DeptData.length === 0"
+              @change="renderChart2"
+            >
+              <el-radio-button label="working">工时</el-radio-button>
+              <el-radio-button label="overtime">加班工时</el-radio-button>
+            </el-radio-group>
+            <el-tooltip content="保存为图片" placement="top">
+              <el-button
+                class="save-img-btn"
+                type="text"
+                icon="el-icon-download"
+                :disabled="loading2 || top3DeptData.length === 0"
+                @click="saveChartImage(chart2, '工时前三项目部门分配表')"
+              ></el-button>
+            </el-tooltip>
+          </div>
+        </div>
+        <div v-if="loading2" class="chart-loading">
+          <i class="el-icon-loading"></i> 加载中...
+        </div>
+        <div v-else-if="top3DeptData.length === 0" class="chart-empty">暂无数据</div>
+        <div v-show="!loading2 && top3DeptData.length > 0" ref="chart2" class="chart-body"></div>
+      </div>
+
+      <!-- 图表3:各部门总工时和人均工时 -->
+      <div class="chart-card">
+        <div class="chart-title">
+          <span>各部门总工时和人均工时表</span>
+          <el-tooltip content="保存为图片" placement="top">
+            <el-button
+              class="save-img-btn"
+              type="text"
+              icon="el-icon-download"
+              :disabled="loading3 || deptHoursData.length === 0"
+              @click="saveChartImage(chart3, '各部门总工时和人均工时表')"
+            ></el-button>
+          </el-tooltip>
+        </div>
+        <div v-if="loading3" class="chart-loading">
+          <i class="el-icon-loading"></i> 加载中...
+        </div>
+        <div v-else-if="deptHoursData.length === 0" class="chart-empty">暂无数据</div>
+        <div v-show="!loading3 && deptHoursData.length > 0" ref="chart3" class="chart-body"></div>
+      </div>
+
+      <!-- 图表4:各部门参与项目数量 -->
+      <div class="chart-card">
+        <div class="chart-title">
+          <span>各部门参与项目数量表</span>
+          <el-tooltip content="保存为图片" placement="top">
+            <el-button
+              class="save-img-btn"
+              type="text"
+              icon="el-icon-download"
+              :disabled="loading4 || deptProjectCountData.length === 0"
+              @click="saveChartImage(chart4, '各部门参与项目数量表')"
+            ></el-button>
+          </el-tooltip>
+        </div>
+        <div v-if="loading4" class="chart-loading">
+          <i class="el-icon-loading"></i> 加载中...
+        </div>
+        <div v-else-if="deptProjectCountData.length === 0" class="chart-empty">暂无数据</div>
+        <div v-show="!loading4 && deptProjectCountData.length > 0" ref="chart4" class="chart-body"></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+
+export default {
+  name: 'Dashboard',
+  data() {
+    const now = new Date()
+    const year = now.getFullYear()
+    const month = String(now.getMonth() + 1).padStart(2, '0')
+    return {
+      user: JSON.parse(sessionStorage.getItem('user')),
+      selectedMonth: `${year}-${month}`,
+      loading1: false,
+      loading2: false,
+      loading3: false,
+      loading4: false,
+      top10Data: [],
+      top3DeptData: [],
+      deptHoursData: [],
+      deptProjectCountData: [],
+      chart1: null,
+      chart2: null,
+      chart3: null,
+      chart4: null,
+      top3PieMode: 'working',
+    }
+  },
+  mounted() {
+    this.loadAllCharts()
+    window.addEventListener('resize', this.handleResize)
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.handleResize)
+    if (this.chart1) { this.chart1.dispose(); this.chart1 = null }
+    if (this.chart2) { this.chart2.dispose(); this.chart2 = null }
+    if (this.chart3) { this.chart3.dispose(); this.chart3 = null }
+    if (this.chart4) { this.chart4.dispose(); this.chart4 = null }
+  },
+  methods: {
+    onMonthChange() {
+      this.loadAllCharts()
+    },
+
+    loadAllCharts() {
+      this.loadTop10Project()
+      this.loadTop3ProjectDept()
+      this.loadDeptHours()
+      this.loadDeptProjectCount()
+    },
+
+    /**
+     * 保存图表为图片
+     * @param {Object} chartInstance - ECharts 实例
+     * @param {String} fileName - 下载文件名(不含扩展名)
+     */
+    saveChartImage(chartInstance, fileName) {
+      if (!chartInstance) {
+        this.$message({ message: '图表尚未加载完成', type: 'warning' })
+        return
+      }
+      try {
+        const url = chartInstance.getDataURL({
+          type: 'png',
+          pixelRatio: 2,
+          backgroundColor: '#fff'
+        })
+        const link = document.createElement('a')
+        link.href = url
+        link.download = `${fileName}_${this.selectedMonth}.png`
+        document.body.appendChild(link)
+        link.click()
+        document.body.removeChild(link)
+      } catch (e) {
+        this.$message({ message: '图片保存失败,请稍后重试', type: 'error' })
+      }
+    },
+
+    // 企业微信部门名称转译
+    // 通过 WWOpenData.prefetch 获取转译后的纯文本名称(用于坐标轴等 canvas 渲染场景)
+    // 同时在 data 中保留原始 openid(_deptOpenId),供 tooltip 中 ww-open-data 使用
+    translateDeptNames(dataList, nameKey, callback) {
+      if (!dataList || dataList.length === 0) {
+        callback(dataList)
+        return
+      }
+      // 先保存原始 openid,供 tooltip 的 ww-open-data 使用
+      const dataWithOpenId = dataList.map(item => ({
+        ...item,
+        _deptOpenId: item[nameKey]
+      }))
+      if (typeof WWOpenData !== 'undefined') {
+        if (WWOpenData.initCanvas) {
+          WWOpenData.initCanvas()
+        }
+        const items = dataList.map(item => ({
+          type: 'departmentName',
+          id: item[nameKey]
+        }))
+        const myFun = async () => {
+          try {
+            const result = await new Promise((resolve, reject) => {
+              if (WWOpenData.prefetch) {
+                WWOpenData.prefetch({ items }, (err, data) => {
+                  if (err) { return reject(err) }
+                  resolve(data)
+                })
+              } else {
+                resolve({ items: items.map(i => ({ data: i.id })) })
+              }
+            })
+            const translated = dataWithOpenId.map((item, idx) => ({
+              ...item,
+              [nameKey]: (result.items[idx] && result.items[idx].data) ? result.items[idx].data : item[nameKey]
+            }))
+            callback(translated)
+          } catch (e) {
+            callback(dataWithOpenId)
+          }
+        }
+        myFun()
+      } else {
+        callback(dataWithOpenId)
+      }
+    },
+
+    // 生成部门名称的 tooltip HTML
+    // 企业微信环境下优先用 _deptOpenId(原始 openid)渲染 ww-open-data,
+    // 若无 openid 则降级显示转译后的纯文本
+    deptNameHtml(name, openId) {
+      if (this.user.userNameNeedTranslate == 1 && openId) {
+        return `<ww-open-data type="departmentName" openid="${openId}"></ww-open-data>`
+      }
+      return name
+    },
+
+    // 接口1:项目工时排名前十(不涉及部门,无需转译)
+    loadTop10Project() {
+      this.loading1 = true
+      this.top10Data = []
+      this.http.post('/report/getTop10ProjectReport', { ymonth: this.selectedMonth },
+        res => {
+          this.loading1 = false
+          if (res.code === 'ok') {
+            this.top10Data = res.data || []
+            this.$nextTick(() => {
+              this.renderChart1()
+            })
+          } else {
+            this.$message({ message: res.msg, type: 'error' })
+          }
+        },
+        err => {
+          this.loading1 = false
+          this.$message({ message: err, type: 'error' })
+        }
+      )
+    },
+
+    // 接口2:工时前三项目部门分配(按部门,需转译)
+    loadTop3ProjectDept() {
+      this.loading2 = true
+      this.top3DeptData = []
+      this.http.post('/report/getTop3ProjectReportGroupByDept', { ymonth: this.selectedMonth },
+        res => {
+          this.loading2 = false
+          if (res.code === 'ok') {
+            const data = res.data || []
+            if (this.user.userNameNeedTranslate == 1 && data.length > 0) {
+              this.translateDeptNames(data, 'departmentName', translated => {
+                this.top3DeptData = translated
+                this.$nextTick(() => { this.renderChart2() })
+              })
+            } else {
+              this.top3DeptData = data
+              this.$nextTick(() => { this.renderChart2() })
+            }
+          } else {
+            this.$message({ message: res.msg, type: 'error' })
+          }
+        },
+        err => {
+          this.loading2 = false
+          this.$message({ message: err, type: 'error' })
+        }
+      )
+    },
+
+    // 接口3:各部门总工时和人均工时(按部门,需转译)
+    loadDeptHours() {
+      this.loading3 = true
+      this.deptHoursData = []
+      this.http.post('/report/getProjectReportGroupByDept', { ymonth: this.selectedMonth },
+        res => {
+          this.loading3 = false
+          if (res.code === 'ok') {
+            const data = res.data || []
+            if (this.user.userNameNeedTranslate == 1 && data.length > 0) {
+              this.translateDeptNames(data, 'departmentName', translated => {
+                this.deptHoursData = translated
+                this.$nextTick(() => { this.renderChart3() })
+              })
+            } else {
+              this.deptHoursData = data
+              this.$nextTick(() => { this.renderChart3() })
+            }
+          } else {
+            this.$message({ message: res.msg, type: 'error' })
+          }
+        },
+        err => {
+          this.loading3 = false
+          this.$message({ message: err, type: 'error' })
+        }
+      )
+    },
+
+    // 接口4:各部门参与项目数量(按部门,需转译)
+    loadDeptProjectCount() {
+      this.loading4 = true
+      this.deptProjectCountData = []
+      this.http.post('/report/getDeptProjectCount', { ymonth: this.selectedMonth },
+        res => {
+          this.loading4 = false
+          if (res.code === 'ok') {
+            const data = res.data || []
+            if (this.user.userNameNeedTranslate == 1 && data.length > 0) {
+              this.translateDeptNames(data, 'departmentName', translated => {
+                this.deptProjectCountData = translated
+                this.$nextTick(() => { this.renderChart4() })
+              })
+            } else {
+              this.deptProjectCountData = data
+              this.$nextTick(() => { this.renderChart4() })
+            }
+          } else {
+            this.$message({ message: res.msg, type: 'error' })
+          }
+        },
+        err => {
+          this.loading4 = false
+          this.$message({ message: err, type: 'error' })
+        }
+      )
+    },
+
+    // 图表1:项目工时排名前十 - 水平条形图
+    renderChart1() {
+      if (!this.$refs.chart1) return
+      if (this.chart1) { this.chart1.dispose(); this.chart1 = null }
+      const el = this.$refs.chart1
+      const parentEl = el.parentElement
+      const width = parentEl ? parentEl.clientWidth - 32 : el.clientWidth
+      this.chart1 = echarts.init(el, null, { width: width, height: 300 })
+
+      const data = this.top10Data.slice().reverse()
+      const names = data.map(item => item.projectName || item.projectCode || '未知项目')
+      const workingTimes = data.map(item => Number(item.workingTime || 0))
+      const overtimeTimes = data.map(item => Number(item.overtimeHours || 0))
+
+      const option = {
+        tooltip: {
+          trigger: 'axis',
+          axisPointer: { type: 'shadow' },
+          formatter: function(params) {
+            let result = params[0].name + '<br/>'
+            params.forEach(p => {
+              result += p.marker + p.seriesName + ': ' + p.value + ' h<br/>'
+            })
+            return result
+          }
+        },
+        legend: {
+          data: ['工时(h)', '加班工时(h)'],
+          top: 10
+        },
+        grid: {
+          left: '3%',
+          right: '8%',
+          bottom: '3%',
+          top: 50,
+          containLabel: true
+        },
+        xAxis: {
+          type: 'value',
+          axisLabel: { formatter: '{value} h' }
+        },
+        yAxis: {
+          type: 'category',
+          data: names,
+          axisLabel: {
+            width: 120,
+            overflow: 'truncate',
+            ellipsis: '...'
+          }
+        },
+        series: [
+          {
+            name: '工时(h)',
+            type: 'bar',
+            data: workingTimes,
+            itemStyle: { color: '#5B9BD5' },
+            label: { show: true, position: 'right', formatter: '{c} h' }
+          },
+          {
+            name: '加班工时(h)',
+            type: 'bar',
+            data: overtimeTimes,
+            itemStyle: { color: '#ED7D31' },
+            label: { show: true, position: 'right', formatter: '{c} h' }
+          }
+        ]
+      }
+      this.chart1.setOption(option)
+    },
+
+    // 图表2:工时前三项目部门分配 - 环形饼图(支持工时/加班工时切换)
+    renderChart2() {
+      if (!this.$refs.chart2) return
+      if (this.chart2) { this.chart2.dispose(); this.chart2 = null }
+      const el = this.$refs.chart2
+      const parentEl = el.parentElement
+      const width = parentEl ? parentEl.clientWidth - 32 : el.clientWidth
+      this.chart2 = echarts.init(el, null, { width: width, height: 300 })
+
+      const isOvertime = this.top3PieMode === 'overtime'
+      const seriesName = isOvertime ? '部门加班工时分配' : '部门工时分配'
+      const valueLabel = isOvertime ? '加班工时' : '工时'
+
+      // 部门名称已在 loadTop3ProjectDept 中完成转译(如需要)
+      // pieData 中额外存储 openId 供 tooltip 使用
+      const pieData = this.top3DeptData.map(item => ({
+        name: item.departmentName,
+        openId: item._deptOpenId || item.departmentName,
+        value: isOvertime
+          ? Number(item.overtimeHours || 0)
+          : Number(item.workingTime || 0)
+      }))
+
+      const deptNameHtml = this.deptNameHtml.bind(this)
+      const needTranslate = this.user.userNameNeedTranslate == 1
+      const option = {
+        tooltip: {
+          trigger: 'item',
+          enterable: needTranslate,
+          formatter: function(params) {
+            const openId = params.data && params.data.openId ? params.data.openId : null
+            return deptNameHtml(params.name, openId) + '<br/>' + valueLabel + ': ' + params.value + ' h<br/>占比: ' + params.percent + '%'
+          }
+        },
+        legend: {
+          orient: 'vertical',
+          left: 'left',
+          top: 'center'
+        },
+        series: [
+          {
+            name: seriesName,
+            type: 'pie',
+            radius: ['35%', '65%'],
+            center: ['60%', '50%'],
+            avoidLabelOverlap: true,
+            itemStyle: {
+              borderRadius: 6,
+              borderColor: '#fff',
+              borderWidth: 2
+            },
+            label: {
+              show: true,
+              formatter: '{b}\n{d}%'
+            },
+            emphasis: {
+              label: { show: true, fontSize: 14, fontWeight: 'bold' }
+            },
+            data: pieData
+          }
+        ]
+      }
+      this.chart2.setOption(option)
+    },
+
+    // 图表3:各部门总工时和人均工时 - 双轴柱线图
+    renderChart3() {
+      if (!this.$refs.chart3) return
+      if (this.chart3) { this.chart3.dispose(); this.chart3 = null }
+      const el = this.$refs.chart3
+      const parentEl = el.parentElement
+      const width = parentEl ? parentEl.clientWidth - 32 : el.clientWidth
+      this.chart3 = echarts.init(el, null, { width: width, height: 300 })
+
+      // 部门名称已在 loadDeptHours 中完成转译(如需要)
+      const depts = this.deptHoursData.map(item => item.departmentName)
+      const totalHours = this.deptHoursData.map(item => Number((item.workingTime || 0).toFixed(2)))
+      const avgHours = this.deptHoursData.map(item => Number((item.avgWorkingTime || 0).toFixed(2)))
+      const totalOvertime = this.deptHoursData.map(item => Number((item.overtimeHours || 0).toFixed(2)))
+      const avgOvertime = this.deptHoursData.map(item => Number((item.avgOvertimeHours || 0).toFixed(2)))
+
+      // 建立 转译名称 -> 原始openId 的映射,供 tooltip 使用
+      const deptOpenIdMap3 = {}
+      this.deptHoursData.forEach(item => {
+        deptOpenIdMap3[item.departmentName] = item._deptOpenId || item.departmentName
+      })
+
+      const deptNameHtml3 = this.deptNameHtml.bind(this)
+      const needTranslate3 = this.user.userNameNeedTranslate == 1
+      const option = {
+        tooltip: {
+          trigger: 'axis',
+          axisPointer: { type: 'shadow' },
+          enterable: needTranslate3,
+          formatter: function(params) {
+            const openId = deptOpenIdMap3[params[0].name] || null
+            let result = deptNameHtml3(params[0].name, openId) + '<br/>'
+            params.forEach(p => {
+              result += p.marker + p.seriesName + ': ' + p.value + ' h<br/>'
+            })
+            return result
+          }
+        },
+        legend: {
+          data: ['总工时(h)', '总加班工时(h)', '人均工时(h)', '人均加班工时(h)'],
+          top: 10,
+          textStyle: { fontSize: 11 }
+        },
+        grid: {
+          left: '3%',
+          right: '5%',
+          bottom: '15%',
+          top: 60,
+          containLabel: true
+        },
+        xAxis: {
+          type: 'category',
+          data: depts,
+          axisLabel: { rotate: 30, interval: 0, fontSize: 11 }
+        },
+        yAxis: [
+          {
+            type: 'value',
+            name: '总工时(h)',
+            axisLabel: { formatter: '{value} h' }
+          },
+          {
+            type: 'value',
+            name: '人均工时(h)',
+            axisLabel: { formatter: '{value} h' }
+          }
+        ],
+        series: [
+          {
+            name: '总工时(h)',
+            type: 'bar',
+            yAxisIndex: 0,
+            data: totalHours,
+            itemStyle: { color: '#5B9BD5' },
+            barMaxWidth: 40
+          },
+          {
+            name: '总加班工时(h)',
+            type: 'bar',
+            yAxisIndex: 0,
+            data: totalOvertime,
+            itemStyle: { color: '#ED7D31' },
+            barMaxWidth: 40
+          },
+          {
+            name: '人均工时(h)',
+            type: 'line',
+            yAxisIndex: 1,
+            data: avgHours,
+            itemStyle: { color: '#70AD47' },
+            lineStyle: { width: 2 },
+            symbol: 'circle',
+            symbolSize: 6
+          },
+          {
+            name: '人均加班工时(h)',
+            type: 'line',
+            yAxisIndex: 1,
+            data: avgOvertime,
+            itemStyle: { color: '#FFC000' },
+            lineStyle: { width: 2 },
+            symbol: 'circle',
+            symbolSize: 6
+          }
+        ]
+      }
+      this.chart3.setOption(option)
+    },
+
+    // 图表4:各部门参与项目数量 - 柱状图
+    renderChart4() {
+      if (!this.$refs.chart4) return
+      if (this.chart4) { this.chart4.dispose(); this.chart4 = null }
+      const el = this.$refs.chart4
+      const parentEl = el.parentElement
+      const width = parentEl ? parentEl.clientWidth - 32 : el.clientWidth
+      this.chart4 = echarts.init(el, null, { width: width, height: 300 })
+
+      // 部门名称已在 loadDeptProjectCount 中完成转译(如需要)
+      const depts = this.deptProjectCountData.map(item => item.departmentName)
+      const counts = this.deptProjectCountData.map(item => Number(item.projectCount || 0))
+
+      // 建立 转译名称 -> 原始openId 的映射,供 tooltip 使用
+      const deptOpenIdMap4 = {}
+      this.deptProjectCountData.forEach(item => {
+        deptOpenIdMap4[item.departmentName] = item._deptOpenId || item.departmentName
+      })
+
+      const deptNameHtml4 = this.deptNameHtml.bind(this)
+      const needTranslate4 = this.user.userNameNeedTranslate == 1
+      const option = {
+        tooltip: {
+          trigger: 'axis',
+          axisPointer: { type: 'shadow' },
+          enterable: needTranslate4,
+          formatter: function(params) {
+            const openId = deptOpenIdMap4[params[0].name] || null
+            return deptNameHtml4(params[0].name, openId) + '<br/>' + params[0].marker + '参与项目数: ' + params[0].value + ' 个'
+          }
+        },
+        grid: {
+          left: '3%',
+          right: '5%',
+          bottom: '15%',
+          top: 30,
+          containLabel: true
+        },
+        xAxis: {
+          type: 'category',
+          data: depts,
+          axisLabel: { rotate: 30, interval: 0, fontSize: 11 }
+        },
+        yAxis: {
+          type: 'value',
+          name: '项目数量(个)',
+          minInterval: 1,
+          axisLabel: { formatter: '{value} 个' }
+        },
+        series: [
+          {
+            name: '参与项目数',
+            type: 'bar',
+            data: counts,
+            barMaxWidth: 50,
+            itemStyle: {
+              color: function(params) {
+                const colorList = [
+                  '#5B9BD5', '#ED7D31', '#70AD47', '#FFC000',
+                  '#4472C4', '#FF6B6B', '#48CAE4', '#9B59B6',
+                  '#2ECC71', '#E74C3C'
+                ]
+                return colorList[params.dataIndex % colorList.length]
+              }
+            },
+            label: {
+              show: true,
+              position: 'top',
+              formatter: '{c} 个'
+            }
+          }
+        ]
+      }
+      this.chart4.setOption(option)
+    },
+
+    handleResize() {
+      const resizeChart = (chart, refName) => {
+        if (!chart || !this.$refs[refName]) return
+        const el = this.$refs[refName]
+        const parentEl = el.parentElement
+        const width = parentEl ? parentEl.clientWidth - 32 : el.clientWidth
+        chart.resize({ width: width })
+      }
+      resizeChart(this.chart1, 'chart1')
+      resizeChart(this.chart2, 'chart2')
+      resizeChart(this.chart3, 'chart3')
+      resizeChart(this.chart4, 'chart4')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.dashboard-container {
+  padding: 12px 20px;
+  background: #f5f6fa;
+  min-height: 100%;
+  box-sizing: border-box;
+  width: 100%;
+  max-width: 100%;
+  overflow-x: hidden;
+}
+
+.dashboard-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+  background: #fff;
+  padding: 12px 20px;
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+  box-sizing: border-box;
+}
+
+.dashboard-title {
+  margin: 0;
+  font-size: 16px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.charts-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+  width: 100%;
+  box-sizing: border-box;
+  overflow: hidden;
+}
+
+.chart-card {
+  background: #fff;
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+  padding: 16px;
+  display: flex;
+  flex-direction: column;
+  box-sizing: border-box;
+  overflow: hidden;
+  min-width: 0;
+}
+
+.chart-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 15px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 12px;
+  padding-bottom: 10px;
+  border-bottom: 2px solid #409EFF;
+  flex-shrink: 0;
+}
+
+.chart-title-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.save-img-btn {
+  font-size: 16px;
+  color: #909399;
+  padding: 0;
+  line-height: 1;
+  transition: color 0.2s;
+}
+
+.save-img-btn:hover:not([disabled]) {
+  color: #409EFF;
+}
+
+.save-img-btn[disabled] {
+  color: #c0c4cc;
+  cursor: not-allowed;
+}
+
+.chart-body {
+  height: 300px;
+  width: 100%;
+  overflow: hidden;
+}
+
+.chart-loading,
+.chart-empty {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #909399;
+  font-size: 14px;
+  height: 300px;
+}
+
+.chart-loading i {
+  margin-right: 6px;
+  font-size: 18px;
+}
+</style>