云
2026-10-26 6d5df8bf7ec6b35e4ccdfd4010e3add4af44e910
feat: 新增公出申请审批、安全驾驶舱及工资发放记录查看

- 审批管理新增公出申请模块,模板列表支持按模板类型筛选
- 人员工资统计新增发放记录弹窗与发放时间列
- 新增安全驾驶舱页面及对应接口
已添加9个文件
已修改6个文件
2245 ■■■■■ 文件已修改
src/api/reportAnalysis/safetyDashboard.js 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceListSearch.js 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/AttendManage/out-apply/index.vue 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/personnelManagement/monthlyStatistics/index.vue 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/reportAnalysis/safetyDashboard/components/center-bottom.vue 217 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/reportAnalysis/safetyDashboard/components/center-top.vue 432 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/reportAnalysis/safetyDashboard/components/left-bottom.vue 206 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/reportAnalysis/safetyDashboard/components/left-top.vue 241 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/reportAnalysis/safetyDashboard/components/right-bottom.vue 275 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/reportAnalysis/safetyDashboard/components/right-top.vue 275 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/reportAnalysis/safetyDashboard/index.vue 256 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/reportAnalysis/safetyDashboard.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
import request from '@/utils/request'
// é¡¶éƒ¨æ€»è§ˆ
export function getSafetyOverview() {
  return request({
    url: '/safeDashboard/getOverview',
    method: 'get'
  })
}
// æœ€æ–°å®‰å…¨åŠ¨æ€
export function getLatestSafeEvents(params) {
  return request({
    url: '/safeDashboard/getLatestEvents',
    method: 'get',
    params
  })
}
// é𐿂£ç±»åž‹åˆ†å¸ƒ
export function getHiddenTypeDistribution() {
  return request({
    url: '/safeDashboard/getHiddenTypeDistribution',
    method: 'get'
  })
}
// è¿‘6个月隐患趋势
export function getHiddenTrend() {
  return request({
    url: '/safeDashboard/getHiddenTrend',
    method: 'get'
  })
}
// å±é™©æºé£Žé™©ç­‰çº§åˆ†å¸ƒ
export function getHazardRiskDistribution() {
  return request({
    url: '/safeDashboard/getHazardRiskDistribution',
    method: 'get'
  })
}
// å±é™©æºç±»åž‹æŽ’名
export function getHazardTypeRanking(params) {
  return request({
    url: '/safeDashboard/getHazardTypeRanking',
    method: 'get',
    params
  })
}
// é𐿂£æ•´æ”¹è´£ä»»äººæŽ’名
export function getHiddenRectifyRanking(params) {
  return request({
    url: '/safeDashboard/getHiddenRectifyRanking',
    method: 'get',
    params
  })
}
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceListSearch.js
@@ -8,6 +8,7 @@
  APPROVAL_MODULE_KEYS.WORK_HANDOVER,
  APPROVAL_MODULE_KEYS.LEAVE,
  APPROVAL_MODULE_KEYS.OVERTIME,
  APPROVAL_MODULE_KEYS.OUT_OFFICE,
]);
export const INSTANCE_NO_TABLE_COLUMN = {
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js
@@ -9,6 +9,7 @@
  WORK_HANDOVER: "work_handover",
  LEAVE: "leave",
  OVERTIME: "overtime",
  OUT_OFFICE: "out_office",
  TRAVEL_REIMBURSE: "travel_reimburse",
  COST_REIMBURSE: "cost_reimburse",
  ENTERPRISE_NEWS: "enterprise_news",
@@ -21,6 +22,7 @@
  [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: 13,
  [APPROVAL_MODULE_KEYS.LEAVE]: 14,
  [APPROVAL_MODULE_KEYS.OVERTIME]: 15,
  [APPROVAL_MODULE_KEYS.OUT_OFFICE]: 1,
  [APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE]: 16,
  [APPROVAL_MODULE_KEYS.COST_REIMBURSE]: 17,
  [APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS]: 18,
@@ -63,6 +65,12 @@
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.OVERTIME],
    typeLabels: ["加班", "加班申请", "加班审批"],
  },
  [APPROVAL_MODULE_KEYS.OUT_OFFICE]: {
    label: "公出申请",
    approvalType: "out_office",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.OUT_OFFICE],
    typeLabels: ["公出", "公出申请", "公出管理"],
  },
  [APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE]: {
    label: "差旅报销",
    approvalType: "travel_reimburse",
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
@@ -270,6 +270,9 @@
  };
  const kw = (searchForm?.keyword || "").trim();
  if (kw) params.templateName = kw;
  if (searchForm?.businessType != null && searchForm.businessType !== "") {
    params.businessType = searchForm.businessType;
  }
  if (searchForm?.enabledOnly) params.enabled = "1";
  return params;
}
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
@@ -26,6 +26,22 @@
        />
        <span class="search_title" style="margin-left: 12px">模板类型:</span>
        <el-select
          v-model="searchForm.businessType"
          placeholder="请选择模板类型"
          clearable
          filterable
          style="width: 180px"
          @change="handleQuery"
        >
          <el-option
            v-for="opt in templateTypeOptions"
            :key="`search-biz-type-${opt.value}`"
            :label="opt.label"
            :value="opt.value"
          />
        </el-select>
        <el-checkbox v-model="searchForm.enabledOnly" class="ml12" @change="handleQuery">
          ä»…显示启用
@@ -422,6 +438,7 @@
import { ElMessage } from "element-plus";
import { computed, nextTick, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { userListNoPageByTenantId } from "@/api/system/user.js";
@@ -495,6 +512,8 @@
} = useApproveTemplate();
const route = useRoute();
const flowUserOptions = ref([]);
@@ -614,11 +633,17 @@
onMounted(() => {
onMounted(async () => {
  loadUsers();
  loadTemplateTypeOptions();
  const queryBusinessType = route.query.businessType;
  if (queryBusinessType != null && queryBusinessType !== "") {
    searchForm.businessType = Number(queryBusinessType);
  }
  await loadTemplateTypeOptions();
  fetchTemplateList();
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
@@ -49,6 +49,7 @@
  const searchForm = reactive({
    keyword: "",
    businessType: "",
    enabledOnly: false,
  });
@@ -176,6 +177,7 @@
  function resetSearch() {
    searchForm.keyword = "";
    searchForm.businessType = "";
    searchForm.enabledOnly = false;
    handleQuery();
  }
src/views/officeProcessAutomation/AttendManage/out-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,174 @@
<!--OA模块:公出申请-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">审批单号:</span>
        <el-input
          v-model="searchForm.instanceNo"
          style="width: 220px"
          placeholder="请输入审批单号"
          clearable
          @keyup.enter="onSearch"
        />
        <span class="search_title" style="margin-left: 12px">申请人:</span>
        <el-input
          v-model="searchForm.applicantKeyword"
          style="width: 220px"
          placeholder="姓名或编号"
          clearable
          :prefix-icon="Search"
          @keyup.enter="onSearch"
        />
        <el-button type="primary" style="margin-left: 10px" @click="onSearch">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="primary" @click="openAddWithTemplate">新增公出申请</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="onPagination"
        :total="page.total"
      />
    </div>
    <ApprovalInstanceSubmitDialog
      v-model="submitDialog.visible"
      :title="submitDialogTitle"
      :form="submitForm"
      :rules="submitFormRules"
      :fields="submitFormFields"
      :active-template="activeTemplate"
      :user-options="flowUserOptions"
      :is-edit="isSubmitEdit"
      :saving="submitSaving"
      :form-ref="submitFormRef"
      @submit="onSubmit"
    />
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
      :module-key="APPROVAL_MODULE_KEYS.OUT_OFFICE"
      skip-form-confirm
      @confirm="onTemplateBound"
      @closed="onTemplateBindClosed"
    />
    <ApprovalInstanceDetailDialog
      v-model="detailDialog.visible"
      title="公出申请详情"
      :row="detailRow"
      @edit="openEditFromDetail"
    />
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { onMounted, reactive } from "vue";
import ApprovalInstanceDetailDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue";
import ApprovalInstanceSubmitDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import { buildInstanceTableColumns } from "../../ApproveManage/approve-shared/approvalInstanceFormConfigTable.js";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import { useApprovalInstanceModule } from "../../ApproveManage/approve-shared/useApprovalInstanceModule.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
const searchForm = reactive({
  instanceNo: "",
  applicantKeyword: "",
});
const mod = useApprovalInstanceModule({
  moduleKey: APPROVAL_MODULE_KEYS.OUT_OFFICE,
});
const {
  tableData,
  tableLoading,
  page,
  detailDialog,
  detailRow,
  submitDialog,
  submitForm,
  submitFormRef,
  submitSaving,
  isSubmitEdit,
  activeTemplate,
  submitFormFields,
  submitFormRules,
  submitDialogTitle,
  templateBindVisible,
  handleQuery,
  initModuleList,
  pagination,
  openAddWithTemplate,
  onTemplateBound,
  onTemplateBindClosed,
  openEditFromDetail,
  submitInstanceForm,
  buildTableActions,
} = mod;
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const tableColumn = buildInstanceTableColumns(tableData, buildTableActions, {
  moduleKey: APPROVAL_MODULE_KEYS.OUT_OFFICE,
});
function onSearch() {
  handleQuery(searchForm);
}
function resetSearch() {
  searchForm.instanceNo = "";
  searchForm.applicantKeyword = "";
  onSearch();
}
function onPagination(obj) {
  pagination(obj, searchForm);
}
async function onSubmit() {
  const ok = await submitInstanceForm({ skipValidate: true });
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "提交成功");
}
onMounted(async () => {
  loadFlowUsers();
  await initModuleList(searchForm);
});
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
</style>
src/views/personnelManagement/monthlyStatistics/index.vue
@@ -108,15 +108,47 @@
        <el-table-column prop="fundPersonal" label="公积金(个人)" width="110" align="right" />
        <el-table-column prop="salaryTax" label="个税" width="90" align="right" />
        <el-table-column prop="grossSalary" label="应发工资" width="110" align="right">
          <template #default="{ row }">{{ formatMoney(row.grossSalary) }}</template>
          <template #default="{ row }">{{ formatAmount(row.grossSalary) }}</template>
        </el-table-column>
        <el-table-column prop="deductSalary" label="扣款合计" width="100" align="right" />
        <el-table-column prop="netSalary" label="实发工资" width="110" align="right">
          <template #default="{ row }">{{ formatMoney(row.netSalary) }}</template>
          <template #default="{ row }">{{ formatAmount(row.netSalary) }}</template>
        </el-table-column>
      </el-table>
      <template #footer>
        <el-button @click="salaryDetailVisible = false">关闭</el-button>
      </template>
    </el-dialog>
    <el-dialog v-model="issueRecordVisible" title="发放记录" width="820px" append-to-body>
      <template v-if="issueRecordRow">
        <el-descriptions :column="2" border>
          <el-descriptions-item label="工资主题">{{ issueRecordRow.salaryTitle }}</el-descriptions-item>
          <el-descriptions-item label="工资月份">{{ issueRecordRow.salaryMonth }}</el-descriptions-item>
          <el-descriptions-item label="工资总额">{{ formatAmount(issueRecordRow.totalSalary) }}</el-descriptions-item>
          <el-descriptions-item label="发放银行">{{ issueRecordRow.payBank || "-" }}</el-descriptions-item>
          <el-descriptions-item label="发放时间">{{ issueRecordRow.payTime || "-" }}</el-descriptions-item>
          <el-descriptions-item label="审核人">{{ issueRecordRow.auditUserName || "-" }}</el-descriptions-item>
          <el-descriptions-item label="备注" :span="2">{{ issueRecordRow.remark || "-" }}</el-descriptions-item>
        </el-descriptions>
        <div class="issue-record-title">发放明细({{ issueRecordDetails.length }} äººï¼‰</div>
        <el-table :data="issueRecordDetails" border size="small" max-height="360">
          <el-table-column prop="staffName" label="姓名" width="90" />
          <el-table-column prop="deptName" label="部门" width="110" />
          <el-table-column prop="postName" label="岗位" width="110" />
          <el-table-column label="应发工资" width="110" align="right">
            <template #default="{ row }">{{ formatAmount(row.grossSalary) }}</template>
          </el-table-column>
          <el-table-column label="扣款合计" width="110" align="right">
            <template #default="{ row }">{{ formatAmount(row.deductSalary) }}</template>
          </el-table-column>
          <el-table-column label="实发工资" width="110" align="right">
            <template #default="{ row }">{{ formatAmount(row.netSalary) }}</template>
          </el-table-column>
          <el-table-column prop="remark" label="备注" show-overflow-tooltip />
        </el-table>
      </template>
      <template #footer>
        <el-button @click="issueRecordVisible = false">关闭</el-button>
      </template>
    </el-dialog>
  </div>
@@ -175,6 +207,7 @@
  },
  { label: "工资总额", prop: "totalSalary", width: 120 },
  { label: "支付银行", prop: "payBank", width: 120 },
  { label: "发放时间", prop: "payTime", width: 160, formatData: (v) => v || "-" },
  { label: "审核人", prop: "auditUserName", width: 110 },
  { label: "备注", prop: "remark", minWidth: 120 },
  {
@@ -182,7 +215,7 @@
    label: "操作",
    align: "center",
    fixed: "right",
    width: 180,
    width: 240,
    operation: [
      {
        name: "查看明细",
@@ -211,6 +244,12 @@
        type: "text",
        disabled: (row) => Number(row?.status) !== 4,
        clickFun: (row) => openIssue(row),
      },
      {
        name: "发放记录",
        type: "text",
        disabled: (row) => Number(row?.status) !== 5,
        clickFun: (row) => openIssueRecord(row),
      },
    ],
  },
@@ -242,10 +281,20 @@
const auditDiaRef = ref(null);
const salaryDetailVisible = ref(false);
const salaryDetailRow = ref({});
const issueRecordVisible = ref(false);
const issueRecordRow = ref(null);
const formatMoney = (value) => {
  if (value === undefined || value === null || value === "") return "-";
  return Number(value).toFixed(2);
const issueRecordDetails = computed(() =>
  Array.isArray(issueRecordRow.value?.staffSalaryDetailList)
    ? issueRecordRow.value.staffSalaryDetailList
    : []
);
const formatAmount = (v) => (v == null || v === "" ? "-" : Number(v).toFixed(2));
const openIssueRecord = (row) => {
  issueRecordRow.value = row || null;
  issueRecordVisible.value = true;
};
const openSalaryDetail = (row) => {
@@ -457,4 +506,9 @@
  margin-bottom: 12px;
  font-weight: 600;
}
.issue-record-title {
  margin: 16px 0 8px;
  font-weight: 600;
  font-size: 14px;
}
</style>
src/views/reportAnalysis/safetyDashboard/components/center-bottom.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,217 @@
<template>
  <div>
    <PanelHeader title="危险源风险等级分布" />
    <div class="panel-item-customers">
      <div class="pie-chart-wrapper">
        <div class="pie-background" :style="{ backgroundImage: `url(${roseBorderImg})` }"></div>
        <Echarts
          ref="echartsRef"
          :chartStyle="chartStyle"
          :legend="pieLegend"
          :series="pieSeries"
          :tooltip="pieTooltip"
          :color="pieColors"
          :options="pieOptions"
          style="height: 320px"
        />
        <div v-if="showEmpty" class="empty-tip">暂无数据</div>
      </div>
      <span v-if="refreshFailed" class="refresh-error">刷新失败</span>
    </div>
  </div>
</template>
<script setup>
import { ref, computed, onMounted, inject, watch } from 'vue'
import { getHazardRiskDistribution } from '@/api/reportAnalysis/safetyDashboard.js'
import PanelHeader from '@/views/reportAnalysis/dataDashboard/components/PanelHeader.vue'
import Echarts from '@/components/Echarts/echarts.vue'
import roseBorderImg from '@/assets/BI/玫瑰图边框.png'
const echartsRef = ref(null)
const riskRows = ref([])
const loaded = ref(false)
const loading = ref(false)
const refreshFailed = ref(false)
const pieColors = ['#ff5676', '#ffce64', '#43e8fc', '#82BAFF', '#D1E4F5']
const chartStyle = {
  width: '100%',
  height: '100%'
}
const total = computed(() =>
  riskRows.value.reduce((sum, item) => sum + (Number(item.value) || 0), 0)
)
const showEmpty = computed(() => loaded.value && !loading.value && total.value === 0)
const objectData = computed(() => {
  const map = {}
  riskRows.value.forEach((item) => {
    map[item.name] = item
  })
  return map
})
const pieLegend = computed(() => ({
  orient: 'vertical',
  top: 'center',
  left: '50%',
  itemGap: 30,
  icon: 'circle',
  data: riskRows.value.map((item, idx) => ({
    name: item.name,
    icon: 'circle',
    textStyle: {
      fontSize: 18,
      color: pieColors[idx % pieColors.length]
    }
  })),
  formatter: function (name) {
    const item = objectData.value[name]
    if (!item) return name
    return `{title|${name}}{value|${item.value}}{unit|处}{percent|${item.rate ?? '0.00'}}{unit|%}`
  },
  textStyle: {
    rich: {
      value: {
        color: '#43e8fc',
        fontSize: 18,
        fontWeight: 600,
        padding: [0, 10, 0, 30]
      },
      unit: {
        color: '#82baff',
        fontSize: 14,
        fontWeight: 600,
        padding: [0, 50, 0, 0]
      },
      percent: {
        color: '#43e8fc',
        fontSize: 18,
        fontWeight: 600,
        padding: [0, 10, 0, 0]
      },
      title: {
        fontSize: 18,
        padding: [0, 0, 0, 0]
      }
    }
  }
}))
const pieTooltip = {
  trigger: 'item',
  formatter: '{a} <br/>{b}:{c} å¤„({d}%)'
}
const pieSeries = computed(() => [
  {
    name: '危险源风险等级',
    type: 'pie',
    radius: '70%',
    center: ['20%', '50%'],
    itemStyle: {
      borderColor: '#0a1c3a',
      borderWidth: 5
    },
    label: {
      show: false
    },
    minAngle: 15,
    data: riskRows.value.map((item) => ({
      name: item.name,
      value: Number(item.value) || 0,
      rate: item.rate
    })),
    animationType: 'scale',
    animationEasing: 'elasticOut'
  }
])
const pieOptions = {
  backgroundColor: 'transparent',
  textStyle: { color: '#B8C8E0' }
}
const loadData = async () => {
  if (loading.value) return
  loading.value = true
  try {
    const res = await getHazardRiskDistribution()
    riskRows.value = Array.isArray(res?.data) ? res.data : []
    refreshFailed.value = false
    loaded.value = true
  } catch (e) {
    console.error('获取危险源风险等级分布失败:', e)
    refreshFailed.value = true
  } finally {
    loading.value = false
  }
}
const safetyDashboardRefreshTick = inject('safetyDashboardRefreshTick', null)
if (safetyDashboardRefreshTick) {
  watch(safetyDashboardRefreshTick, () => {
    loadData()
  })
}
onMounted(() => {
  loadData()
})
</script>
<style scoped>
.panel-item-customers {
  position: relative;
  border: 1px solid #1a58b0;
  padding: 18px;
  width: 100%;
  height: 370px;
  box-sizing: border-box;
}
.pie-chart-wrapper {
  position: relative;
  width: 100%;
  height: 320px;
  background: transparent;
}
.pie-background {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-113%, -50%);
  width: 360px;
  height: 360px;
  background-size: contain;
  background-position: center;
  background-repeat: no-repeat;
  z-index: 1;
  pointer-events: none;
}
.empty-tip {
  position: absolute;
  left: 0;
  right: 0;
  top: 44%;
  width: 40%;
  text-align: center;
  font-size: 15px;
  color: #8aa4c8;
  pointer-events: none;
}
.refresh-error {
  position: absolute;
  right: 16px;
  top: 8px;
  font-size: 12px;
  color: #ff9a9a;
}
</style>
src/views/reportAnalysis/safetyDashboard/components/center-top.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,432 @@
<template>
  <div class="center-top">
    <div class="stats-cards">
      <div class="stat-card">
        <img src="@/assets/BI/icon@2x.png" alt="图标" class="card-icon" />
        <div class="card-content">
          <span class="card-label">隐患总数</span>
          <span class="card-value">{{ overviewValue(overview.hiddenTotal) }}</span>
          <div class="card-sub">
            <span>待整改 {{ overviewValue(overview.hiddenPending) }}</span>
            <span class="card-sub-divider">/</span>
            <span>已整改 {{ overviewValue(overview.hiddenRectified) }}</span>
          </div>
        </div>
      </div>
      <div class="stat-card">
        <img src="@/assets/BI/icon@2x.png" alt="图标" class="card-icon" />
        <div class="card-content">
          <span class="card-label">危险源总数</span>
          <span class="card-value">{{ overviewValue(overview.hazardTotal) }}</span>
          <div class="card-sub">
            <span>重大风险 {{ overviewValue(overview.hazardMajor) }}</span>
          </div>
        </div>
      </div>
      <div class="stat-card">
        <img src="@/assets/BI/icon@2x.png" alt="图标" class="card-icon" />
        <div class="card-content">
          <span class="card-label">事故总数</span>
          <span class="card-value">{{ overviewValue(overview.accidentTotal) }}</span>
          <div class="card-sub">
            <span>本年 {{ overviewValue(overview.accidentThisYear) }}</span>
          </div>
        </div>
      </div>
    </div>
    <div class="event-info">
      <div class="event-header">
        <img src="@/assets/BI/shijianmingxiicon@2x.png" alt="图标" class="event-icon" />
        <span class="event-title">安全动态</span>
        <span v-if="refreshFailed" class="refresh-error">刷新失败</span>
      </div>
      <div class="metrics-row">
        <div class="metric" v-for="item in metrics" :key="item.label">
          <span class="metric-value">{{ item.value }}</span>
          <span class="metric-label">{{ item.label }}</span>
        </div>
      </div>
      <div class="event-content">
        <ul class="event-list" v-if="displayEvents.length > 0" ref="listRef">
          <li v-for="(item, index) in displayEvents" :key="index">
            <div class="event-line">
              <span class="event-cat" :class="catClass(item.category)">{{ item.category }}</span>
              <span class="event-name">{{ item.title }}</span>
              <span class="event-status">{{ item.statusText }}</span>
            </div>
            <div class="event-meta">
              <span>{{ item.location || '—' }}</span>
              <span>{{ item.eventTime }}</span>
            </div>
          </li>
        </ul>
        <div v-else class="event-empty">
          {{ eventsFailed ? '数据加载失败,60秒后自动重试' : '暂无安全动态' }}
        </div>
      </div>
    </div>
  </div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick, inject, watch } from 'vue'
import {
  getSafetyOverview,
  getLatestSafeEvents
} from '@/api/reportAnalysis/safetyDashboard.js'
const overview = ref({
  hiddenTotal: 0,
  hiddenPending: 0,
  hiddenRectified: 0,
  hazardTotal: 0,
  hazardMajor: 0,
  accidentTotal: 0,
  accidentThisYear: 0,
  trainingCount: 0,
  trainingPersonTimes: 0,
  planCount: 0,
  certCount: 0
})
const overviewLoaded = ref(false)
const overviewFailed = ref(false)
const events = ref([])
const eventsFailed = ref(false)
const listRef = ref(null)
const loading = ref(false)
const refreshFailed = computed(() => overviewFailed.value || eventsFailed.value)
const overviewValue = (val) => (overviewLoaded.value ? Number(val) || 0 : '--')
const metrics = computed(() => [
  { label: '培训场次', value: overviewValue(overview.value.trainingCount) },
  { label: '参训人次', value: overviewValue(overview.value.trainingPersonTimes) },
  { label: '应急预案', value: overviewValue(overview.value.planCount) },
  { label: '安全资质', value: overviewValue(overview.value.certCount) }
])
const displayEvents = computed(() => {
  if (events.value.length >= 4) {
    return [...events.value, ...events.value]
  }
  return events.value
})
const catClass = (category) => {
  if (category === '事故') return 'cat-accident'
  if (category === '危险物料领用') return 'cat-material'
  return 'cat-hidden'
}
let rafId = null
const stopScroll = () => {
  if (rafId) {
    cancelAnimationFrame(rafId)
    rafId = null
  }
}
const startScroll = () => {
  stopScroll()
  nextTick(() => {
    const el = listRef.value
    if (!el || events.value.length < 4) return
    const half = el.scrollHeight / 2
    if (half <= el.clientHeight) return
    let position = 0
    const step = () => {
      position += 0.4
      if (position >= half) {
        position = 0
        el.scrollTop = 0
      } else {
        el.scrollTop = position
      }
      rafId = requestAnimationFrame(step)
    }
    rafId = requestAnimationFrame(step)
  })
}
const loadOverview = async () => {
  try {
    const res = await getSafetyOverview()
    overview.value = {
      hiddenTotal: 0,
      hiddenPending: 0,
      hiddenRectified: 0,
      hazardTotal: 0,
      hazardMajor: 0,
      accidentTotal: 0,
      accidentThisYear: 0,
      trainingCount: 0,
      trainingPersonTimes: 0,
      planCount: 0,
      certCount: 0,
      ...(res?.data || {})
    }
    overviewLoaded.value = true
    overviewFailed.value = false
  } catch (e) {
    console.error('获取安全总览失败:', e)
    overviewFailed.value = true
  }
}
const loadEvents = async () => {
  try {
    const res = await getLatestSafeEvents({ limit: 10 })
    stopScroll()
    events.value = Array.isArray(res?.data) ? res.data : []
    eventsFailed.value = false
    startScroll()
  } catch (e) {
    console.error('获取最新安全动态失败:', e)
    eventsFailed.value = true
  }
}
const refreshData = async () => {
  if (loading.value) return
  loading.value = true
  await Promise.allSettled([loadOverview(), loadEvents()])
  loading.value = false
}
const safetyDashboardRefreshTick = inject('safetyDashboardRefreshTick', null)
if (safetyDashboardRefreshTick) {
  watch(safetyDashboardRefreshTick, () => {
    refreshData()
  })
}
onMounted(() => {
  refreshData()
})
onBeforeUnmount(() => {
  stopScroll()
})
</script>
<style scoped>
.center-top {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 20px;
}
.stats-cards {
  display: flex;
  gap: 30px;
}
.stat-card {
  flex: 1;
  display: flex;
  align-items: center;
  background-image: url('@/assets/BI/border@2x.png');
  background-size: 100% 100%;
  background-position: center;
  background-repeat: no-repeat;
  height: 142px;
}
.card-icon {
  width: 100px;
  height: 100px;
  margin: 20px 20px 0 10px;
}
.card-content {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.card-value {
  font-weight: 500;
  font-size: 40px;
  background: linear-gradient(360deg, #008bfd 0%, #ffffff 100%);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
}
.card-label {
  font-weight: 400;
  font-size: 19px;
  color: rgba(208, 231, 255, 0.7);
}
.card-sub {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 14px;
  color: #d0e7ff;
  opacity: 0.9;
}
.card-sub-divider {
  opacity: 0.5;
}
.event-info {
  flex: 1;
  min-height: 220px;
  display: flex;
  flex-direction: column;
  background-image: url('@/assets/BI/shijianmingchengbeijing@2x.png');
  background-size: 100% 100%;
  background-position: center;
  background-repeat: no-repeat;
  padding: 12px 20px 18px;
}
.event-header {
  display: flex;
  align-items: center;
  position: relative;
}
.event-icon {
  width: 40px;
  height: 40px;
}
.event-title {
  font-weight: 500;
  font-size: 18px;
  color: #fffffe;
  line-height: 30px;
}
.refresh-error {
  margin-left: 12px;
  font-size: 12px;
  color: #ff9a9a;
}
.metrics-row {
  display: flex;
  gap: 12px;
  margin: 8px 0 12px;
}
.metric {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 8px 0;
  border-radius: 6px;
  background: rgba(24, 93, 190, 0.18);
  border: 1px solid rgba(64, 140, 235, 0.25);
}
.metric-value {
  font-size: 22px;
  font-weight: 600;
  color: #43e8fc;
}
.metric-label {
  font-size: 13px;
  color: rgba(208, 231, 255, 0.75);
}
.event-content {
  flex: 1;
  min-height: 0;
  overflow: hidden;
}
.event-list {
  list-style: none;
  padding: 0;
  margin: 0;
  height: 100%;
  overflow: hidden;
}
.event-list li {
  padding: 8px 12px;
  margin-bottom: 8px;
  border-radius: 6px;
  background: rgba(24, 93, 190, 0.12);
}
.event-line {
  display: flex;
  align-items: center;
  gap: 10px;
}
.event-cat {
  flex: none;
  padding: 1px 8px;
  border-radius: 10px;
  font-size: 12px;
  line-height: 18px;
}
.cat-hidden {
  color: #4ee4ff;
  background: rgba(78, 228, 255, 0.15);
}
.cat-accident {
  color: #ff7f97;
  background: rgba(255, 127, 151, 0.15);
}
.cat-material {
  color: #ffce64;
  background: rgba(255, 206, 100, 0.15);
}
.event-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 15px;
  color: #fffffe;
}
.event-status {
  flex: none;
  font-size: 13px;
  color: rgba(208, 231, 255, 0.75);
}
.event-meta {
  display: flex;
  justify-content: space-between;
  margin-top: 4px;
  padding-left: 2px;
  font-size: 12px;
  color: rgba(184, 200, 224, 0.7);
}
.event-empty {
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 15px;
  color: #8aa4c8;
}
</style>
src/views/reportAnalysis/safetyDashboard/components/left-bottom.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,206 @@
<template>
  <div>
    <PanelHeader title="近6个月隐患排查与整改趋势" />
    <div class="panel-item-customers">
      <Echarts
        ref="chart"
        :chartStyle="chartStyle"
        :grid="grid"
        :legend="legend"
        :series="series"
        :tooltip="tooltip"
        :xAxis="xAxis"
        :yAxis="yAxis"
        :options="chartOptions"
        style="height: 380px"
      />
      <div v-if="showEmpty" class="empty-tip">近6个月暂无隐患数据</div>
      <span v-if="refreshFailed" class="refresh-error">刷新失败</span>
    </div>
  </div>
</template>
<script setup>
import { ref, computed, onMounted, inject, watch } from 'vue'
import Echarts from '@/components/Echarts/echarts.vue'
import PanelHeader from '@/views/reportAnalysis/dataDashboard/components/PanelHeader.vue'
import { getHiddenTrend } from '@/api/reportAnalysis/safetyDashboard.js'
const chart = ref(null)
const trendData = ref([])
const loaded = ref(false)
const loading = ref(false)
const refreshFailed = ref(false)
const chartStyle = {
  width: '100%',
  height: '100%'
}
const chartOptions = {
  backgroundColor: 'transparent',
  textStyle: { color: '#B8C8E0' }
}
const showEmpty = computed(() => {
  if (!loaded.value || loading.value) return false
  return trendData.value.every(
    (item) => !Number(item.reportedCount) && !Number(item.completedCount)
  )
})
const categories = computed(() =>
  trendData.value.map((item) => {
    const month = String(item.month || '')
    return month.length >= 7 ? `${month.slice(5)}月` : month
  })
)
const series = computed(() => [
  {
    name: '上报数',
    type: 'bar',
    barWidth: 18,
    itemStyle: {
      color: {
        type: 'linear',
        x: 0,
        y: 1,
        x2: 0,
        y2: 0,
        colorStops: [
          { offset: 0, color: 'rgba(0,164,237,0)' },
          { offset: 1, color: '#4EE4FF' }
        ]
      }
    },
    data: trendData.value.map((item) => Number(item.reportedCount) || 0)
  },
  {
    name: '整改完成数',
    type: 'bar',
    barWidth: 18,
    itemStyle: {
      color: {
        type: 'linear',
        x: 0,
        y: 1,
        x2: 0,
        y2: 0,
        colorStops: [
          { offset: 0, color: 'rgba(38,255,203,0)' },
          { offset: 1, color: '#26FFCB' }
        ]
      }
    },
    data: trendData.value.map((item) => Number(item.completedCount) || 0)
  }
])
const legend = {
  show: true,
  right: '4%',
  top: '0%',
  itemWidth: 10,
  itemHeight: 10,
  icon: 'circle',
  textStyle: { color: '#B8C8E0', fontSize: 13 },
  data: ['上报数', '整改完成数']
}
const grid = {
  left: '3%',
  right: '4%',
  bottom: '3%',
  top: '18%',
  containLabel: true
}
const tooltip = {
  trigger: 'axis',
  axisPointer: { type: 'shadow' },
  formatter: function (params) {
    let result = `${params[0].axisValueLabel}<br/>`
    params.forEach((item) => {
      result += `<div>${item.marker} ${item.seriesName}:${item.value} é¡¹</div>`
    })
    return result
  }
}
const xAxis = computed(() => [
  {
    type: 'category',
    axisTick: { show: false },
    axisLine: { lineStyle: { color: 'rgba(255,255,255,0.2)' } },
    axisLabel: { color: '#B8C8E0' },
    data: categories.value
  }
])
const yAxis = [
  {
    type: 'value',
    minInterval: 1,
    axisLabel: { color: '#B8C8E0' },
    splitLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } }
  }
]
const loadData = async () => {
  if (loading.value) return
  loading.value = true
  try {
    const res = await getHiddenTrend()
    trendData.value = Array.isArray(res?.data) ? res.data : []
    refreshFailed.value = false
    loaded.value = true
  } catch (e) {
    console.error('获取隐患趋势失败:', e)
    refreshFailed.value = true
  } finally {
    loading.value = false
  }
}
const safetyDashboardRefreshTick = inject('safetyDashboardRefreshTick', null)
if (safetyDashboardRefreshTick) {
  watch(safetyDashboardRefreshTick, () => {
    loadData()
  })
}
onMounted(() => {
  loadData()
})
</script>
<style scoped>
.panel-item-customers {
  position: relative;
  border: 1px solid #1a58b0;
  padding: 18px;
  width: 100%;
  height: 478px;
  box-sizing: border-box;
}
.empty-tip {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  text-align: center;
  font-size: 16px;
  color: #8aa4c8;
  pointer-events: none;
}
.refresh-error {
  position: absolute;
  right: 16px;
  top: 8px;
  font-size: 12px;
  color: #ff9a9a;
}
</style>
src/views/reportAnalysis/safetyDashboard/components/left-top.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,241 @@
<template>
  <div>
    <PanelHeader title="隐患类型分布" />
    <div class="panel-item-customers">
      <div class="pie-chart-wrapper" ref="pieWrapperRef">
        <div class="pie-background" ref="pieBackgroundRef"></div>
        <Echarts
          ref="chart"
          :chartStyle="chartStyle"
          :legend="pieLegend"
          :series="pieSeries"
          :tooltip="pieTooltip"
          :color="pieColors"
          :options="pieOptions"
          style="height: 100%"
        />
        <div v-if="showEmpty" class="empty-tip">暂无数据</div>
      </div>
      <span v-if="refreshFailed" class="refresh-error">刷新失败</span>
    </div>
  </div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, inject, watch } from 'vue'
