feat: 新增公出申请审批、安全驾驶舱及工资发放记录查看
- 审批管理新增公出申请模块,模板列表支持按模板类型筛选
- 人员工资统计新增发放记录弹窗与发放时间列
- 新增安全驾驶舱页面及对应接口
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 |
| | | }) |
| | | } |
| | |
| | | APPROVAL_MODULE_KEYS.WORK_HANDOVER, |
| | | APPROVAL_MODULE_KEYS.LEAVE, |
| | | APPROVAL_MODULE_KEYS.OVERTIME, |
| | | APPROVAL_MODULE_KEYS.OUT_OFFICE, |
| | | ]); |
| | | |
| | | export const INSTANCE_NO_TABLE_COLUMN = { |
| | |
| | | WORK_HANDOVER: "work_handover", |
| | | LEAVE: "leave", |
| | | OVERTIME: "overtime", |
| | | OUT_OFFICE: "out_office", |
| | | TRAVEL_REIMBURSE: "travel_reimburse", |
| | | COST_REIMBURSE: "cost_reimburse", |
| | | ENTERPRISE_NEWS: "enterprise_news", |
| | |
| | | [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, |
| | |
| | | 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", |
| | |
| | | }; |
| | | 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; |
| | | } |
| | |
| | | |
| | | /> |
| | | |
| | | <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"> |
| | | |
| | | ä»
æ¾ç¤ºå¯ç¨ |
| | |
| | | import { ElMessage } from "element-plus"; |
| | | |
| | | import { computed, nextTick, onMounted, ref } from "vue"; |
| | | import { useRoute } from "vue-router"; |
| | | |
| | | import { userListNoPageByTenantId } from "@/api/system/user.js"; |
| | | |
| | |
| | | } = useApproveTemplate(); |
| | | |
| | | |
| | | |
| | | const route = useRoute(); |
| | | |
| | | const flowUserOptions = ref([]); |
| | | |
| | |
| | | |
| | | |
| | | |
| | | onMounted(() => { |
| | | onMounted(async () => { |
| | | |
| | | loadUsers(); |
| | | |
| | | loadTemplateTypeOptions(); |
| | | const queryBusinessType = route.query.businessType; |
| | | |
| | | if (queryBusinessType != null && queryBusinessType !== "") { |
| | | searchForm.businessType = Number(queryBusinessType); |
| | | } |
| | | |
| | | await loadTemplateTypeOptions(); |
| | | |
| | | fetchTemplateList(); |
| | | |
| | |
| | | |
| | | const searchForm = reactive({ |
| | | keyword: "", |
| | | businessType: "", |
| | | enabledOnly: false, |
| | | }); |
| | | |
| | |
| | | |
| | | function resetSearch() { |
| | | searchForm.keyword = ""; |
| | | searchForm.businessType = ""; |
| | | searchForm.enabledOnly = false; |
| | | handleQuery(); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!--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> |
| | |
| | | <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> |
| | |
| | | }, |
| | | { 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 }, |
| | | { |
| | |
| | | label: "æä½", |
| | | align: "center", |
| | | fixed: "right", |
| | | width: 180, |
| | | width: 240, |
| | | operation: [ |
| | | { |
| | | name: "æ¥çæç»", |
| | |
| | | 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), |
| | | }, |
| | | ], |
| | | }, |
| | |
| | | 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) => { |
| | |
| | | margin-bottom: 12px; |
| | | font-weight: 600; |
| | | } |
| | | .issue-record-title { |
| | | margin: 16px 0 8px; |
| | | font-weight: 600; |
| | | font-size: 14px; |
| | | } |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |