From 6d5df8bf7ec6b35e4ccdfd4010e3add4af44e910 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期一, 26 十月 2026 21:26:02 +0800
Subject: [PATCH] feat: 新增公出申请审批、安全驾驶舱及工资发放记录查看
---
src/views/reportAnalysis/safetyDashboard/index.vue | 256 ++++++
src/views/officeProcessAutomation/AttendManage/out-apply/index.vue | 174 ++++
src/api/reportAnalysis/safetyDashboard.js | 60 +
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js | 2
src/views/reportAnalysis/safetyDashboard/components/center-top.vue | 432 +++++++++++
src/views/reportAnalysis/safetyDashboard/components/left-bottom.vue | 206 +++++
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js | 8
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js | 3
src/views/reportAnalysis/safetyDashboard/components/right-bottom.vue | 275 +++++++
src/views/reportAnalysis/safetyDashboard/components/center-bottom.vue | 217 +++++
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceListSearch.js | 1
src/views/reportAnalysis/safetyDashboard/components/right-top.vue | 275 +++++++
src/views/reportAnalysis/safetyDashboard/components/left-top.vue | 241 ++++++
src/views/personnelManagement/monthlyStatistics/index.vue | 66 +
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue | 29
15 files changed, 2,237 insertions(+), 8 deletions(-)
diff --git a/src/api/reportAnalysis/safetyDashboard.js b/src/api/reportAnalysis/safetyDashboard.js
new file mode 100644
index 0000000..8309c9d
--- /dev/null
+++ b/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
+ })
+}
+
+// 闅愭偅鏁存敼璐d换浜烘帓鍚�
+export function getHiddenRectifyRanking(params) {
+ return request({
+ url: '/safeDashboard/getHiddenRectifyRanking',
+ method: 'get',
+ params
+ })
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceListSearch.js b/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceListSearch.js
index 80dda5c..7504167 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceListSearch.js
+++ b/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 = {
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js b/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js
index fd56eca..ed61187 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js
+++ b/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",
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
index e11be26..ff02425 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
+++ b/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;
}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue b/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
index 0a495c6..55ec8be 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
+++ b/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();
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
index 79e96bc..d01534c 100644
--- a/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
+++ b/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();
}
diff --git a/src/views/officeProcessAutomation/AttendManage/out-apply/index.vue b/src/views/officeProcessAutomation/AttendManage/out-apply/index.vue
new file mode 100644
index 0000000..c434a20
--- /dev/null
+++ b/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>
diff --git a/src/views/personnelManagement/monthlyStatistics/index.vue b/src/views/personnelManagement/monthlyStatistics/index.vue
index c4f5081..54a4918 100644
--- a/src/views/personnelManagement/monthlyStatistics/index.vue
+++ b/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="鎵f鍚堣" 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="鎵f鍚堣" 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>
\ No newline at end of file
diff --git a/src/views/reportAnalysis/safetyDashboard/components/center-bottom.vue b/src/views/reportAnalysis/safetyDashboard/components/center-bottom.vue
new file mode 100644
index 0000000..9d8036b
--- /dev/null
+++ b/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>
diff --git a/src/views/reportAnalysis/safetyDashboard/components/center-top.vue b/src/views/reportAnalysis/safetyDashboard/components/center-top.vue
new file mode 100644
index 0000000..278490a
--- /dev/null
+++ b/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>
diff --git a/src/views/reportAnalysis/safetyDashboard/components/left-bottom.vue b/src/views/reportAnalysis/safetyDashboard/components/left-bottom.vue
new file mode 100644
index 0000000..6266f83
--- /dev/null
+++ b/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>
diff --git a/src/views/reportAnalysis/safetyDashboard/components/left-top.vue b/src/views/reportAnalysis/safetyDashboard/components/left-top.vue
new file mode 100644
index 0000000..7699bb7
--- /dev/null
+++ b/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>
diff --git a/src/views/reportAnalysis/safetyDashboard/components/right-bottom.vue b/src/views/reportAnalysis/safetyDashboard/components/right-bottom.vue
new file mode 100644
index 0000000..984d728
--- /dev/null
+++ b/src/views/reportAnalysis/safetyDashboard/components/right-bottom.vue
@@ -0,0 +1,275 @@
+<template>
+ <div>
+ <PanelHeader title="闅愭偅鏁存敼璐d换浜烘帓鍚�" />
+ <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('鑾峰彇闅愭偅鏁存敼璐d换浜烘帓鍚嶅け璐�:', 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>
diff --git a/src/views/reportAnalysis/safetyDashboard/components/right-top.vue b/src/views/reportAnalysis/safetyDashboard/components/right-top.vue
new file mode 100644
index 0000000..b08c194
--- /dev/null
+++ b/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>
diff --git a/src/views/reportAnalysis/safetyDashboard/index.vue b/src/views/reportAnalysis/safetyDashboard/index.vue
new file mode 100644
index 0000000..119dabb
--- /dev/null
+++ b/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>
--
Gitblit v1.9.3