import Echarts from '@/components/Echarts/echarts.vue'
import PanelHeader from '@/views/reportAnalysis/dataDashboard/components/PanelHeader.vue'
import { useChartBackground } from '@/hooks/useChartBackground.js'
import { getHiddenTypeDistribution } from '@/api/reportAnalysis/safetyDashboard.js'
const pieWrapperRef = ref(null)
const pieBackgroundRef = ref(null)
const chart = ref(null)
const dataList = ref([])
const loaded = ref(false)
const loading = ref(false)
const refreshFailed = ref(false)
const pieColors = ['#26FFCB', '#24CBFF', '#35FBF4', '#2651FF', '#D1E4F5', '#5782F7', '#2F67EF', '#82BAFF']
const showEmpty = computed(() => loaded.value && !loading.value && dataList.value.length === 0)
const dotRich = pieColors.reduce((acc, color, idx) => {
  acc[`dot${idx}`] = {
    width: 8,
    height: 8,
    borderRadius: 8,
    backgroundColor: color,
    align: 'center'
  }
  return acc
}, {})
const pieLegend = {
  show: false,
  data: []
}
const pieTooltip = {
  trigger: 'item',
  formatter: function (params) {
    return `${params.name}:${params.value} é¡¹ï¼ˆ${params.percent}%)`
  }
}
const pieSeries = computed(() => {
  if (!dataList.value.length) return []
  return [
    {
      name: '隐患类型',
      type: 'pie',
      radius: ['35%', '55%'],
      center: ['50%', '50%'],
      label: {
        show: true,
        position: 'outside',
        color: '#fff',
        fontSize: 12,
        lineHeight: 18,
        rich: {
          ...dotRich,
          parent: { fontSize: 14, fontWeight: 600, color: '#fff', lineHeight: 20, overflow: 'break' },
          child: { fontSize: 12, color: '#fff', lineHeight: 18 }
        },
        formatter: function (params) {
          const children = params?.data?.children || []
          const parentName = params?.data?.name || ''
          const parentValue = Number(params?.data?.value) || 0
          const dotKey = `dot${(params?.dataIndex || 0) % pieColors.length}`
          const parentLine = `{${dotKey}|} {parent|${parentName} (${parentValue}项)}`
          if (!children.length) return parentLine
          const childLines = children.map(
            (c) => `{child|${c.name} ${Number(c.value) || 0}项}`
          )
          return [parentLine, ...childLines].join('\n')
        }
      },
      labelLine: {
        show: true,
        length: 20,
        length2: 20,
        lineStyle: {
          color: '#B8C8E0'
        }
      },
      itemStyle: {
        color: function (params) {
          return pieColors[params.dataIndex % pieColors.length]
        }
      },
      data: dataList.value
    },
    {
      type: 'pie',
      radius: ['35%', '40%'],
      center: ['50%', '50%'],
      silent: true,
      label: { show: false },
      labelLine: { show: false },
      itemStyle: {
        color: 'rgba(0, 127, 255, 0.25)'
      },
      data: [1]
    }
  ]
})
const chartStyle = {
  width: '100%',
  height: '126%'
}
const pieOptions = {
  backgroundColor: 'transparent',
  textStyle: { color: '#B8C8E0' }
}
const { adjustBackgroundPosition, init: initBackground, cleanup: cleanupBackground } = useChartBackground({
  wrapperRef: pieWrapperRef,
  backgroundRef: pieBackgroundRef,
  offsetX: '-51.5%',
  offsetY: '-39%',
  watchData: dataList
})
const loadData = async () => {
  if (loading.value) return
  loading.value = true
  try {
    const res = await getHiddenTypeDistribution()
    const items = res?.data || []
    dataList.value = items.map((it) => ({
      name: it.name || '未知类型',
      value: Number(it.value) || 0,
      rate: it.rate,
      children: Array.isArray(it.children) ? it.children.map((c) => ({
        name: c.name,
        value: Number(c.value) || 0,
        rate: c.rate
      })) : []
    }))
    refreshFailed.value = false
    loaded.value = true
    adjustBackgroundPosition()
  } catch (e) {
    console.error('获取隐患类型分布失败:', e)
    refreshFailed.value = true
  } finally {
    loading.value = false
  }
}
const safetyDashboardRefreshTick = inject('safetyDashboardRefreshTick', null)
if (safetyDashboardRefreshTick) {
  watch(safetyDashboardRefreshTick, () => {
    loadData()
  })
}
onMounted(() => {
  loadData()
  initBackground()
})
onBeforeUnmount(() => {
  cleanupBackground()
})
</script>
<style scoped>
.panel-item-customers {
  position: relative;
  border: 1px solid #1a58b0;
  padding: 18px;
  width: 100%;
  height: 420px;
  box-sizing: border-box;
}
.pie-chart-wrapper {
  position: relative;
  width: 100%;
  height: 320px;
  background: transparent;
}
.pie-background {
  position: absolute;
  width: 360px;
  height: 360px;
  background-image: url('@/assets/BI/玫瑰图边框.png');
  background-size: contain;
  background-position: center;
  background-repeat: no-repeat;
  z-index: 1;
  pointer-events: none;
  left: 50%;
  top: 50%;
  transform: translate(-51.5%, -39%);
}
.empty-tip {
  position: absolute;
  left: 0;
  right: 0;
  top: 48%;
  text-align: center;
  font-size: 16px;
  color: #8aa4c8;
  pointer-events: none;
}
.refresh-error {
  position: absolute;
  right: 16px;
  top: 8px;
  font-size: 12px;
  color: #ff9a9a;
}
</style>
src/views/reportAnalysis/safetyDashboard/components/right-bottom.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,275 @@
<template>
  <div>
    <PanelHeader title="隐患整改责任人排名" />
    <div class="panel-item-customers">
      <Echarts
        ref="chart"
        :chartStyle="chartStyle"
        :grid="grid"
        :series="series"
        :tooltip="tooltip"
        :xAxis="xAxis"
        :yAxis="yAxis"
        :options="chartOptions"
        style="height: 390px"
      />
      <div v-if="showEmpty" class="empty-tip">暂无数据</div>
      <span v-if="refreshFailed" class="refresh-error">刷新失败</span>
    </div>
  </div>
</template>
<script setup>
import { ref, computed, onMounted, inject, watch } from 'vue'
import Echarts from '@/components/Echarts/echarts.vue'
import PanelHeader from '@/views/reportAnalysis/dataDashboard/components/PanelHeader.vue'
import { getHiddenRectifyRanking } from '@/api/reportAnalysis/safetyDashboard.js'
const chart = ref(null)
const rows = ref([])
const loaded = ref(false)
const loading = ref(false)
const refreshFailed = ref(false)
const aircraft =
  'path://M107.000,71.000 C104.936,71.000 102.665,70.806 100.273,70.467 C94.592,76.922 86.275,81.000 77.000,81.000 C70.794,81.000 65.020,79.170 60.172,76.029 C66.952,74.165 72.647,69.714 76.173,63.817 C69.821,61.362 64.063,58.593 60.000,56.039 L60.000,52.813 C70.456,53.950 80.723,55.000 83.000,55.000 C88.972,55.000 93.000,53.723 93.000,50.000 C93.000,47.071 89.222,45.000 83.000,45.000 C80.723,45.000 70.456,46.050 60.000,47.187 L60.000,43.989 C64.057,41.431 69.807,38.644 76.168,36.173 C72.641,30.281 66.948,25.834 60.172,23.971 C65.020,20.830 70.794,19.000 77.000,19.000 C86.270,19.000 94.584,23.074 100.265,29.524 C102.647,29.191 104.918,29.000 107.000,29.000 C129.644,29.000 148.000,50.000 148.000,50.000 C148.000,50.000 129.644,71.000 107.000,71.000 ZM113.000,38.000 C106.373,38.000 101.000,43.373 101.000,50.000 C101.000,56.627 106.373,62.000 113.000,62.000 C119.627,62.000 125.000,56.627 125.000,50.000 C125.000,43.373 119.627,38.000 113.000,38.000 ZM113.000,56.000 C109.686,56.000 107.000,53.314 107.000,50.000 C107.000,46.686 109.686,44.000 113.000,44.000 C116.314,44.000 119.000,46.686 119.000,50.000 C119.000,53.314 116.314,56.000 113.000,56.000 ZM110.500,19.000 C109.567,19.000 108.763,18.483 108.334,17.726 C100.231,9.857 89.187,5.000 77.000,5.000 C64.813,5.000 53.769,9.857 45.666,17.726 C45.237,18.483 44.433,19.000 43.500,19.000 C42.119,19.000 41.000,17.881 41.000,16.500 C41.000,15.847 41.256,15.259 41.665,14.813 L41.575,14.718 C50.629,5.628 63.156,-0.000 77.000,-0.000 C90.844,-0.000 103.371,5.628 112.425,14.718 L112.335,14.813 C112.744,15.259 113.000,15.847 113.000,16.500 C113.000,17.881 111.881,19.000 110.500,19.000 ZM53.000,49.484 C61.406,48.626 77.810,47.000 81.345,47.000 C87.353,47.000 91.000,48.243 91.000,50.000 C91.000,52.234 87.111,53.000 81.345,53.000 C77.810,53.000 61.406,51.374 53.000,50.516 L53.000,49.484 ZM53.000,47.000 L9.000,50.000 L53.000,53.000 L53.000,56.000 L-0.000,50.000 L53.000,44.000 L53.000,47.000 ZM43.500,81.000 C44.433,81.000 45.237,81.517 45.666,82.274 C53.769,90.143 64.813,95.000 77.000,95.000 C89.187,95.000 100.231,90.143 108.334,82.274 C108.763,81.517 109.567,81.000 110.500,81.000 C111.881,81.000 113.000,82.119 113.000,83.500 C113.000,84.153 112.744,84.741 112.335,85.187 L112.425,85.282 C103.371,94.372 90.844,100.000 77.000,100.000 C63.156,100.000 50.629,94.372 41.575,85.282 L41.665,85.187 C41.256,84.741 41.000,84.153 41.000,83.500 C41.000,82.119 42.119,81.000 43.500,81.000 Z'
const color = {
  0: '#ff5676',
  1: '#ffd83e',
  2: '#fbff94',
  3: '#7daeff'
}
const chartStyle = {
  width: '100%',
  height: '100%'
}
const chartOptions = {
  backgroundColor: 'transparent',
  textStyle: { color: '#B8C8E0' }
}
const sortedRows = computed(() =>
  [...rows.value].sort((a, b) => (Number(a.value) || 0) - (Number(b.value) || 0))
)
const showEmpty = computed(
  () => loaded.value && !loading.value && rows.value.length === 0
)
const dataNum = computed(() =>
  sortedRows.value.map((item, i) => {
    const index = sortedRows.value.length - i - 1
    const isTop3 = index < 3
    return {
      value: Number(item.value) || 0,
      itemStyle: {
        color: {
          type: 'linear',
          x: 1,
          y: 0,
          x2: 0,
          y2: 0,
          colorStops: [
            { offset: 0, color: isTop3 ? '#ffdae1' : '#ecf3ff' },
            { offset: 0.07, color: isTop3 ? color[index] : color[3] },
            { offset: 1, color: isTop3 ? 'rgba(255, 86, 118, .1)' : 'rgba(125,174,255, .1)' }
          ],
          global: false
        },
        barBorderRadius: [0, 20, 20, 0]
      },
      symbol: isTop3 ? aircraft : 'none',
      symbolPosition: 'end',
      symbolSize: [30, 25],
      symbolOffset: [35, 0]
    }
  })
)
const bgData = computed(() => {
  const maxValue = Math.max(1, ...dataNum.value.map((v) => v.value))
  return dataNum.value.map(() => maxValue * 1.25)
})
const tooltip = computed(() => ({
  trigger: 'axis',
  textStyle: { fontSize: '100%' },
  formatter: function (params) {
    const index = params[0]?.dataIndex ?? 0
    const row = sortedRows.value[index] || {}
    return `${params[0]?.axisValueLabel || ''}<br/>已整改:${row.value ?? 0} é¡¹<br/>占比:${row.rate ?? '0.00'}%`
  }
}))
const grid = { top: 0, left: '22%', right: '12%', bottom: 0 }
const xAxis = [
  {
    splitLine: { show: false },
    axisLine: { show: false },
    axisLabel: { show: false },
    axisTick: { show: false }
  }
]
const yAxis = computed(() => [
  {
    type: 'category',
    data: sortedRows.value.map((item) => item.name),
    axisLabel: {
      formatter: (value) => {
        if (!value) return ''
        const maxLen = 6
        if (value.length <= maxLen) return `{a|${value}}`
        const lines = []
        for (let i = 0; i < value.length; i += maxLen) {
          lines.push(value.slice(i, i + maxLen))
        }
        return lines.map((line) => `{a|${line}}`).join('\n')
      },
      rich: {
        a: {
          width: 120,
          fontSize: 14,
          color: '#fff',
          padding: [5, 4, 5, 0],
          align: 'right'
        }
      }
    },
    axisLine: { show: false },
    axisTick: { show: false },
    splitLine: { show: false }
  },
  {
    type: 'category',
    data: sortedRows.value.map((item) => Number(item.value) || 0),
    axisLabel: {
      formatter: (params, index) => {
        const row = sortedRows.value[index] || {}
        const rank = sortedRows.value.length - index
        const isTop3 = rank < 4
        return `{a${isTop3 ? rank : ''}|${row.rate ?? '0.00'} }{b${isTop3 ? rank : ''}|%}`
      },
      rich: {
        a: { fontSize: 18, color: '#98bfff', verticalAlign: 'bottom' },
        a1: { fontSize: 18, color: '#ff7f97', verticalAlign: 'bottom' },
        a2: { fontSize: 18, color: '#ffce64', verticalAlign: 'bottom' },
        a3: { fontSize: 18, color: '#e8ed66', verticalAlign: 'bottom' },
        b: { fontSize: 12, color: '#98bfff', verticalAlign: 'bottom' },
        b1: { fontSize: 12, color: '#ff7f97', verticalAlign: 'bottom' },
        b2: { fontSize: 12, color: '#ffce64', verticalAlign: 'bottom' },
        b3: { fontSize: 12, color: '#e8ed66', verticalAlign: 'bottom' }
      }
    },
    axisLine: { show: false },
    axisTick: { show: false },
    splitLine: { show: false }
  }
])
const series = computed(() => [
  {
    name: '已整改',
    z: 6,
    type: 'pictorialBar',
    data: dataNum.value
  },
  {
    name: '背景',
    z: 6,
    type: 'bar',
    barWidth: 22,
    tooltip: { show: false },
    itemStyle: {
      color: 'rgba(255,255,255,.1)',
      barBorderRadius: [0, 20, 20, 0]
    },
    data: bgData.value
  },
  {
    name: '已整改渐变',
    type: 'bar',
    barWidth: 22,
    barGap: '-100%',
    tooltip: { show: false },
    itemStyle: {
      color: {
        type: 'linear',
        x: 1,
        y: 0,
        x2: 0,
        y2: 0,
        colorStops: [
          { offset: 0, color: 'rgba(255, 218, 220)' },
          { offset: 0.07, color: 'rgba(255, 86, 118)' },
          { offset: 1, color: 'rgba(255, 86, 118, 0)' }
        ],
        global: false
      },
      barBorderRadius: [0, 20, 20, 0]
    },
    data: dataNum.value
  }
])
const loadData = async () => {
  if (loading.value) return
  loading.value = true
  try {
    const res = await getHiddenRectifyRanking({ limit: 5 })
    rows.value = Array.isArray(res?.data) ? res.data : []
    refreshFailed.value = false
    loaded.value = true
  } catch (e) {
    console.error('获取隐患整改责任人排名失败:', e)
    refreshFailed.value = true
  } finally {
    loading.value = false
  }
}
const safetyDashboardRefreshTick = inject('safetyDashboardRefreshTick', null)
if (safetyDashboardRefreshTick) {
  watch(safetyDashboardRefreshTick, () => {
    loadData()
  })
}
onMounted(() => {
  loadData()
})
</script>
<style scoped>
.panel-item-customers {
  position: relative;
  border: 1px solid #1a58b0;
  padding: 18px;
  width: 100%;
  height: 449px;
  box-sizing: border-box;
}
.empty-tip {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  text-align: center;
  font-size: 16px;
  color: #8aa4c8;
  pointer-events: none;
}
.refresh-error {
  position: absolute;
  right: 16px;
  top: 8px;
  font-size: 12px;
  color: #ff9a9a;
}
</style>
src/views/reportAnalysis/safetyDashboard/components/right-top.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,275 @@
<template>
  <div>
    <PanelHeader title="危险源类型排名" />
    <div class="panel-item-customers">
      <Echarts
        ref="chart"
        :chartStyle="chartStyle"
        :grid="grid"
        :series="series"
        :tooltip="tooltip"
        :xAxis="xAxis"
        :yAxis="yAxis"
        :options="chartOptions"
        style="height: 390px"
      />
      <div v-if="showEmpty" class="empty-tip">暂无数据</div>
      <span v-if="refreshFailed" class="refresh-error">刷新失败</span>
    </div>
  </div>
</template>
<script setup>
import { ref, computed, onMounted, inject, watch } from 'vue'
import Echarts from '@/components/Echarts/echarts.vue'
import PanelHeader from '@/views/reportAnalysis/dataDashboard/components/PanelHeader.vue'
import { getHazardTypeRanking } from '@/api/reportAnalysis/safetyDashboard.js'
const chart = ref(null)
const rows = ref([])
const loaded = ref(false)
const loading = ref(false)
const refreshFailed = ref(false)
const aircraft =
  'path://M107.000,71.000 C104.936,71.000 102.665,70.806 100.273,70.467 C94.592,76.922 86.275,81.000 77.000,81.000 C70.794,81.000 65.020,79.170 60.172,76.029 C66.952,74.165 72.647,69.714 76.173,63.817 C69.821,61.362 64.063,58.593 60.000,56.039 L60.000,52.813 C70.456,53.950 80.723,55.000 83.000,55.000 C88.972,55.000 93.000,53.723 93.000,50.000 C93.000,47.071 89.222,45.000 83.000,45.000 C80.723,45.000 70.456,46.050 60.000,47.187 L60.000,43.989 C64.057,41.431 69.807,38.644 76.168,36.173 C72.641,30.281 66.948,25.834 60.172,23.971 C65.020,20.830 70.794,19.000 77.000,19.000 C86.270,19.000 94.584,23.074 100.265,29.524 C102.647,29.191 104.918,29.000 107.000,29.000 C129.644,29.000 148.000,50.000 148.000,50.000 C148.000,50.000 129.644,71.000 107.000,71.000 ZM113.000,38.000 C106.373,38.000 101.000,43.373 101.000,50.000 C101.000,56.627 106.373,62.000 113.000,62.000 C119.627,62.000 125.000,56.627 125.000,50.000 C125.000,43.373 119.627,38.000 113.000,38.000 ZM113.000,56.000 C109.686,56.000 107.000,53.314 107.000,50.000 C107.000,46.686 109.686,44.000 113.000,44.000 C116.314,44.000 119.000,46.686 119.000,50.000 C119.000,53.314 116.314,56.000 113.000,56.000 ZM110.500,19.000 C109.567,19.000 108.763,18.483 108.334,17.726 C100.231,9.857 89.187,5.000 77.000,5.000 C64.813,5.000 53.769,9.857 45.666,17.726 C45.237,18.483 44.433,19.000 43.500,19.000 C42.119,19.000 41.000,17.881 41.000,16.500 C41.000,15.847 41.256,15.259 41.665,14.813 L41.575,14.718 C50.629,5.628 63.156,-0.000 77.000,-0.000 C90.844,-0.000 103.371,5.628 112.425,14.718 L112.335,14.813 C112.744,15.259 113.000,15.847 113.000,16.500 C113.000,17.881 111.881,19.000 110.500,19.000 ZM53.000,49.484 C61.406,48.626 77.810,47.000 81.345,47.000 C87.353,47.000 91.000,48.243 91.000,50.000 C91.000,52.234 87.111,53.000 81.345,53.000 C77.810,53.000 61.406,51.374 53.000,50.516 L53.000,49.484 ZM53.000,47.000 L9.000,50.000 L53.000,53.000 L53.000,56.000 L-0.000,50.000 L53.000,44.000 L53.000,47.000 ZM43.500,81.000 C44.433,81.000 45.237,81.517 45.666,82.274 C53.769,90.143 64.813,95.000 77.000,95.000 C89.187,95.000 100.231,90.143 108.334,82.274 C108.763,81.517 109.567,81.000 110.500,81.000 C111.881,81.000 113.000,82.119 113.000,83.500 C113.000,84.153 112.744,84.741 112.335,85.187 L112.425,85.282 C103.371,94.372 90.844,100.000 77.000,100.000 C63.156,100.000 50.629,94.372 41.575,85.282 L41.665,85.187 C41.256,84.741 41.000,84.153 41.000,83.500 C41.000,82.119 42.119,81.000 43.500,81.000 Z'
const color = {
  0: '#ff5676',
  1: '#ffd83e',
  2: '#fbff94',
  3: '#7daeff'
}
const chartStyle = {
  width: '100%',
  height: '100%'
}
const chartOptions = {
  backgroundColor: 'transparent',
  textStyle: { color: '#B8C8E0' }
}
const sortedRows = computed(() =>
  [...rows.value].sort((a, b) => (Number(a.value) || 0) - (Number(b.value) || 0))
)
const showEmpty = computed(
  () => loaded.value && !loading.value && rows.value.length === 0
)
const dataNum = computed(() =>
  sortedRows.value.map((item, i) => {
    const index = sortedRows.value.length - i - 1
    const isTop3 = index < 3
    return {
      value: Number(item.value) || 0,
      itemStyle: {
        color: {
          type: 'linear',
          x: 1,
          y: 0,
          x2: 0,
          y2: 0,
          colorStops: [
            { offset: 0, color: isTop3 ? '#ffdae1' : '#ecf3ff' },
            { offset: 0.07, color: isTop3 ? color[index] : color[3] },
            { offset: 1, color: isTop3 ? 'rgba(255, 86, 118, .1)' : 'rgba(125,174,255, .1)' }
          ],
          global: false
        },
        barBorderRadius: [0, 20, 20, 0]
      },
      symbol: isTop3 ? aircraft : 'none',
      symbolPosition: 'end',
      symbolSize: [30, 25],
      symbolOffset: [35, 0]
    }
  })
)
const bgData = computed(() => {
  const maxValue = Math.max(1, ...dataNum.value.map((v) => v.value))
  return dataNum.value.map(() => maxValue * 1.25)
})
const tooltip = computed(() => ({
  trigger: 'axis',
  textStyle: { fontSize: '100%' },
  formatter: function (params) {
    const index = params[0]?.dataIndex ?? 0
    const row = sortedRows.value[index] || {}
    return `${params[0]?.axisValueLabel || ''}<br/>数量:${row.value ?? 0} å¤„<br/>占比:${row.rate ?? '0.00'}%`
  }
}))
const grid = { top: 0, left: '22%', right: '12%', bottom: 0 }
const xAxis = [
  {
    splitLine: { show: false },
    axisLine: { show: false },
    axisLabel: { show: false },
    axisTick: { show: false }
  }
]
const yAxis = computed(() => [
  {
    type: 'category',
    data: sortedRows.value.map((item) => item.name),
    axisLabel: {
      formatter: (value) => {
        if (!value) return ''
        const maxLen = 6
        if (value.length <= maxLen) return `{a|${value}}`
        const lines = []
        for (let i = 0; i < value.length; i += maxLen) {
          lines.push(value.slice(i, i + maxLen))
        }
        return lines.map((line) => `{a|${line}}`).join('\n')
      },
      rich: {
        a: {
          width: 120,
          fontSize: 14,
          color: '#fff',
          padding: [5, 4, 5, 0],
          align: 'right'
        }
      }
    },
    axisLine: { show: false },
    axisTick: { show: false },
    splitLine: { show: false }
  },
  {
    type: 'category',
    data: sortedRows.value.map((item) => Number(item.value) || 0),
    axisLabel: {
      formatter: (params, index) => {
        const row = sortedRows.value[index] || {}
        const rank = sortedRows.value.length - index
        const isTop3 = rank < 4
        return `{a${isTop3 ? rank : ''}|${row.rate ?? '0.00'} }{b${isTop3 ? rank : ''}|%}`
      },
      rich: {
        a: { fontSize: 18, color: '#98bfff', verticalAlign: 'bottom' },
        a1: { fontSize: 18, color: '#ff7f97', verticalAlign: 'bottom' },
        a2: { fontSize: 18, color: '#ffce64', verticalAlign: 'bottom' },
        a3: { fontSize: 18, color: '#e8ed66', verticalAlign: 'bottom' },
        b: { fontSize: 12, color: '#98bfff', verticalAlign: 'bottom' },
        b1: { fontSize: 12, color: '#ff7f97', verticalAlign: 'bottom' },
        b2: { fontSize: 12, color: '#ffce64', verticalAlign: 'bottom' },
        b3: { fontSize: 12, color: '#e8ed66', verticalAlign: 'bottom' }
      }
    },
    axisLine: { show: false },
    axisTick: { show: false },
    splitLine: { show: false }
  }
])
const series = computed(() => [
  {
    name: '数量',
    z: 6,
    type: 'pictorialBar',
    data: dataNum.value
  },
  {
    name: '背景',
    z: 6,
    type: 'bar',
    barWidth: 22,
    tooltip: { show: false },
    itemStyle: {
      color: 'rgba(255,255,255,.1)',
      barBorderRadius: [0, 20, 20, 0]
    },
    data: bgData.value
  },
  {
    name: '数量渐变',
    type: 'bar',
    barWidth: 22,
    barGap: '-100%',
    tooltip: { show: false },
    itemStyle: {
      color: {
        type: 'linear',
        x: 1,
        y: 0,
        x2: 0,
        y2: 0,
        colorStops: [
          { offset: 0, color: 'rgba(255, 218, 220)' },
          { offset: 0.07, color: 'rgba(255, 86, 118)' },
          { offset: 1, color: 'rgba(255, 86, 118, 0)' }
        ],
        global: false
      },
      barBorderRadius: [0, 20, 20, 0]
    },
    data: dataNum.value
  }
])
const loadData = async () => {
  if (loading.value) return
  loading.value = true
  try {
    const res = await getHazardTypeRanking({ limit: 8 })
    rows.value = Array.isArray(res?.data) ? res.data : []
    refreshFailed.value = false
    loaded.value = true
  } catch (e) {
    console.error('获取危险源类型排名失败:', e)
    refreshFailed.value = true
  } finally {
    loading.value = false
  }
}
const safetyDashboardRefreshTick = inject('safetyDashboardRefreshTick', null)
if (safetyDashboardRefreshTick) {
  watch(safetyDashboardRefreshTick, () => {
    loadData()
  })
}
onMounted(() => {
  loadData()
})
</script>
<style scoped>
.panel-item-customers {
  position: relative;
  border: 1px solid #1a58b0;
  padding: 18px;
  width: 100%;
  height: 449px;
  box-sizing: border-box;
}
.empty-tip {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  text-align: center;
  font-size: 16px;
  color: #8aa4c8;
  pointer-events: none;
}
.refresh-error {
  position: absolute;
  right: 16px;
  top: 8px;
  font-size: 12px;
  color: #ff9a9a;
}
</style>
src/views/reportAnalysis/safetyDashboard/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,256 @@
<template>
  <div class="scale-container">
    <div class="data-dashboard" :style="{ transform: `scale(${scaleRatio})` }">
      <button class="fullscreen-btn" @click="toggleFullscreen" :title="isFullscreen ? '退出全屏' : '全屏显示'">
        <svg v-if="!isFullscreen" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/>
        </svg>
        <svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
        </svg>
      </button>
      <div class="dashboard-header">
        <div class="factory-name">安全数据驾驶舱</div>
      </div>
      <div class="dashboard-content">
        <div class="left-panel">
          <LeftTop />
          <LeftBottom />
        </div>
        <div class="center-panel">
          <CenterTop />
          <CenterBottom />
        </div>
        <div class="right-panel">
          <RightTop />
          <RightBottom />
        </div>
      </div>
    </div>
  </div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick, provide } from 'vue'
import LeftTop from './components/left-top.vue'
import LeftBottom from './components/left-bottom.vue'
import CenterTop from './components/center-top.vue'
import CenterBottom from './components/center-bottom.vue'
import RightTop from './components/right-top.vue'
import RightBottom from './components/right-bottom.vue'
const isFullscreen = ref(false)
const scaleRatio = ref(1)
const designWidth = 1920
const designHeight = 1080
const DASHBOARD_REFRESH_MS = 60 * 1000
const safetyDashboardRefreshTick = ref(0)
provide('safetyDashboardRefreshTick', safetyDashboardRefreshTick)
let dashboardPollTimer = null
const calculateScale = () => {
  const container = document.querySelector('.scale-container')
  if (!container) return
  const rect = container.getBoundingClientRect?.()
  const containerWidth = container.clientWidth || rect?.width || window.innerWidth
  const containerHeight = container.clientHeight || rect?.height || window.innerHeight
  const scaleX = containerWidth / designWidth
  const scaleY = containerHeight / designHeight
  scaleRatio.value = Math.min(scaleX, scaleY)
}
const handleResize = () => {
  setTimeout(() => {
    calculateScale()
  }, 100)
}
const toggleFullscreen = () => {
  const element = document.querySelector('.scale-container')
  if (!element) return
  if (!isFullscreen.value) {
    if (element.requestFullscreen) {
      element.requestFullscreen()
    } else if (element.webkitRequestFullscreen) {
      element.webkitRequestFullscreen()
    } else if (element.msRequestFullscreen) {
      element.msRequestFullscreen()
    }
  } else {
    if (document.exitFullscreen) {
      document.exitFullscreen()
    } else if (document.webkitExitFullscreen) {
      document.webkitExitFullscreen()
    } else if (document.msExitFullscreen) {
      document.msExitFullscreen()
    }
  }
}
const handleFullscreenChange = () => {
  const fullscreenElement =
    document.fullscreenElement ||
    document.webkitFullscreenElement ||
    document.msFullscreenElement
  isFullscreen.value = !!(fullscreenElement && fullscreenElement.classList.contains('scale-container'))
  setTimeout(() => {
    calculateScale()
  }, 200)
}
onMounted(() => {
  nextTick(() => {
    calculateScale()
  })
  window.addEventListener('resize', handleResize)
  window.addEventListener('fullscreenchange', handleFullscreenChange)
  window.addEventListener('webkitfullscreenchange', handleFullscreenChange)
  window.addEventListener('MSFullscreenChange', handleFullscreenChange)
  dashboardPollTimer = setInterval(() => {
    safetyDashboardRefreshTick.value++
  }, DASHBOARD_REFRESH_MS)
})
onBeforeUnmount(() => {
  if (dashboardPollTimer) {
    clearInterval(dashboardPollTimer)
    dashboardPollTimer = null
  }
  window.removeEventListener('resize', handleResize)
  window.removeEventListener('fullscreenchange', handleFullscreenChange)
  window.removeEventListener('webkitfullscreenchange', handleFullscreenChange)
  window.removeEventListener('MSFullscreenChange', handleFullscreenChange)
})
</script>
<style scoped>
.scale-container {
  position: relative;
  width: 100%;
  height: calc(100vh - 84px);
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #000;
  overflow: hidden;
}
.data-dashboard {
  position: relative;
  width: 1920px;
  height: 1080px;
  background-image: url("@/assets/BI/backImage@2x.png");
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  transform-origin: center center;
}
.scale-container:fullscreen {
  width: 100vw;
  height: 100vh;
  margin: 0;
  padding: 0;
  background-color: #000;
  z-index: 9999;
}
.scale-container:-webkit-full-screen {
  width: 100vw;
  height: 100vh;
  margin: 0;
  padding: 0;
  background-color: #000;
  z-index: 9999;
}
.scale-container:-ms-fullscreen {
  width: 100vw;
  height: 100vh;
  margin: 0;
  padding: 0;
  background-color: #000;
  z-index: 9999;
}
.dashboard-header {
  position: relative;
  z-index: 1;
  height: 86px;
  background-image: url("@/assets/BI/biaoti.png");
  background-size: cover;
  background-repeat: no-repeat;
  display: flex;
  align-items: center;
  justify-content: center;
}
.factory-name {
  font-weight: 600;
  font-size: 46px;
  color: #FFFFFF;
  top: 16px;
  position: absolute;
  letter-spacing: 4px;
}
.fullscreen-btn {
  position: absolute;
  top: 10px;
  left: 20px;
  width: 40px;
  height: 40px;
  background: rgba(0, 20, 60, 0.8);
  border: 1px solid rgba(0, 212, 255, 0.3);
  border-radius: 6px;
  color: #00d4ff;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.3s;
  z-index: 10000;
}
.fullscreen-btn:hover {
  background: rgba(0, 30, 90, 0.9);
  border-color: rgba(0, 212, 255, 0.5);
}
.dashboard-content {
  position: relative;
  z-index: 1;
  display: flex;
  gap: 30px;
  padding: 0 30px;
  height: calc(100% - 86px);
  overflow: hidden;
}
.left-panel,
.center-panel,
.right-panel {
  overflow: hidden;
}
.left-panel,
.right-panel {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 24px;
  width: 520px;
}
.center-panel {
  flex: 1.5;
  display: flex;
  flex-direction: column;
  gap: 20px;
}
</style>