vue3 升级
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# 仅供本地 Vite 代理读取,不会通过 VITE_ 前缀打包到浏览器。
|
||||
QHSE_API_TOKEN='AF17168B-87BD-4GLY-1111-F0A0A1158F9B'
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
# 数据治理看板 Vue3 版
|
||||
|
||||
本目录是原版数据治理看板的 Vue3 升级版本。原版 `indexV2.html`、`res/` 和 `server.js` 保持不变,Vue3 版可独立开发和构建,并保持既有接口参数、业务口径、外部页签参数和 1920×1080 大屏布局。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 3 + TypeScript + Vite:组件化开发、类型检查和静态构建。
|
||||
- Pinia:集中管理筛选条件、监控数据、项目详情和穿透弹窗状态。
|
||||
- Vue Router:集团、公司、项目三级哈希路由。
|
||||
- ECharts:柱状图和折线图按需加载,Canvas 渲染。
|
||||
- Vitest:数据适配和业务口径单元测试。
|
||||
|
||||
## 项目架构
|
||||
|
||||
### 分层关系
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[App 应用壳层] --> B[Router 路由页面]
|
||||
B --> C[Pinia Dashboard Store]
|
||||
C --> D[HTTP Service]
|
||||
D --> E[同源网关 /qhse_webapi]
|
||||
E --> F[数据治理接口]
|
||||
C --> G[Adapter 数据适配]
|
||||
G --> H[稳定 ViewModel]
|
||||
H --> B
|
||||
B --> I[业务组件]
|
||||
I --> J[ECharts 生命周期组件]
|
||||
```
|
||||
|
||||
数据读取遵循单向链路:页面触发 Store Action,Service 请求接口,Adapter 将原始响应转换为 ViewModel,Store 更新状态后由 Vue 响应式刷新页面。页面组件不直接兼容后端字段别名,也不直接持有认证令牌。
|
||||
|
||||
### 目录职责
|
||||
|
||||
```text
|
||||
next/
|
||||
├─ src/
|
||||
│ ├─ adapters/ 接口原始数据到 ViewModel 的转换及口径测试
|
||||
│ ├─ components/ KPI、指标列表、ECharts 和全局穿透弹窗
|
||||
│ ├─ config/ 接口路径、外链、系统枚举和报告文件映射
|
||||
│ ├─ router/ 集团、公司、项目三级路由
|
||||
│ ├─ services/ 请求参数、超时、取消、请求 ID 和错误处理
|
||||
│ ├─ stores/ 筛选、监控数据、项目数据和弹窗状态
|
||||
│ ├─ styles/ Vue3 版增量样式
|
||||
│ ├─ types/ API、ViewModel、筛选和弹窗类型
|
||||
│ ├─ views/ 集团级、公司级、项目级页面
|
||||
│ ├─ App.vue 大屏壳层、筛选、面包屑和缩放
|
||||
│ └─ main.ts Vue、Pinia、Router 和样式入口
|
||||
├─ vite.config.ts 开发服务器和接口代理
|
||||
├─ tsconfig.json TypeScript 配置
|
||||
└─ package.json 依赖和工程命令
|
||||
```
|
||||
|
||||
`main.ts` 会引入上级 `res/css/` 中的原版模块化样式作为视觉基线,Vite 构建时会将这些 CSS 合并进 `dist/assets/`。Vue3 专属修正放在 `src/styles/next.css`,禁止修改或重新聚合原版 CSS 文件。
|
||||
|
||||
### 页面与路由
|
||||
|
||||
| 层级 | 路由 | 页面职责 |
|
||||
|------|------|----------|
|
||||
| 集团级 | `#/` | 集团 KPI、重点指标、三率趋势、企业分析、报告入口、指标穿透 |
|
||||
| 公司级 | `#/enterprise/:unitId` | 公司 KPI、三率趋势、项目分析、公司指标穿透 |
|
||||
| 项目级 | `#/project/:unitId/:projectId` | 项目信息、指标历史值、异常状态 |
|
||||
|
||||
外部系统的页签参数位于哈希前,例如 `?type=1,2#/`。参数映射保持原规则:`1` 为安全、`2` 为质量、`3` 为应急;未传或传入非法值时展示全部系统。
|
||||
|
||||
### 状态管理
|
||||
|
||||
`stores/dashboard.ts` 是唯一业务状态入口,主要状态分为:
|
||||
|
||||
- 筛选状态:`system`、`dimension`、`allowedSystems`。
|
||||
- 监控状态:集团或当前公司的 `monitoring`、`loading`、`error`。
|
||||
- 项目状态:`projectData`、`projectLoading`、`projectError`。
|
||||
- 穿透状态:`dialog`、`selectedMetric`、`metricAnalysis`、`metricLoading`。
|
||||
|
||||
集团和公司监控共用 `loadMonitoring`。新请求开始时会取消旧请求,并通过递增请求序号阻止旧响应覆盖最新筛选结果。项目详情和指标穿透分别使用独立 Action,避免页面组件自行维护请求状态。
|
||||
|
||||
### 接口与数据适配
|
||||
|
||||
所有接口使用同源前缀 `/qhse_webapi/api/DataGovernance`:
|
||||
|
||||
- `GetMetricMonitoringSituation`:集团监控。
|
||||
- `GetUnitMetricMonitoringSituation`:公司监控。
|
||||
- `GetGroupMetricAnalysis`:集团指标穿透。
|
||||
- `GetUnitMetricAnalysis`:公司指标穿透。
|
||||
- `GetProjectMonitoringSituation`:项目详情。
|
||||
|
||||
集团监控接口要求查询字符串始终包含 `unitId=`,即使值为空也不能省略。`services/http.ts` 因此只过滤 `undefined`,显式空字符串会被保留。
|
||||
|
||||
`adapters/dashboard.ts` 负责兼容 PascalCase/camelCase 字段、非法数字、布尔字符串和空数组,并集中维护上线率口径:
|
||||
|
||||
```text
|
||||
(已关联项目数 + 预立项项目数) / (主数据项目数 + 预立项项目数 - 申请不用项目数)
|
||||
```
|
||||
|
||||
组件只能读取适配后的 ViewModel。新增或调整接口字段时,应先修改 `types/` 和 `adapters/`,不要在 Vue 模板中增加字段兼容分支。
|
||||
|
||||
### 图表与大屏布局
|
||||
|
||||
`components/EChart.vue` 统一负责 ECharts 初始化、响应式更新、`ResizeObserver` 监听和 `dispose`。当前只注册柱状图、折线图、网格、图例、提示框和 Canvas 渲染器。
|
||||
|
||||
页面按照 1920×1080 设计尺寸渲染,由 `App.vue` 等比缩放到实际窗口。趋势图宽度跟随面板;项目和企业数量较多的柱状图使用动态最小宽度和横向滚动,避免柱体被强制压缩。
|
||||
|
||||
### 安全与部署边界
|
||||
|
||||
- 浏览器只访问同源 `/qhse_webapi/`,前端构建产物不包含接口 token。
|
||||
- 本地 token 仅写入 `.env.local`,由 Vite 开发代理注入。
|
||||
- 生产环境由网关反向代理 `https://qhse.cncecoa.com/qhse_webapi` 并注入 token。
|
||||
- 网关应配置上游超时、请求 ID 透传、访问日志和 5xx 告警。
|
||||
|
||||
## 本地开发
|
||||
|
||||
复制 `.env.example` 为 `.env.local`,填写仅供本地代理使用的 `QHSE_API_TOKEN`,然后执行:
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 `http://127.0.0.1:18081/`。外部页签参数仍支持 `?type=1`、`?type=2`、`?type=3` 和 `?type=1,2`。
|
||||
|
||||
## 测试
|
||||
|
||||
```powershell
|
||||
npm run test
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
当前单元测试重点覆盖接口字段归一化、上线率口径、空数据、非法数字和布尔字符串。页面结构或交互调整后,还需要在浏览器验证集团、公司、项目三级路由以及统计和指标穿透弹窗。
|
||||
|
||||
## 构建与部署
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
```
|
||||
|
||||
产物位于 `dist/`,可部署到任意静态 Web 服务。生产环境需要由同源网关代理 `/qhse_webapi/`,并在服务端注入接口 token;token 不进入浏览器构建产物。
|
||||
|
||||
建议网关同时配置 12 秒上游超时、请求 ID 透传、访问日志和 5xx 告警。应用采用哈希路由,无需 Web 服务器配置 SPA history fallback。
|
||||
|
||||
## 开发约定
|
||||
|
||||
- 新接口先在 `config/dashboard.ts` 注册,再通过 `services/http.ts` 请求。
|
||||
- 后端响应必须先经过 `adapters/`,页面不得直接依赖原始字段命名。
|
||||
- 跨页面状态进入 Pinia;仅组件内部使用的展示状态保留在组件中。
|
||||
- 通用展示提取到 `components/`,集团、公司、项目编排保留在 `views/`。
|
||||
- 图表必须复用 `EChart.vue`,不要在页面中直接创建实例。
|
||||
- 样式优先复用原版 CSS 类;Vue3 增量样式只添加到 `styles/next.css`。
|
||||
- 修改后至少执行 `npm run test` 和 `npm run build`。
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>集团数据治理项目监控大屏</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2294
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "data-governance-dashboard-next",
|
||||
"private": true,
|
||||
"version": "3.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"echarts": "^6.1.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "latest",
|
||||
"@vitejs/plugin-vue": "latest",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.1.4",
|
||||
"vitest": "latest",
|
||||
"vue-tsc": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 应用壳层:负责大屏缩放、全局筛选、面包屑和路由出口。
|
||||
* 集团/公司/项目业务数据由路由页面和 Pinia 仓库负责,壳层不直接处理接口响应。
|
||||
*/
|
||||
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { EXTERNAL_LINKS, SYSTEMS, type DimensionKey, type SystemKey } from "./config/dashboard";
|
||||
import { useDashboardStore } from "./stores/dashboard";
|
||||
|
||||
const DrillModal = defineAsyncComponent(() => import("./components/DrillModal.vue"));
|
||||
|
||||
const store = useDashboardStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const screen = ref<HTMLElement>();
|
||||
let frame = 0;
|
||||
|
||||
const visibleSystems = computed(() => SYSTEMS.filter((item) => store.allowedSystems.includes(item.key)));
|
||||
const pageName = computed(() => route.name === "project" ? "项目明细" : route.name === "enterprise" ? store.currentUnitName || "企业看板" : "集团总览");
|
||||
|
||||
function scaleScreen() {
|
||||
// 以 1920×1080 设计稿等比缩放,requestAnimationFrame 避免 resize 高频触发布局计算。
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
if (!screen.value) return;
|
||||
const scale = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
|
||||
const left = Math.max((window.innerWidth - 1920 * scale) / 2, 0);
|
||||
const top = Math.max((window.innerHeight - 1080 * scale) / 2, 0);
|
||||
screen.value.style.transform = `translate(${left}px, ${top}px) scale(${scale})`;
|
||||
});
|
||||
}
|
||||
|
||||
async function changeFilter(nextSystem: SystemKey, nextDimension: DimensionKey) {
|
||||
// 项目页切换筛选时刷新项目接口,其余页面刷新当前集团或公司接口。
|
||||
store.setFilters(nextSystem, nextDimension);
|
||||
if (route.name === "project") {
|
||||
await store.loadProject(String(route.params.unitId), String(route.params.projectId));
|
||||
} else {
|
||||
await store.loadMonitoring(route.name === "enterprise" ? String(route.params.unitId) : "", store.currentUnitName);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
scaleScreen();
|
||||
window.addEventListener("resize", scaleScreen);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("resize", scaleScreen);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="screen" class="screen">
|
||||
<header class="topbar">
|
||||
<div class="title-zone">
|
||||
<div class="brand-mark" aria-hidden="true"></div>
|
||||
<div class="title-text"><div class="eyebrow">DATA GOVERNANCE COMMAND CENTER</div><h1>集团数据治理项目监控大屏</h1></div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="top-meta-row">
|
||||
<a class="source-download" :href="EXTERNAL_LINKS.source" target="_blank" rel="noopener noreferrer">数据溯源表</a>
|
||||
<div class="time-card"><span>数据时间 <strong>{{ store.monitoring.summary.dataDate || '--' }}</strong></span></div>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<div class="dimension-toggle" aria-label="数据维度切换">
|
||||
<button type="button" :class="{ active: store.dimension === 'all' }" @click="changeFilter(store.system, 'all')">所有项目</button>
|
||||
<button type="button" :class="{ active: store.dimension === 'new' }" @click="changeFilter(store.system, 'new')">新开工项目</button>
|
||||
</div>
|
||||
<div class="system-toggle" aria-label="系统切换">
|
||||
<button v-for="item in visibleSystems" :key="item.key" type="button" :class="{ active: store.system === item.key }" @click="changeFilter(item.key, store.dimension)">{{ item.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="crumb-row">
|
||||
<div class="breadcrumbs">
|
||||
<span class="crumb clickable" @click="router.push('/')">集团总览</span><span v-if="route.name !== 'group'">›</span><span v-if="route.name !== 'group'" class="crumb">{{ pageName }}</span>
|
||||
<span class="crumb">当前维度:{{ store.dimension === 'all' ? '所有项目' : '新开工项目' }}</span>
|
||||
<span class="crumb">当前系统:{{ SYSTEMS.find(item => item.key === store.system)?.label }}</span>
|
||||
</div>
|
||||
<button type="button" class="ghost-btn" @click="router.push('/')">返回集团总览</button>
|
||||
</div>
|
||||
<main id="app-main"><RouterView /></main>
|
||||
</div>
|
||||
<DrillModal />
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
/** 数据适配层回归测试:锁定接口兼容规则和核心统计口径。 */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeMonitoring } from "./dashboard";
|
||||
|
||||
describe("normalizeMonitoring", () => {
|
||||
it("按业务口径归一化集团汇总、单位和指标数据", () => {
|
||||
const result = normalizeMonitoring({
|
||||
ProInfo: "8/1/10/11",
|
||||
OnlineRate: 80,
|
||||
UsageRate: 76.5,
|
||||
AccuracyRate: 91.2,
|
||||
DataDate: "2026-07-12",
|
||||
MasterDataAnalysiss: [{
|
||||
UnitId: "u1", Abbreviation: "测试单位", Total: 5, MasterTotal: 5, QHSETotal: 4,
|
||||
ZhengChang: 4, YuLiXiang: 1, BuYong: 1, DaiJian: 1, IsOnline: "false"
|
||||
}],
|
||||
KeyMetricDatas: [{ Name: "隐患排查", Field: "Danger", ProjectNum: 10, ProjectUsageNum: 8, UsageRate: 80, ExactRate: 95 }],
|
||||
UnitAnalysises: [{ UnitId: "u1", Name: "测试单位", OnlineRate: 80, UsageRate: 70, AccuracyRate: 90 }],
|
||||
ProjectAnalysises: [{ UnitId: "u1", ProjectId: "p1", Name: "测试项目", IsOnline: "false", UsageRate: 60 }]
|
||||
});
|
||||
|
||||
expect(result.summary.proInfoText).toBe("8/1/10/11");
|
||||
expect(result.summary.onlineRate).toBe(80);
|
||||
expect(result.records[0].onlineRate).toBe(100);
|
||||
expect(result.keyMetrics[0]).toMatchObject({ name: "隐患排查", field: "Danger", usageRate: 80, accuracyRate: 95 });
|
||||
expect(result.units[0].name).toBe("测试单位");
|
||||
expect(result.projects[0].isOnline).toBe(false);
|
||||
});
|
||||
|
||||
it("在空数据和非法数字下返回稳定空模型", () => {
|
||||
const result = normalizeMonitoring({ OnlineRate: "invalid", MasterDataAnalysiss: null });
|
||||
expect(result.records).toEqual([]);
|
||||
expect(result.summary.onlineRate).toBe(0);
|
||||
expect(result.trend.dates).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 接口数据适配层。
|
||||
* 后端字段命名和空值并不完全稳定,本模块将原始响应转换为页面只读 ViewModel,页面组件不直接兼容接口别名。
|
||||
*/
|
||||
import type {
|
||||
FailProjectVM,
|
||||
MasterRecordVM,
|
||||
MetricVM,
|
||||
MonitoringVM,
|
||||
ProjectAnalysisVM,
|
||||
SummaryVM,
|
||||
UnitAnalysisVM,
|
||||
UnitMetricUsageVM
|
||||
} from "../types/dashboard";
|
||||
|
||||
type Raw = Record<string, unknown>;
|
||||
|
||||
export const numberValue = (value: unknown): number => {
|
||||
const result = Number(value);
|
||||
return Number.isFinite(result) ? result : 0;
|
||||
};
|
||||
|
||||
const textValue = (value: unknown): string => value == null ? "" : String(value);
|
||||
const booleanValue = (value: unknown): boolean => value === true || value === 1 || String(value).toLowerCase() === "true";
|
||||
const value = (item: Raw, ...keys: string[]): unknown => {
|
||||
// 先按明确字段读取,再做大小写不敏感匹配,兼容历史接口字段调整。
|
||||
for (const key of keys) if (item[key] !== undefined && item[key] !== null) return item[key];
|
||||
const entries = Object.entries(item);
|
||||
const matched = entries.find(([key]) => keys.some((candidate) => candidate.toLowerCase() === key.toLowerCase()));
|
||||
return matched?.[1];
|
||||
};
|
||||
|
||||
export function normalizeMetric(item: Raw, index = 0): MetricVM {
|
||||
return {
|
||||
name: textValue(value(item, "Name", "name")),
|
||||
field: textValue(value(item, "Field", "field", "Filed", "filed")),
|
||||
projects: numberValue(value(item, "ProjectNum", "projectNum")),
|
||||
projectUsageNum: numberValue(value(item, "ProjectUsageNum", "projectUsageNum")),
|
||||
usageRate: numberValue(value(item, "UsageRate", "usageRate")),
|
||||
accuracyRate: numberValue(value(item, "ExactRate", "exactRate", "AccuracyRate", "accuracyRate")),
|
||||
frequency: numberValue(value(item, "Frequency", "frequency")),
|
||||
sortIndex: numberValue(value(item, "SortIndex", "sortIndex")) || index + 1
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRecord(item: Raw, index: number): MasterRecordVM {
|
||||
const record: MasterRecordVM = {
|
||||
id: textValue(value(item, "Id", "id")),
|
||||
unitId: textValue(value(item, "UnitId", "unitId")),
|
||||
name: textValue(value(item, "Abbreviation", "abbreviation")),
|
||||
total: numberValue(value(item, "Total", "total")),
|
||||
onlineAutoTotal: numberValue(value(item, "YiShangXian", "yiShangXian")),
|
||||
onlineReportedTotal: numberValue(value(item, "TianBao", "tianBao")),
|
||||
masterProjectCount: numberValue(value(item, "MasterTotal", "masterTotal", "masterProjectCount")),
|
||||
qhseProjectCount: numberValue(value(item, "QHSETotal", "qhseTotal", "qhseProjectCount")),
|
||||
linkedTotal: numberValue(value(item, "ZhengChang", "zhengChang")),
|
||||
pendingTotal: numberValue(value(item, "DaiJian", "daiJian")),
|
||||
noNeedTotal: numberValue(value(item, "BuYong", "buYong")),
|
||||
noNeedCompletedTotal: numberValue(value(item, "BuYongYiWanGong", "buYongYiWanGong")),
|
||||
noNeedStoppedTotal: numberValue(value(item, "BuYongTingHuanJian", "buYongTingHuanJian")),
|
||||
noNeedRestrictedTotal: numberValue(value(item, "BuYongTJShouXian", "buYongTJShouXian")),
|
||||
preProjectTotal: numberValue(value(item, "YuLiXiang", "yuLiXiang")),
|
||||
onlineRate: numberValue(value(item, "OnlineRate", "OnlinRate", "onlineRate", "onlinRate")),
|
||||
reportDate: textValue(value(item, "ReportDate", "reportDate", "DataDate", "dataDate")),
|
||||
webUrl: textValue(value(item, "WebUrl", "webUrl", "URL", "url")),
|
||||
sortIndex: numberValue(value(item, "SortIndex", "sortIndex")) || index + 1
|
||||
};
|
||||
// 接口未返回上线率时,按现行业务口径计算:(已关联 + 预立项) / (主数据 + 预立项 - 申请不用)。
|
||||
if (!record.onlineRate) {
|
||||
const denominator = record.masterProjectCount + record.preProjectTotal - record.noNeedTotal;
|
||||
record.onlineRate = denominator ? (record.linkedTotal + record.preProjectTotal) / denominator * 100 : 0;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function summarize(records: MasterRecordVM[], data: Raw): SummaryVM {
|
||||
const totals = records.reduce((sum, row) => ({
|
||||
master: sum.master + row.masterProjectCount,
|
||||
qhse: sum.qhse + row.qhseProjectCount,
|
||||
linked: sum.linked + row.linkedTotal,
|
||||
pending: sum.pending + row.pendingTotal,
|
||||
pre: sum.pre + row.preProjectTotal,
|
||||
noNeed: sum.noNeed + row.noNeedTotal,
|
||||
total: sum.total + row.total
|
||||
}), { master: 0, qhse: 0, linked: 0, pending: 0, pre: 0, noNeed: 0, total: 0 });
|
||||
const proInfoText = textValue(value(data, "ProInfo", "proInfo"));
|
||||
const info = proInfoText.split("/").map(numberValue);
|
||||
const onlineActual = info[0] || totals.linked + totals.pre || totals.qhse;
|
||||
const onlineExpected = info[2] || totals.total || totals.master;
|
||||
return {
|
||||
onlineActual,
|
||||
onlineExpected,
|
||||
onlineRate: numberValue(value(data, "OnlineRate", "onlineRate")) || (onlineExpected ? onlineActual / onlineExpected * 100 : 0),
|
||||
usageRate: numberValue(value(data, "UsageRate", "usageRate")),
|
||||
accuracyRate: numberValue(value(data, "AccuracyRate", "accuracyRate")),
|
||||
preProjectTotal: numberValue(value(data, "YuLiXiang", "yuLiXiang")) || totals.pre,
|
||||
pendingTotal: numberValue(value(data, "DaiJian", "daiJian")) || totals.pending,
|
||||
failProjectNum: numberValue(value(data, "FailProjectNum", "failProjectNum")),
|
||||
noNeedProjectCount: info[1] || totals.noNeed,
|
||||
masterProjectCount: info[2] || totals.master,
|
||||
qhseProjectCount: info[0] || totals.qhse,
|
||||
projectCount: info[3] || totals.total,
|
||||
proInfoText: proInfoText || `${onlineActual}/${totals.noNeed}/${onlineExpected}/${totals.total}`,
|
||||
dataDate: textValue(value(data, "DataDate", "dataDate"))
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMonitoring(input: unknown): MonitoringVM {
|
||||
// 集团接口返回对象,公司接口在部分环境可能直接返回数组,两种形态在此统一。
|
||||
const data = (input && typeof input === "object" ? input : {}) as Raw;
|
||||
const rawRecords = Array.isArray(input) ? input as Raw[] : (value(data, "MasterDataAnalysiss") as Raw[] || []);
|
||||
const records = rawRecords.map(normalizeRecord).filter((item) => item.name).sort((a, b) => a.sortIndex - b.sortIndex);
|
||||
const map = <T>(key: string, convert: (item: Raw, index: number) => T): T[] => {
|
||||
const rows = value(data, key);
|
||||
return Array.isArray(rows) ? (rows as Raw[]).map(convert) : [];
|
||||
};
|
||||
const units = map<UnitAnalysisVM>("UnitAnalysises", (item, index) => ({
|
||||
unitId: textValue(value(item, "UnitId", "unitId")), name: textValue(value(item, "Name", "name")),
|
||||
onlineRate: numberValue(value(item, "OnlineRate", "onlineRate")), usageRate: numberValue(value(item, "UsageRate", "usageRate")),
|
||||
accuracyRate: numberValue(value(item, "AccuracyRate", "accuracyRate")), sortIndex: numberValue(value(item, "SortIndex", "sortIndex")) || index + 1
|
||||
}));
|
||||
const projects = map<ProjectAnalysisVM>("ProjectAnalysises", (item, index) => ({
|
||||
unitId: textValue(value(item, "UnitId", "unitId")), projectId: textValue(value(item, "ProjectId", "projectId")) || `project-${index}`,
|
||||
name: textValue(value(item, "Name", "name")), isOnline: booleanValue(value(item, "IsOnline", "isOnline")),
|
||||
usageRate: numberValue(value(item, "UsageRate", "usageRate")), accuracyRate: numberValue(value(item, "AccuracyRate", "accuracyRate")),
|
||||
sortIndex: numberValue(value(item, "SortIndex", "sortIndex")) || index + 1
|
||||
}));
|
||||
const failProjects = map<FailProjectVM>("FailProjects", (item, index) => ({
|
||||
unitId: textValue(value(item, "UnitId", "unitId")), projectId: textValue(value(item, "ProjectId", "projectId")) || `fail-${index}`,
|
||||
unitName: textValue(value(item, "UnitName", "unitName")), abbreviation: textValue(value(item, "Abbreviation", "abbreviation")),
|
||||
projectName: textValue(value(item, "ProjectName", "projectName")), shortName: textValue(value(item, "ShortName", "shortName")),
|
||||
usageRate: numberValue(value(item, "UsageRate", "usageRate")), usage: textValue(value(item, "Usage", "usage")),
|
||||
sortIndex: numberValue(value(item, "SortIndex", "sortIndex")) || index + 1
|
||||
}));
|
||||
const unitMetricUsages = map<UnitMetricUsageVM>("UnitMetricUsages", (item, index) => ({
|
||||
unitId: textValue(value(item, "UnitId", "unitId")), unitName: textValue(value(item, "UnitName", "unitName")),
|
||||
abbreviation: textValue(value(item, "Abbreviation", "abbreviation")), qhseTotal: numberValue(value(item, "QHSETotal", "qhseTotal")),
|
||||
activeNum: numberValue(value(item, "ActiveNum", "activeNum")), usageRate: numberValue(value(item, "UsageRate", "usageRate")),
|
||||
date: textValue(value(item, "Date", "date")), sortIndex: numberValue(value(item, "SortIndex", "sortIndex")) || index + 1
|
||||
}));
|
||||
const trend = value(data, "TrendCurve") as Raw | undefined;
|
||||
return {
|
||||
records,
|
||||
summary: summarize(records, data),
|
||||
keyMetrics: map("KeyMetricDatas", normalizeMetric).filter((item) => item.name),
|
||||
metrics: map("MetricDatas", normalizeMetric).filter((item) => item.name),
|
||||
units: units.filter((item) => item.name).sort((a, b) => a.sortIndex - b.sortIndex),
|
||||
projects: projects.filter((item) => item.name).sort((a, b) => a.sortIndex - b.sortIndex),
|
||||
failProjects: failProjects.sort((a, b) => a.sortIndex - b.sortIndex),
|
||||
unitMetricUsages: unitMetricUsages.sort((a, b) => a.sortIndex - b.sortIndex),
|
||||
trend: {
|
||||
dates: Array.isArray(trend?.Date) ? trend.Date.map(textValue) : [],
|
||||
online: Array.isArray(trend?.OnlineRate) ? trend.OnlineRate.map(numberValue) : [],
|
||||
usage: Array.isArray(trend?.UsageRate) ? trend.UsageRate.map(numberValue) : [],
|
||||
accuracy: Array.isArray(trend?.AccuracyRate) ? trend.AccuracyRate.map(numberValue) : []
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 全局穿透弹窗。
|
||||
* 根据 store.dialog 渲染上线统计、未达标清单、单位使用率或指标穿透,避免每个页面维护独立弹窗状态。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import type { EChartsOption } from "echarts";
|
||||
import { useDashboardStore } from "../stores/dashboard";
|
||||
import EChart from "./EChart.vue";
|
||||
import { normalizeMetric, numberValue } from "../adapters/dashboard";
|
||||
import { EXTERNAL_LINKS, UNIT_REPORT_FILES } from "../config/dashboard";
|
||||
import type { MasterRecordVM } from "../types/dashboard";
|
||||
|
||||
const store = useDashboardStore();
|
||||
const raw = computed(() => store.metricAnalysis || {});
|
||||
const metric = computed(() => {
|
||||
const source = raw.value.MetricData;
|
||||
return source && typeof source === "object"
|
||||
? normalizeMetric(source as Record<string, unknown>)
|
||||
: store.selectedMetricVM;
|
||||
});
|
||||
const unitRows = computed(() =>
|
||||
Array.isArray(raw.value.UnitAnalysises)
|
||||
? (raw.value.UnitAnalysises as Record<string, unknown>[])
|
||||
: [],
|
||||
);
|
||||
const projectRows = computed(() =>
|
||||
Array.isArray(raw.value.ProjectMetrics)
|
||||
? (raw.value.ProjectMetrics as Record<string, unknown>[])
|
||||
: [],
|
||||
);
|
||||
const unitChart = computed<EChartsOption>(() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: { textStyle: { color: "#bfeaff" } },
|
||||
grid: { left: 54, right: 24, top: 48, bottom: 70 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: unitRows.value.map((item) => String(item.Name || item.name || "")),
|
||||
axisLabel: { color: "#a8c7db", rotate: 28 },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
max: 100,
|
||||
axisLabel: { color: "#a8c7db", formatter: "{value}%" },
|
||||
splitLine: { lineStyle: { color: "rgba(93,191,255,.14)" } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "使用率",
|
||||
type: "bar",
|
||||
data: unitRows.value.map((item) =>
|
||||
numberValue(item.UsageRate ?? item.usageRate),
|
||||
),
|
||||
itemStyle: { color: "#5ee1b5" },
|
||||
},
|
||||
{
|
||||
name: "准确率",
|
||||
type: "bar",
|
||||
data: unitRows.value.map((item) =>
|
||||
numberValue(item.AccuracyRate ?? item.accuracyRate),
|
||||
),
|
||||
itemStyle: { color: "#ffc857" },
|
||||
},
|
||||
],
|
||||
}));
|
||||
const title = computed(() => {
|
||||
const labels = {
|
||||
online: "集团项目上线统计",
|
||||
belowStandard: "未达标项目清单",
|
||||
unitUsage: "项目指标使用率(使用率=100*活跃项目/QHSE在建项目;项目中18个指标有一个指标有异动则视为活跃项目)",
|
||||
groupMetric: "指标穿透分析",
|
||||
unitMetric: "企业指标穿透分析",
|
||||
};
|
||||
return store.dialog ? labels[store.dialog] : "";
|
||||
});
|
||||
|
||||
const sum = (field: keyof MasterRecordVM) =>
|
||||
store.monitoring.records.reduce(
|
||||
(total, row) => total + numberValue(row[field]),
|
||||
0,
|
||||
);
|
||||
// “应使用项目数-条件受限”沿用原版表格口径,不能与“申请不用-条件受限”混用。
|
||||
const restricted = (row: MasterRecordVM) =>
|
||||
Math.max(
|
||||
row.total - row.linkedTotal - row.preProjectTotal - row.pendingTotal,
|
||||
0,
|
||||
);
|
||||
const restrictedTotal = computed(() =>
|
||||
store.monitoring.records.reduce((total, row) => total + restricted(row), 0),
|
||||
);
|
||||
|
||||
function previewUnitReport(unit: string) {
|
||||
// 单位简称存在历史别名,先归一化再匹配固定文件名,避免按名称模糊拼接到错误文件。
|
||||
const aliases: Record<string, string> = {
|
||||
二建: "二化建",
|
||||
三建: "三化建",
|
||||
四建: "四化建",
|
||||
六建: "六化建",
|
||||
七建: "七化建",
|
||||
九建: "九化建",
|
||||
十一建: "十一化建",
|
||||
十三建: "十三化建",
|
||||
十四建: "十四化建",
|
||||
十六建: "十六化建",
|
||||
装备科技: "装备",
|
||||
华谊工程: "华谊",
|
||||
建投: "北建投",
|
||||
南投: "南建投",
|
||||
};
|
||||
const fileName = UNIT_REPORT_FILES[unit] || UNIT_REPORT_FILES[aliases[unit]];
|
||||
if (!fileName) {
|
||||
window.alert(`${unit || "该单位"}未上传一览表`);
|
||||
return;
|
||||
}
|
||||
const fileUrl = `${EXTERNAL_LINKS.reportFileBase}${encodeURIComponent(
|
||||
fileName,
|
||||
)}`;
|
||||
window.open(
|
||||
`${EXTERNAL_LINKS.officePreview}${encodeURIComponent(fileUrl)}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.dialog" class="drill-modal">
|
||||
<div class="drill-modal-panel">
|
||||
<div class="drill-modal-head">
|
||||
<div class="drill-modal-title">{{ title }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-close"
|
||||
aria-label="关闭"
|
||||
@click="store.dialog = null"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="drill-modal-body">
|
||||
<div v-if="store.metricLoading" class="empty-state">
|
||||
正在获取指标穿透数据...
|
||||
</div>
|
||||
<div v-else-if="store.metricError" class="empty-state">
|
||||
{{ store.metricError }}
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-else-if="store.dialog === 'online'"
|
||||
class="panel drill-panel"
|
||||
>
|
||||
<div class="table-wrap">
|
||||
<table class="project-master-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2">企业简称</th>
|
||||
<th colspan="2">上线项目数</th>
|
||||
<th rowspan="2">上线率</th>
|
||||
<th colspan="5">应使用项目数</th>
|
||||
<th colspan="4">申请不用</th>
|
||||
<th rowspan="2">主数据项目数</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>自动取值</th>
|
||||
<th>定期填报</th>
|
||||
<th>总数</th>
|
||||
<th>已关联项目</th>
|
||||
<th>预立项项目</th>
|
||||
<th>待建项目</th>
|
||||
<th>条件受限</th>
|
||||
<th>总数</th>
|
||||
<th>已完工</th>
|
||||
<th>停缓建</th>
|
||||
<th>条件受限</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in store.monitoring.records"
|
||||
:key="row.unitId"
|
||||
:class="{
|
||||
'green-row':
|
||||
row.name.includes('国际') || row.name.includes('生态'),
|
||||
}"
|
||||
>
|
||||
<td>
|
||||
<a
|
||||
v-if="/^https?:\/\//i.test(row.webUrl)"
|
||||
class="table-link"
|
||||
:href="row.webUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ row.name }}</a
|
||||
><template v-else>{{ row.name }}</template>
|
||||
</td>
|
||||
<td class="num">{{ row.onlineAutoTotal }}</td>
|
||||
<td class="num">{{ row.onlineReportedTotal }}</td>
|
||||
<td class="num">{{ row.onlineRate.toFixed(2) }}%</td>
|
||||
<td class="num">{{ row.total }}</td>
|
||||
<td class="num">{{ row.linkedTotal }}</td>
|
||||
<td class="num">{{ row.preProjectTotal }}</td>
|
||||
<td class="num">{{ row.pendingTotal }}</td>
|
||||
<td class="num">{{ restricted(row) }}</td>
|
||||
<td class="num">{{ row.noNeedTotal }}</td>
|
||||
<td class="num">{{ row.noNeedCompletedTotal }}</td>
|
||||
<td class="num">{{ row.noNeedStoppedTotal }}</td>
|
||||
<td class="num">{{ row.noNeedRestrictedTotal }}</td>
|
||||
<td class="num">{{ row.masterProjectCount }}</td>
|
||||
</tr>
|
||||
<tr class="summary-row">
|
||||
<td>合计</td>
|
||||
<td class="num">{{ sum("onlineAutoTotal") }}</td>
|
||||
<td class="num">{{ sum("onlineReportedTotal") }}</td>
|
||||
<td class="num">
|
||||
{{ store.monitoring.summary.onlineRate.toFixed(2) }}%
|
||||
</td>
|
||||
<td class="num">{{ sum("total") }}</td>
|
||||
<td class="num">{{ sum("linkedTotal") }}</td>
|
||||
<td class="num">{{ sum("preProjectTotal") }}</td>
|
||||
<td class="num">{{ sum("pendingTotal") }}</td>
|
||||
<td class="num">{{ restrictedTotal }}</td>
|
||||
<td class="num">{{ sum("noNeedTotal") }}</td>
|
||||
<td class="num">{{ sum("noNeedCompletedTotal") }}</td>
|
||||
<td class="num">{{ sum("noNeedStoppedTotal") }}</td>
|
||||
<td class="num">{{ sum("noNeedRestrictedTotal") }}</td>
|
||||
<td class="num">{{ sum("masterProjectCount") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="store.dialog === 'belowStandard'"
|
||||
class="panel drill-panel"
|
||||
>
|
||||
<div v-if="!store.monitoring.failProjects.length" class="empty-state">
|
||||
当前暂无指标使用率低于 80% 的项目。
|
||||
</div>
|
||||
<div v-else class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>单位</th>
|
||||
<th>项目名称</th>
|
||||
<th>指标使用率</th>
|
||||
<th>使用情况</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in store.monitoring.failProjects"
|
||||
:key="row.projectId"
|
||||
>
|
||||
<td>{{ row.abbreviation || row.unitName }}</td>
|
||||
<td>{{ row.projectName || row.shortName }}</td>
|
||||
<td class="num">{{ row.usageRate.toFixed(1) }}%</td>
|
||||
<td>{{ row.usage }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="store.dialog === 'unitUsage'"
|
||||
class="panel drill-panel"
|
||||
>
|
||||
<div
|
||||
v-if="!store.monitoring.unitMetricUsages.length"
|
||||
class="empty-state"
|
||||
>
|
||||
当前暂无单位项目指标使用数据。
|
||||
</div>
|
||||
<div v-else class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>单位简称</th>
|
||||
<th>QHSE在建项目数</th>
|
||||
<th>活跃项目数</th>
|
||||
<th>使用率</th>
|
||||
<th>日期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in store.monitoring.unitMetricUsages"
|
||||
:key="row.unitId"
|
||||
>
|
||||
<td>{{ row.abbreviation || row.unitName }}</td>
|
||||
<td class="num">{{ row.qhseTotal }}</td>
|
||||
<td class="num">{{ row.activeNum }}</td>
|
||||
<td class="num">{{ row.usageRate.toFixed(1) }}%</td>
|
||||
<td>{{ row.date }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-else-if="metric">
|
||||
<section class="drill-hero">
|
||||
<article class="drill-title-card">
|
||||
<h2>{{ metric.name }}</h2>
|
||||
</article>
|
||||
<article class="drill-card">
|
||||
<div class="drill-card-label">使用项目/项目总数</div>
|
||||
<div class="drill-card-value">
|
||||
{{ metric.projectUsageNum }}/{{ metric.projects }}
|
||||
</div>
|
||||
</article>
|
||||
<article class="drill-card">
|
||||
<div class="drill-card-label">使用率</div>
|
||||
<div class="drill-card-value">
|
||||
{{ metric.usageRate.toFixed(2) }}%
|
||||
</div>
|
||||
</article>
|
||||
<article class="drill-card">
|
||||
<div class="drill-card-label">准确率</div>
|
||||
<div class="drill-card-value">
|
||||
{{ metric.accuracyRate.toFixed(2) }}%
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section
|
||||
v-if="store.dialog === 'groupMetric'"
|
||||
class="panel drill-panel"
|
||||
>
|
||||
<EChart :option="unitChart" :height="520" />
|
||||
</section>
|
||||
<section v-else class="panel drill-panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">项目指标分析</h2>
|
||||
</div>
|
||||
<div v-if="!projectRows.length" class="empty-state">
|
||||
暂无项目指标明细。
|
||||
</div>
|
||||
<div v-else class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目名称</th>
|
||||
<th>是否异常</th>
|
||||
<th>今日</th>
|
||||
<th>昨日</th>
|
||||
<th>三天前</th>
|
||||
<th>一周前</th>
|
||||
<th>一个月前</th>
|
||||
<th>三个月前</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in projectRows" :key="index">
|
||||
<td>{{ row.ProjectName || row.projectName }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="status-pill"
|
||||
:class="row.IsAbnormal || row.isAbnormal ? 'bad' : 'ok'"
|
||||
>{{
|
||||
row.IsAbnormal || row.isAbnormal ? "异常" : "正常"
|
||||
}}</span
|
||||
>
|
||||
</td>
|
||||
<td>{{ row.NowData ?? "-" }}</td>
|
||||
<td>{{ row.YesterdayData ?? "-" }}</td>
|
||||
<td>{{ row.ThreeDaysAgoData ?? "-" }}</td>
|
||||
<td>{{ row.WeekAgoData ?? "-" }}</td>
|
||||
<td>{{ row.MonthAgoData ?? "-" }}</td>
|
||||
<td>{{ row.ThreeMonthsAgoData ?? "-" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ECharts 生命周期组件。
|
||||
* 只注册当前项目使用的柱状图、折线图和 Canvas 渲染器,并统一处理更新、容器缩放与实例销毁。
|
||||
*/
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { init, use, type ECharts, type EChartsCoreOption } from "echarts/core";
|
||||
import { BarChart, LineChart } from "echarts/charts";
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
|
||||
use([BarChart, LineChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer]);
|
||||
|
||||
const props = defineProps<{ option: object; height?: number; minWidth?: number }>();
|
||||
const emit = defineEmits<{ select: [index: number] }>();
|
||||
const host = ref<HTMLElement>();
|
||||
let chart: ECharts | null = null;
|
||||
let observer: ResizeObserver | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (!host.value) return;
|
||||
chart = init(host.value, undefined, { renderer: "canvas" });
|
||||
chart.setOption(props.option as EChartsCoreOption, true);
|
||||
chart.on("click", (params) => {
|
||||
if (typeof params.dataIndex === "number") emit("select", params.dataIndex);
|
||||
});
|
||||
// 面板宽度由大屏缩放和路由布局共同决定,监听容器而不是只监听 window.resize。
|
||||
observer = new ResizeObserver(() => chart?.resize());
|
||||
observer.observe(host.value);
|
||||
});
|
||||
|
||||
watch(() => props.option, (option) => chart?.setOption(option as EChartsCoreOption, true), { deep: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect();
|
||||
chart?.dispose();
|
||||
chart = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="host" class="echart-host" :style="{ height: `${height || 300}px`, minWidth: minWidth ? `${minWidth}px` : undefined }"></div>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 指标分析列表。
|
||||
* 每行固定展示项目覆盖量、使用率和准确率三条进度,用于集团页与公司页复用。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import type { MetricVM } from "../types/dashboard";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ metrics: MetricVM[]; limit?: number }>(),
|
||||
{ limit: 0 },
|
||||
);
|
||||
defineEmits<{ select: [name: string] }>();
|
||||
const rows = computed(() =>
|
||||
props.limit ? props.metrics.slice(0, props.limit) : props.metrics,
|
||||
);
|
||||
const maxProjects = computed(() =>
|
||||
Math.max(...rows.value.map((item) => item.projects), 1),
|
||||
);
|
||||
// 所有进度条统一限制在 0~100%,防止异常接口值破坏布局。
|
||||
const width = (value: number) => `${Math.max(0, Math.min(value, 100))}%`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="risk-summary">
|
||||
<div
|
||||
v-for="metric in rows"
|
||||
:key="metric.name"
|
||||
class="risk-item"
|
||||
@click="$emit('select', metric.name)"
|
||||
>
|
||||
<div class="risk-name" :title="metric.name">{{ metric.name }}</div>
|
||||
<div class="risk-bars">
|
||||
<div class="mini-bar">
|
||||
<span>项目</span
|
||||
><span class="mini-track"
|
||||
><span
|
||||
:style="{
|
||||
width: width((metric.projects / maxProjects) * 100),
|
||||
background: '#5d93ff',
|
||||
}"
|
||||
></span></span
|
||||
><strong>{{ metric.projects }}</strong>
|
||||
</div>
|
||||
<div class="mini-bar">
|
||||
<span>使用</span
|
||||
><span class="mini-track"
|
||||
><span
|
||||
:style="{ width: width(metric.usageRate), background: '#5ee1b5' }"
|
||||
></span></span
|
||||
><strong>{{ metric.usageRate.toFixed(1) }}%</strong>
|
||||
</div>
|
||||
<div class="mini-bar">
|
||||
<span>准确</span
|
||||
><span class="mini-track"
|
||||
><span
|
||||
:style="{
|
||||
width: width(metric.accuracyRate),
|
||||
background: '#ffc857',
|
||||
}"
|
||||
></span></span
|
||||
><strong>{{ metric.accuracyRate.toFixed(1) }}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
/** 集团/公司顶部 KPI 卡片,统一数值、趋势、进度和可点击状态。 */
|
||||
defineProps<{
|
||||
label: string;
|
||||
value: string | number;
|
||||
unit?: string;
|
||||
description?: string;
|
||||
trend?: string;
|
||||
progress?: number;
|
||||
clickable?: boolean;
|
||||
}>();
|
||||
defineEmits<{ select: [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="kpi-card"
|
||||
:class="{ 'chart-click': clickable }"
|
||||
@click="clickable && $emit('select')"
|
||||
>
|
||||
<div v-if="trend" class="kpi-trend">{{ trend }}</div>
|
||||
<div class="kpi-label">{{ label }}</div>
|
||||
<div class="kpi-value">
|
||||
{{ value }}<span v-if="unit" class="kpi-unit">{{ unit }}</span>
|
||||
</div>
|
||||
<div class="risk-band">
|
||||
<span
|
||||
:style="{ width: `${Math.max(0, Math.min(progress ?? 0, 100))}%` }"
|
||||
></span>
|
||||
</div>
|
||||
<div class="kpi-desc">{{ description }}</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
/** 顶部重点指标条,点击指标后由父页面发起集团或公司级穿透。 */
|
||||
import type { MetricVM } from "../types/dashboard";
|
||||
defineProps<{ metrics: MetricVM[] }>();
|
||||
defineEmits<{ select: [name: string] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="indicator-strip">
|
||||
<article
|
||||
v-for="metric in metrics.slice(0, 8)"
|
||||
:key="metric.name"
|
||||
class="warning-card chart-click"
|
||||
@click="$emit('select', metric.name)"
|
||||
>
|
||||
<div class="warning-name" :title="metric.name">{{ metric.name }}</div>
|
||||
<div class="warning-main">
|
||||
<strong>{{ metric.projects }}</strong
|
||||
><span>个项目</span>
|
||||
</div>
|
||||
<div class="warning-meta">
|
||||
<span>使用率 {{ metric.usageRate.toFixed(1) }}%</span
|
||||
><span>准确率 {{ metric.accuracyRate.toFixed(1) }}%</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 看板静态配置。
|
||||
* 这里只存放接口相对路径、外部链接和枚举映射,认证令牌必须由开发代理或生产网关注入。
|
||||
*/
|
||||
export const API_BASE = "/qhse_webapi/api/DataGovernance";
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
monitoring: `${API_BASE}/GetMetricMonitoringSituation`,
|
||||
unitMonitoring: `${API_BASE}/GetUnitMetricMonitoringSituation`,
|
||||
groupMetric: `${API_BASE}/GetGroupMetricAnalysis`,
|
||||
unitMetric: `${API_BASE}/GetUnitMetricAnalysis`,
|
||||
projectMonitoring: `${API_BASE}/GetProjectMonitoringSituation`
|
||||
} as const;
|
||||
|
||||
export const EXTERNAL_LINKS = {
|
||||
source: "https://lygcgs.com.cn:8078/HSE/FileUpload/QHSE生产调度应急指挥中心大屏指标.xlsx",
|
||||
indicatorMore: "https://psecc.cncecoa.com/webroot/decision/v10/entry/access/e6251c34-31ea-4a35-8269-b83074bd9c64?preview=true",
|
||||
enterpriseIndicatorMore: "https://psecc.cncecoa.com/webroot/decision/view/report",
|
||||
groupReport: "https://lygcgs.com.cn:8078/QHSEDataExChange/res/report/%E6%95%B0%E6%8D%AE%E6%B2%BB%E7%90%86%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88%E5%8F%8A%E6%95%B0%E6%8D%AE%E5%88%86%E6%9E%90%E6%8A%A5%E5%91%8A.pdf",
|
||||
reportFileBase: "https://sggl.sedin.com.cn/res/report/",
|
||||
officePreview: "https://view.officeapps.live.com/op/embed.aspx?src="
|
||||
} as const;
|
||||
|
||||
export const UNIT_REPORT_FILES: Record<string, string> = {
|
||||
天辰: "1、天辰~主数据项目与QHSE项目一览表2026.6.3.xlsx", 赛鼎: "2、赛鼎~主数据项目与QHSE项目一览表2026.6.3.xlsx",
|
||||
东华: "3、东华~主数据项目与QHSE项目一览表2026.6.3.xlsx", 五环: "4、五环~主数据项目与QHSE项目一览表2026.6.3.xlsx",
|
||||
华陆: "5、华陆~主数据项目与QHSE项目一览表2026.6.3.xlsx", 成达: "6、成达~主数据项目与QHSE项目一览表2026.6.3.xlsx",
|
||||
二化建: "7、二化建~主数据项目与QHSE项目一览表2026.6.3.xlsx", 三化建: "8、三化建~主数据项目与QHSE项目一览表2026.6.3.xlsx",
|
||||
四化建: "9、四化建~主数据项目与QHSE项目一览表2026.6.3.xlsx", 六化建: "10、六化建~主数据项目与QHSE项目一览表2026.6.3.xlsx",
|
||||
七化建: "11、七化建~主数据项目与QHSE项目一览表2026.6.3.xlsx", 九化建: "12、九化建~主数据项目与QHSE项目一览表2026.6.3.xlsx",
|
||||
十一化建: "13、十一化建~主数据项目与QHSE项目一览表2026.6.3.xlsx", 十三化建: "14、十三化建~主数据项目与QHSE项目一览表2026.6.3.xlsx",
|
||||
十四化建: "15、十四化建~主数据项目与QHSE项目一览表2026.6.3.xlsx", 十六化建: "16、十六化建~主数据项目与QHSE项目一览表2026.6.3.xlsx",
|
||||
土木: "17、土木~主数据项目与QHSE项目一览表2026.6.3.xlsx", 装备: "18、装备~主数据项目与QHSE项目一览表2026.6.3.xlsx",
|
||||
华谊: "19、华谊~主数据项目与QHSE项目一览表2026.6.3.xlsx", 重机: "21、重机~主数据项目与QHSE项目一览表2026.6.3.xlsx",
|
||||
北建投: "22、北建投~主数据项目与QHSE项目一览表2026.6.3.xlsx", 南建投: "23、南建投~主数据项目与QHSE项目一览表2026.6.3.xlsx",
|
||||
城投: "24、城投~主数据项目与QHSE项目一览表2026.6.3.xlsx", 交建: "25、交建~主数据项目与QHSE项目一览表2026.6.3.xlsx",
|
||||
西南: "27、西南~主数据项目与QHSE项目一览表2026.6.3.xlsx"
|
||||
};
|
||||
|
||||
export const SYSTEMS = [
|
||||
{ key: "safety", label: "安全", type: 0, externalType: "1" },
|
||||
{ key: "quality", label: "质量", type: 1, externalType: "2" },
|
||||
{ key: "emergency", label: "应急", type: 0, externalType: "3" }
|
||||
] as const;
|
||||
|
||||
export type SystemKey = (typeof SYSTEMS)[number]["key"];
|
||||
export type DimensionKey = "all" | "new";
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Vue3 应用入口。
|
||||
* 依次注册 Pinia、路由和样式资源;原版 CSS 作为视觉基线,next.css 只承载 Vue3 版增量样式。
|
||||
*/
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "../../res/css/indexV2.base.css";
|
||||
import "../../res/css/indexV2.cards.css";
|
||||
import "../../res/css/indexV2.panels.css";
|
||||
import "../../res/css/indexV2.modal-table.css";
|
||||
import "../../res/css/indexV2.detail.css";
|
||||
import "../../res/css/indexV2.responsive.css";
|
||||
import "./styles/next.css";
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount("#app");
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 页面路由。
|
||||
* 使用哈希路由保证 dist 可直接部署到静态服务器,无需配置 history fallback;页面组件按路由懒加载。
|
||||
*/
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: "/", name: "group", component: () => import("../views/GroupView.vue") },
|
||||
{ path: "/enterprise/:unitId", name: "enterprise", component: () => import("../views/EnterpriseView.vue") },
|
||||
{ path: "/project/:unitId/:projectId", name: "project", component: () => import("../views/ProjectView.vue") }
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 同源 GET 请求封装。
|
||||
* 负责查询参数编码、请求 ID、超时/取消和业务错误归一化,不承载具体业务字段转换。
|
||||
*/
|
||||
import type { ApiEnvelope } from "../types/dashboard";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status = 0,
|
||||
public readonly requestId = ""
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function getJson<T>(
|
||||
url: string,
|
||||
params: Record<string, string | number | undefined>,
|
||||
options: { timeout?: number; signal?: AbortSignal } = {}
|
||||
): Promise<ApiEnvelope<T>> {
|
||||
const requestId = crypto.randomUUID();
|
||||
const target = new URL(url, window.location.origin);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
// 集团监控接口要求 unitId 即使为空也必须出现在查询字符串中。
|
||||
if (value !== undefined) target.searchParams.set(key, String(value));
|
||||
});
|
||||
|
||||
// 外部 signal 与内部超时共用一个控制器,调用方取消和超时都走相同错误出口。
|
||||
const timeoutController = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => timeoutController.abort(), options.timeout ?? 12000);
|
||||
const abort = () => timeoutController.abort();
|
||||
options.signal?.addEventListener("abort", abort, { once: true });
|
||||
|
||||
try {
|
||||
const response = await fetch(target, {
|
||||
headers: { Accept: "application/json", "X-Request-Id": requestId },
|
||||
cache: "no-store",
|
||||
signal: timeoutController.signal
|
||||
});
|
||||
if (!response.ok) throw new ApiError(`接口请求失败:HTTP ${response.status}`, response.status, requestId);
|
||||
const result = await response.json() as ApiEnvelope<T>;
|
||||
if (Number(result.code) !== 1) throw new ApiError(result.message || "接口返回业务错误", response.status, requestId);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
if (timeoutController.signal.aborted) throw new ApiError("接口请求超时或已取消", 0, requestId);
|
||||
throw new ApiError(error instanceof Error ? error.message : "接口请求失败", 0, requestId);
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
options.signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 看板核心状态仓库。
|
||||
* 统一管理筛选条件、集团/公司监控数据、项目详情、弹窗和指标穿透,页面只负责展示与触发 action。
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { API_ENDPOINTS, SYSTEMS, type DimensionKey, type SystemKey } from "../config/dashboard";
|
||||
import { normalizeMonitoring } from "../adapters/dashboard";
|
||||
import { getJson } from "../services/http";
|
||||
import type { DialogKind, MasterRecordVM, MonitoringVM } from "../types/dashboard";
|
||||
|
||||
const emptyMonitoring = (): MonitoringVM => normalizeMonitoring({});
|
||||
|
||||
function allowedSystemsFromUrl(): SystemKey[] {
|
||||
// 兼容外部系统沿用的 ?type=1,2 参数;非法值回退为全部系统。
|
||||
const raw = new URLSearchParams(window.location.search).get("type");
|
||||
if (!raw) return SYSTEMS.map((item) => item.key);
|
||||
const result = raw.split(/[,,\s]+/).map((part) => SYSTEMS.find((item) => item.externalType === part)?.key).filter(Boolean) as SystemKey[];
|
||||
return result.length ? [...new Set(result)] : SYSTEMS.map((item) => item.key);
|
||||
}
|
||||
|
||||
export const useDashboardStore = defineStore("dashboard", () => {
|
||||
const allowedSystems = ref<SystemKey[]>(allowedSystemsFromUrl());
|
||||
const system = ref<SystemKey>(allowedSystems.value[0] || "safety");
|
||||
const dimension = ref<DimensionKey>("all");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const monitoring = ref<MonitoringVM>(emptyMonitoring());
|
||||
const currentUnitId = ref("");
|
||||
const currentUnitName = ref("");
|
||||
const projectData = ref<Record<string, unknown> | null>(null);
|
||||
const projectLoading = ref(false);
|
||||
const projectError = ref("");
|
||||
const dialog = ref<DialogKind>(null);
|
||||
const selectedMetric = ref("");
|
||||
const metricAnalysis = ref<Record<string, unknown> | null>(null);
|
||||
const metricLoading = ref(false);
|
||||
const metricError = ref("");
|
||||
let monitoringRequest = 0;
|
||||
let monitoringController: AbortController | null = null;
|
||||
|
||||
const apiType = computed(() => SYSTEMS.find((item) => item.key === system.value)?.type ?? 0);
|
||||
const apiRange = computed(() => dimension.value === "new" ? 1 : 0);
|
||||
const allMetrics = computed(() => monitoring.value.metrics.length ? monitoring.value.metrics : monitoring.value.keyMetrics);
|
||||
const selectedMetricVM = computed(() => allMetrics.value.find((item) => item.name === selectedMetric.value) || allMetrics.value[0]);
|
||||
|
||||
async function loadMonitoring(unitId = "", unitName = "") {
|
||||
// 新请求开始前取消旧请求,并用递增序号阻止已失效响应覆盖最新筛选结果。
|
||||
const request = ++monitoringRequest;
|
||||
monitoringController?.abort();
|
||||
monitoringController = new AbortController();
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
currentUnitId.value = unitId;
|
||||
if (unitName) currentUnitName.value = unitName;
|
||||
try {
|
||||
const endpoint = unitId ? API_ENDPOINTS.unitMonitoring : API_ENDPOINTS.monitoring;
|
||||
const result = await getJson<unknown>(endpoint, { type: apiType.value, range: apiRange.value, unitId }, { signal: monitoringController.signal });
|
||||
if (request !== monitoringRequest) return;
|
||||
monitoring.value = normalizeMonitoring(result.data);
|
||||
} catch (reason) {
|
||||
if (request !== monitoringRequest) return;
|
||||
error.value = reason instanceof Error ? reason.message : "数据加载失败";
|
||||
monitoring.value = emptyMonitoring();
|
||||
} finally {
|
||||
if (request === monitoringRequest) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProject(unitId: string, projectId: string) {
|
||||
projectLoading.value = true;
|
||||
projectError.value = "";
|
||||
projectData.value = null;
|
||||
try {
|
||||
const result = await getJson<Record<string, unknown>>(API_ENDPOINTS.projectMonitoring, { type: apiType.value, unitId, projectId });
|
||||
projectData.value = result.data;
|
||||
} catch (reason) {
|
||||
projectError.value = reason instanceof Error ? reason.message : "项目数据加载失败";
|
||||
} finally {
|
||||
projectLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openMetric(name: string) {
|
||||
// 是否存在 unitId 决定调用集团指标穿透还是公司指标穿透接口。
|
||||
const metric = allMetrics.value.find((item) => item.name === name);
|
||||
if (!metric?.field) return;
|
||||
selectedMetric.value = name;
|
||||
dialog.value = currentUnitId.value ? "unitMetric" : "groupMetric";
|
||||
metricLoading.value = true;
|
||||
metricError.value = "";
|
||||
metricAnalysis.value = null;
|
||||
try {
|
||||
const endpoint = currentUnitId.value ? API_ENDPOINTS.unitMetric : API_ENDPOINTS.groupMetric;
|
||||
const result = await getJson<Record<string, unknown>>(endpoint, {
|
||||
type: apiType.value, range: apiRange.value, filed: metric.field, unitId: currentUnitId.value || undefined
|
||||
});
|
||||
metricAnalysis.value = result.data;
|
||||
} catch (reason) {
|
||||
metricError.value = reason instanceof Error ? reason.message : "指标穿透加载失败";
|
||||
} finally {
|
||||
metricLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setFilters(nextSystem: SystemKey, nextDimension: DimensionKey) {
|
||||
system.value = nextSystem;
|
||||
dimension.value = nextDimension;
|
||||
}
|
||||
|
||||
function findRecord(unitId: string): MasterRecordVM | undefined {
|
||||
return monitoring.value.records.find((item) => item.unitId === unitId);
|
||||
}
|
||||
|
||||
return {
|
||||
allowedSystems, system, dimension, loading, error, monitoring, currentUnitId, currentUnitName,
|
||||
projectData, projectLoading, projectError, dialog, selectedMetric, selectedMetricVM, metricAnalysis,
|
||||
metricLoading, metricError, allMetrics, loadMonitoring, loadProject, openMetric, setFilters, findRecord
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Vue3 应用壳层与 ECharts 容器补充;基础视觉继续复用原版模块化 CSS。 */
|
||||
#app { width: 100vw; height: 100vh; overflow: hidden; }
|
||||
#app-main { min-height: 0; }
|
||||
.echart-host { width: 100%; min-width: 0; }
|
||||
.error-panel { display: grid; place-items: center; gap: 14px; min-height: 420px; }
|
||||
.error-panel .ghost-btn { margin-bottom: 30px; }
|
||||
/* Vue3 指标卡内部结构。 */
|
||||
.warning-card { min-width: 0; }
|
||||
.warning-main { display: flex; align-items: baseline; gap: 6px; margin: 8px 0; }
|
||||
.warning-main strong { color: var(--cyan); font: 700 28px/1 var(--num); }
|
||||
.warning-meta { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 12px; }
|
||||
/* 图例语义色与左右分屏尺寸约束。 */
|
||||
.more-btn { text-decoration: none; }
|
||||
.online-dot { background: #5d93ff; }
|
||||
.usage-dot { background: #5ee1b5; }
|
||||
.accuracy-dot { background: #ffc857; }
|
||||
.trend-panel .chart-box, .enterprise-overview .chart-box { width: 100%; min-width: 0; }
|
||||
.group-left-stack .usage-report-panel { width: 100%; margin: 0; }
|
||||
/* 全局穿透弹窗和表格状态。 */
|
||||
.drill-modal { display: grid; }
|
||||
.drill-modal[hidden] { display: none; }
|
||||
.drill-modal-actions { display: flex; }
|
||||
.green-row { background: rgba(50, 175, 112, .16); }
|
||||
.table-link { color: var(--cyan); }
|
||||
.chart-click { cursor: pointer; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { DimensionKey, SystemKey } from "../config/dashboard";
|
||||
|
||||
/** 接口统一响应外壳。 */
|
||||
export interface ApiEnvelope<T> {
|
||||
code: number | string;
|
||||
message?: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/** 页面向数据治理接口发送的公共查询参数。 */
|
||||
export interface DashboardQuery {
|
||||
type: number;
|
||||
range: number;
|
||||
unitId?: string;
|
||||
projectId?: string;
|
||||
filed?: string;
|
||||
}
|
||||
|
||||
/** 指标在页面中的稳定模型,隔离后端 PascalCase/camelCase 字段差异。 */
|
||||
export interface MetricVM {
|
||||
name: string;
|
||||
field: string;
|
||||
projects: number;
|
||||
projectUsageNum: number;
|
||||
usageRate: number;
|
||||
accuracyRate: number;
|
||||
frequency: number;
|
||||
sortIndex: number;
|
||||
}
|
||||
|
||||
/** 集团上线统计的单位行模型。 */
|
||||
export interface MasterRecordVM {
|
||||
id: string;
|
||||
unitId: string;
|
||||
name: string;
|
||||
total: number;
|
||||
onlineAutoTotal: number;
|
||||
onlineReportedTotal: number;
|
||||
masterProjectCount: number;
|
||||
qhseProjectCount: number;
|
||||
linkedTotal: number;
|
||||
pendingTotal: number;
|
||||
noNeedTotal: number;
|
||||
noNeedCompletedTotal: number;
|
||||
noNeedStoppedTotal: number;
|
||||
noNeedRestrictedTotal: number;
|
||||
preProjectTotal: number;
|
||||
onlineRate: number;
|
||||
reportDate: string;
|
||||
webUrl: string;
|
||||
sortIndex: number;
|
||||
}
|
||||
|
||||
/** 集团或公司级顶部汇总模型。 */
|
||||
export interface SummaryVM {
|
||||
onlineActual: number;
|
||||
onlineExpected: number;
|
||||
onlineRate: number;
|
||||
usageRate: number;
|
||||
accuracyRate: number;
|
||||
preProjectTotal: number;
|
||||
pendingTotal: number;
|
||||
failProjectNum: number;
|
||||
noNeedProjectCount: number;
|
||||
masterProjectCount: number;
|
||||
qhseProjectCount: number;
|
||||
projectCount: number;
|
||||
proInfoText: string;
|
||||
dataDate: string;
|
||||
}
|
||||
|
||||
export interface UnitAnalysisVM {
|
||||
unitId: string;
|
||||
name: string;
|
||||
onlineRate: number;
|
||||
usageRate: number;
|
||||
accuracyRate: number;
|
||||
sortIndex: number;
|
||||
}
|
||||
|
||||
export interface ProjectAnalysisVM {
|
||||
unitId: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
isOnline: boolean;
|
||||
usageRate: number;
|
||||
accuracyRate: number;
|
||||
sortIndex: number;
|
||||
}
|
||||
|
||||
export interface FailProjectVM {
|
||||
unitId: string;
|
||||
projectId: string;
|
||||
unitName: string;
|
||||
abbreviation: string;
|
||||
projectName: string;
|
||||
shortName: string;
|
||||
usageRate: number;
|
||||
usage: string;
|
||||
sortIndex: number;
|
||||
}
|
||||
|
||||
export interface UnitMetricUsageVM {
|
||||
unitId: string;
|
||||
unitName: string;
|
||||
abbreviation: string;
|
||||
qhseTotal: number;
|
||||
activeNum: number;
|
||||
usageRate: number;
|
||||
date: string;
|
||||
sortIndex: number;
|
||||
}
|
||||
|
||||
export interface TrendVM {
|
||||
dates: string[];
|
||||
online: number[];
|
||||
usage: number[];
|
||||
accuracy: number[];
|
||||
}
|
||||
|
||||
/** 一次集团/公司监控请求归一化后的完整页面数据。 */
|
||||
export interface MonitoringVM {
|
||||
records: MasterRecordVM[];
|
||||
summary: SummaryVM;
|
||||
keyMetrics: MetricVM[];
|
||||
metrics: MetricVM[];
|
||||
units: UnitAnalysisVM[];
|
||||
projects: ProjectAnalysisVM[];
|
||||
failProjects: FailProjectVM[];
|
||||
unitMetricUsages: UnitMetricUsageVM[];
|
||||
trend: TrendVM;
|
||||
}
|
||||
|
||||
export interface AppFilters {
|
||||
system: SystemKey;
|
||||
dimension: DimensionKey;
|
||||
}
|
||||
|
||||
export type DialogKind = "online" | "belowStandard" | "unitUsage" | "groupMetric" | "unitMetric" | null;
|
||||
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
/** 公司级页面:按当前单位加载趋势、项目分析和指标分析,并支持继续下钻项目。 */
|
||||
import { computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import type { EChartsOption } from "echarts";
|
||||
import { EXTERNAL_LINKS } from "../config/dashboard";
|
||||
import { useDashboardStore } from "../stores/dashboard";
|
||||
import EChart from "../components/EChart.vue";
|
||||
import IndicatorAnalysis from "../components/IndicatorAnalysis.vue";
|
||||
import KpiCard from "../components/KpiCard.vue";
|
||||
import MetricStrip from "../components/MetricStrip.vue";
|
||||
|
||||
const store = useDashboardStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const unitId = computed(() => String(route.params.unitId));
|
||||
const unitName = computed(() =>
|
||||
String(route.query.name || store.currentUnitName || "企业"),
|
||||
);
|
||||
const summary = computed(() => store.monitoring.summary);
|
||||
const trendOption = computed<EChartsOption>(() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: { left: 48, right: 24, top: 8, bottom: 24 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: store.monitoring.trend.dates,
|
||||
axisLabel: { color: "#a8c7db" },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
max: 100,
|
||||
axisLabel: { color: "#a8c7db", formatter: "{value}%" },
|
||||
splitLine: { lineStyle: { color: "rgba(93,191,255,.14)" } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "上线率",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
data: store.monitoring.trend.online,
|
||||
color: "#5d93ff",
|
||||
},
|
||||
{
|
||||
name: "使用率",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
data: store.monitoring.trend.usage,
|
||||
color: "#5ee1b5",
|
||||
},
|
||||
{
|
||||
name: "准确率",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
data: store.monitoring.trend.accuracy,
|
||||
color: "#ffc857",
|
||||
},
|
||||
],
|
||||
}));
|
||||
const projectOption = computed<EChartsOption>(() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: { left: 48, right: 20, top: 8, bottom: 54 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: store.monitoring.projects.map((item) => item.name),
|
||||
axisLabel: { color: "#a8c7db", rotate: 24 },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
max: 100,
|
||||
axisLabel: { color: "#a8c7db", formatter: "{value}%" },
|
||||
splitLine: { lineStyle: { color: "rgba(93,191,255,.14)" } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "使用率",
|
||||
type: "bar",
|
||||
data: store.monitoring.projects.map((item) => item.usageRate),
|
||||
itemStyle: { color: "#5ee1b5" },
|
||||
},
|
||||
{
|
||||
name: "准确率",
|
||||
type: "bar",
|
||||
data: store.monitoring.projects.map((item) => item.accuracyRate),
|
||||
itemStyle: { color: "#ffc857" },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
function enterProject(index: number) {
|
||||
// 项目图表使用数据索引定位 projectId,单位名称放入 query 供项目面包屑和详情兜底。
|
||||
const project = store.monitoring.projects[index];
|
||||
if (!project) return;
|
||||
router.push({
|
||||
name: "project",
|
||||
params: {
|
||||
unitId: project.unitId || unitId.value,
|
||||
projectId: project.projectId,
|
||||
},
|
||||
query: { unitName: unitName.value },
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.currentUnitName = unitName.value;
|
||||
store.loadMonitoring(unitId.value, unitName.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.loading" class="empty-state">正在获取企业监控数据...</div>
|
||||
<div v-else-if="store.error" class="panel error-panel">
|
||||
<div class="empty-state">{{ store.error }}</div>
|
||||
<button class="ghost-btn" @click="store.loadMonitoring(unitId, unitName)">
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<section class="kpi-grid">
|
||||
<KpiCard
|
||||
label="项目上线率"
|
||||
:value="summary.proInfoText"
|
||||
:trend="`${summary.onlineRate.toFixed(2)}%`"
|
||||
description="上线项目/申请不用/应使用项目/项目总数"
|
||||
:progress="summary.onlineRate"
|
||||
clickable
|
||||
@select="store.dialog = 'online'"
|
||||
/>
|
||||
<KpiCard
|
||||
label="预立项项目数"
|
||||
:value="summary.preProjectTotal"
|
||||
unit="个"
|
||||
:progress="summary.preProjectTotal / 2"
|
||||
clickable
|
||||
@select="store.dialog = 'online'"
|
||||
/>
|
||||
<KpiCard
|
||||
label="待建项目数"
|
||||
:value="summary.pendingTotal"
|
||||
unit="个"
|
||||
:progress="summary.pendingTotal"
|
||||
clickable
|
||||
@select="store.dialog = 'online'"
|
||||
/>
|
||||
<KpiCard
|
||||
label="未达标项目数"
|
||||
:value="summary.failProjectNum"
|
||||
unit="个"
|
||||
description="指标使用率低于 80% 的项目"
|
||||
:progress="100 - summary.failProjectNum"
|
||||
clickable
|
||||
@select="store.dialog = 'belowStandard'"
|
||||
/>
|
||||
<KpiCard
|
||||
label="项目指标使用率"
|
||||
:value="`${summary.usageRate.toFixed(1)}%`"
|
||||
:progress="summary.usageRate"
|
||||
/>
|
||||
<KpiCard
|
||||
label="项目指标准确率"
|
||||
:value="`${summary.accuracyRate.toFixed(1)}%`"
|
||||
:progress="summary.accuracyRate"
|
||||
/>
|
||||
</section>
|
||||
<MetricStrip
|
||||
:metrics="store.monitoring.keyMetrics"
|
||||
@select="store.openMetric"
|
||||
/>
|
||||
<article class="panel trend-panel enterprise-trend-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">企业三率趋势曲线</h2>
|
||||
<div class="panel-note">
|
||||
近 7 日{{ unitName }}上线率、使用率、准确率变化。
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="legend-item"
|
||||
><span class="dot online-dot"></span>上线</span
|
||||
><span class="legend-item"
|
||||
><span class="dot usage-dot"></span>使用</span
|
||||
><span class="legend-item"
|
||||
><span class="dot accuracy-dot"></span>准确</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<EChart
|
||||
v-if="store.monitoring.trend.dates.length"
|
||||
:option="trendOption"
|
||||
:height="112"
|
||||
/>
|
||||
<div v-else class="empty-state compact-empty">暂无趋势数据</div>
|
||||
</div>
|
||||
</article>
|
||||
<section class="dashboard-grid enterprise-overview">
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">{{ unitName }} · 项目分析</h2>
|
||||
<div class="panel-note">
|
||||
项目级不统计上线率,点击项目柱状图进入项目明细。
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="legend-item"
|
||||
><span class="dot usage-dot"></span>使用率</span
|
||||
><span class="legend-item"
|
||||
><span class="dot accuracy-dot"></span>准确率</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<EChart
|
||||
v-if="store.monitoring.projects.length"
|
||||
:option="projectOption"
|
||||
:height="210"
|
||||
:min-width="
|
||||
Math.max(1100, store.monitoring.projects.length * 126 + 120)
|
||||
"
|
||||
@select="enterProject"
|
||||
/>
|
||||
<div v-else class="empty-state compact-empty">暂无项目分析数据</div>
|
||||
</div>
|
||||
<div class="explain">
|
||||
企业层用于回答:本单位哪些项目未上线、哪些项目上线后未使用指标、哪些项目数据连续未更新。
|
||||
</div>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">指标分析</h2>
|
||||
<div class="panel-note">
|
||||
按{{ unitName }}指标预警项目数、使用率和数据状态展示责任短板。
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
class="more-btn"
|
||||
:href="EXTERNAL_LINKS.enterpriseIndicatorMore"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>更多</a
|
||||
>
|
||||
</div>
|
||||
<IndicatorAnalysis
|
||||
:metrics="store.allMetrics"
|
||||
@select="store.openMetric"
|
||||
/>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
/** 集团级页面:展示集团汇总、重点指标、趋势、企业分析、报告入口和集团指标穿透。 */
|
||||
import { computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { EChartsOption } from "echarts";
|
||||
import { EXTERNAL_LINKS } from "../config/dashboard";
|
||||
import { useDashboardStore } from "../stores/dashboard";
|
||||
import EChart from "../components/EChart.vue";
|
||||
import IndicatorAnalysis from "../components/IndicatorAnalysis.vue";
|
||||
import KpiCard from "../components/KpiCard.vue";
|
||||
import MetricStrip from "../components/MetricStrip.vue";
|
||||
|
||||
const store = useDashboardStore();
|
||||
const router = useRouter();
|
||||
const summary = computed(() => store.monitoring.summary);
|
||||
const trendOption = computed<EChartsOption>(() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: { left: 48, right: 24, top: 8, bottom: 24 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: store.monitoring.trend.dates,
|
||||
axisLabel: { color: "#a8c7db" },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
max: 100,
|
||||
axisLabel: { color: "#a8c7db", formatter: "{value}%" },
|
||||
splitLine: { lineStyle: { color: "rgba(93,191,255,.14)" } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "上线率",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
data: store.monitoring.trend.online,
|
||||
color: "#5d93ff",
|
||||
},
|
||||
{
|
||||
name: "使用率",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
data: store.monitoring.trend.usage,
|
||||
color: "#5ee1b5",
|
||||
},
|
||||
{
|
||||
name: "准确率",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
data: store.monitoring.trend.accuracy,
|
||||
color: "#ffc857",
|
||||
},
|
||||
],
|
||||
}));
|
||||
const enterpriseOption = computed<EChartsOption>(() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: { left: 48, right: 20, top: 8, bottom: 54 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: store.monitoring.units.map((item) => item.name),
|
||||
axisLabel: { color: "#a8c7db", rotate: 28 },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
max: 100,
|
||||
axisLabel: { color: "#a8c7db", formatter: "{value}%" },
|
||||
splitLine: { lineStyle: { color: "rgba(93,191,255,.14)" } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "上线率",
|
||||
type: "bar",
|
||||
data: store.monitoring.units.map((item) => item.onlineRate),
|
||||
itemStyle: { color: "#5d93ff" },
|
||||
},
|
||||
{
|
||||
name: "使用率",
|
||||
type: "bar",
|
||||
data: store.monitoring.units.map((item) => item.usageRate),
|
||||
itemStyle: { color: "#5ee1b5" },
|
||||
},
|
||||
{
|
||||
name: "准确率",
|
||||
type: "bar",
|
||||
data: store.monitoring.units.map((item) => item.accuracyRate),
|
||||
itemStyle: { color: "#ffc857" },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
function enterEnterprise(index: number) {
|
||||
// ECharts 点击事件返回 dataIndex,以同序单位模型构造公司路由。
|
||||
const unit = store.monitoring.units[index];
|
||||
if (!unit) return;
|
||||
store.currentUnitName = unit.name;
|
||||
router.push({
|
||||
name: "enterprise",
|
||||
params: { unitId: unit.unitId },
|
||||
query: { name: unit.name },
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => store.loadMonitoring());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.loading" class="empty-state">正在获取集团监控数据...</div>
|
||||
<div v-else-if="store.error" class="panel error-panel">
|
||||
<div class="empty-state">{{ store.error }}</div>
|
||||
<button class="ghost-btn" @click="store.loadMonitoring()">重新加载</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<section class="kpi-grid">
|
||||
<KpiCard
|
||||
label="集团项目上线率"
|
||||
:value="summary.proInfoText"
|
||||
:trend="`${summary.onlineRate.toFixed(2)}%`"
|
||||
description="上线项目/申请不用/应使用项目/项目总数"
|
||||
:progress="summary.onlineRate"
|
||||
clickable
|
||||
@select="store.dialog = 'online'"
|
||||
/>
|
||||
<KpiCard
|
||||
label="预立项项目数"
|
||||
:value="summary.preProjectTotal"
|
||||
unit="个"
|
||||
description="点击查看集团项目上线统计"
|
||||
:progress="summary.preProjectTotal / 2"
|
||||
clickable
|
||||
@select="store.dialog = 'online'"
|
||||
/>
|
||||
<KpiCard
|
||||
label="待建项目数"
|
||||
:value="summary.pendingTotal"
|
||||
unit="个"
|
||||
description="点击查看集团项目上线统计"
|
||||
:progress="summary.pendingTotal"
|
||||
clickable
|
||||
@select="store.dialog = 'online'"
|
||||
/>
|
||||
<KpiCard
|
||||
label="未达标项目数"
|
||||
:value="summary.failProjectNum"
|
||||
unit="个"
|
||||
description="指标使用率低于 80% 的项目"
|
||||
:progress="100 - summary.failProjectNum"
|
||||
clickable
|
||||
@select="store.dialog = 'belowStandard'"
|
||||
/>
|
||||
<KpiCard
|
||||
label="项目指标使用率"
|
||||
:value="`${summary.usageRate.toFixed(1)}%`"
|
||||
description="点击查看单位项目指标使用情况"
|
||||
:progress="summary.usageRate"
|
||||
clickable
|
||||
@select="store.dialog = 'unitUsage'"
|
||||
/>
|
||||
<KpiCard
|
||||
label="项目指标准确率"
|
||||
:value="`${summary.accuracyRate.toFixed(1)}%`"
|
||||
:progress="summary.accuracyRate"
|
||||
/>
|
||||
</section>
|
||||
<MetricStrip
|
||||
:metrics="store.monitoring.keyMetrics"
|
||||
@select="store.openMetric"
|
||||
/>
|
||||
<article class="panel trend-panel group-trend-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">集团三率趋势曲线</h2>
|
||||
<div class="panel-note">近 7 日集团上线率、使用率、准确率变化。</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="legend-item"
|
||||
><span class="dot online-dot"></span>上线</span
|
||||
><span class="legend-item"
|
||||
><span class="dot usage-dot"></span>使用</span
|
||||
><span class="legend-item"
|
||||
><span class="dot accuracy-dot"></span>准确</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<EChart
|
||||
v-if="store.monitoring.trend.dates.length"
|
||||
:option="trendOption"
|
||||
:height="112"
|
||||
/>
|
||||
<div v-else class="empty-state compact-empty">暂无趋势数据</div>
|
||||
</div>
|
||||
</article>
|
||||
<section class="group-bottom-grid">
|
||||
<div class="group-left-stack">
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">企业分析</h2>
|
||||
<div class="panel-note">点击企业柱状图进入企业级看板。</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="legend-item"
|
||||
><span class="dot online-dot"></span>上线率</span
|
||||
><span class="legend-item"
|
||||
><span class="dot usage-dot"></span>使用率</span
|
||||
><span class="legend-item"
|
||||
><span class="dot accuracy-dot"></span>准确率</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<EChart
|
||||
v-if="store.monitoring.units.length"
|
||||
:option="enterpriseOption"
|
||||
:height="132"
|
||||
:min-width="
|
||||
Math.max(1180, store.monitoring.units.length * 88 + 120)
|
||||
"
|
||||
@select="enterEnterprise"
|
||||
/>
|
||||
<div v-else class="empty-state compact-empty">暂无企业分析数据</div>
|
||||
</div>
|
||||
</article>
|
||||
<article
|
||||
class="panel usage-report-panel chart-click"
|
||||
@click="store.dialog = 'online'"
|
||||
>
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">集团在建项目使用分析报告</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="more-btn"
|
||||
@click.stop="store.dialog = 'online'"
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<article class="panel risk-panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">指标分析</h2>
|
||||
<a
|
||||
class="more-btn"
|
||||
:href="EXTERNAL_LINKS.indicatorMore"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>更多</a
|
||||
>
|
||||
</div>
|
||||
<IndicatorAnalysis
|
||||
:metrics="store.allMetrics"
|
||||
:limit="8"
|
||||
@select="store.openMetric"
|
||||
/>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
/** 项目级页面:展示项目基本信息、治理指标历史值和异常状态。 */
|
||||
import { computed, onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useDashboardStore } from "../stores/dashboard";
|
||||
|
||||
const store = useDashboardStore();
|
||||
const route = useRoute();
|
||||
const data = computed(() => store.projectData || {});
|
||||
const project = computed(
|
||||
() => (data.value.MetricProjectInfo || {}) as Record<string, unknown>,
|
||||
);
|
||||
const rows = computed(() =>
|
||||
Array.isArray(data.value.MetricDataAnalysises)
|
||||
? (data.value.MetricDataAnalysises as Record<string, unknown>[])
|
||||
: [],
|
||||
);
|
||||
const display = (value: unknown) =>
|
||||
value == null || value === "" ? "-" : String(value);
|
||||
// 接口日期可能含时间部分,项目详情只展示业务日期。
|
||||
const date = (value: unknown) => (value ? String(value).split("T")[0] : "-");
|
||||
|
||||
onMounted(() =>
|
||||
store.loadProject(
|
||||
String(route.params.unitId),
|
||||
String(route.params.projectId),
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.projectLoading" class="empty-state">
|
||||
正在获取项目监控数据...
|
||||
</div>
|
||||
<div v-else-if="store.projectError" class="panel error-panel">
|
||||
<div class="empty-state">{{ store.projectError }}</div>
|
||||
<button
|
||||
class="ghost-btn"
|
||||
@click="
|
||||
store.loadProject(
|
||||
String(route.params.unitId),
|
||||
String(route.params.projectId),
|
||||
)
|
||||
"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<section class="metric-cards project-metric-cards">
|
||||
<article class="mini-panel">
|
||||
<div class="mini-label">达标状态</div>
|
||||
<div class="mini-value">{{ display(data.UsageState) }}</div>
|
||||
</article>
|
||||
<article class="mini-panel">
|
||||
<div class="mini-label">指标使用数量</div>
|
||||
<div class="mini-value">{{ display(data.MetricUsageInfo) }}</div>
|
||||
</article>
|
||||
<article class="mini-panel">
|
||||
<div class="mini-label">准确数量</div>
|
||||
<div class="mini-value">{{ display(data.MetricAccuracyInfo) }}</div>
|
||||
</article>
|
||||
<article class="mini-panel">
|
||||
<div class="mini-label">预警指标</div>
|
||||
<div class="mini-value">
|
||||
{{ Number(data.AbnormalNum || 0) }}<span class="kpi-unit">项</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section class="project-layout">
|
||||
<article class="panel">
|
||||
<div class="panel-header"><h2 class="panel-title">项目信息</h2></div>
|
||||
<div class="field-list">
|
||||
<div class="field-row">
|
||||
<div class="field-name">所属企业</div>
|
||||
<div class="field-value">
|
||||
{{ display(project.UnitName || route.query.unitName) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-name">项目名称</div>
|
||||
<div class="field-value">
|
||||
{{ display(project.ProjectName || project.ProjectShortName) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-name">项目编号</div>
|
||||
<div class="field-value">{{ display(project.ProjectCode) }}</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-name">项目地址</div>
|
||||
<div class="field-value">{{ display(project.ProjectAddress) }}</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-name">项目状态</div>
|
||||
<div class="field-value">
|
||||
{{ display(project.ProjectState || data.OnlineState) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-name">开工日期</div>
|
||||
<div class="field-value">{{ date(project.StartDate) }}</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-name">数据日期</div>
|
||||
<div class="field-value">{{ date(data.DataDate) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<div class="panel-header"><h2 class="panel-title">指标分析</h2></div>
|
||||
<div v-if="!rows.length" class="empty-state">暂无项目指标数据。</div>
|
||||
<div v-else class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>指标名称</th>
|
||||
<th>是否异常</th>
|
||||
<th>对比</th>
|
||||
<th>今日</th>
|
||||
<th>昨日</th>
|
||||
<th>三天前</th>
|
||||
<th>一周前</th>
|
||||
<th>一个月前</th>
|
||||
<th>三个月前</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in rows" :key="index">
|
||||
<td>{{ row.Name || row.name }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="status-pill"
|
||||
:class="row.IsAbnormal || row.isAbnormal ? 'bad' : 'ok'"
|
||||
>{{
|
||||
row.IsAbnormal || row.isAbnormal ? "异常" : "正常"
|
||||
}}</span
|
||||
>
|
||||
</td>
|
||||
<td>{{ row.Compare || row.compare || "-" }}</td>
|
||||
<td>{{ row.NowData ?? "-" }}</td>
|
||||
<td>{{ row.YesterdayData ?? "-" }}</td>
|
||||
<td>{{ row.ThreeDaysAgoData ?? "-" }}</td>
|
||||
<td>{{ row.WeekAgoData ?? "-" }}</td>
|
||||
<td>{{ row.MonthAgoData ?? "-" }}</td>
|
||||
<td>{{ row.ThreeMonthsAgoData ?? "-" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client", "node"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Vite 开发与构建配置。
|
||||
* 开发环境将同源 /qhse_webapi 请求转发到正式接口域名,并从服务端环境变量注入 token。
|
||||
*/
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import type { ClientRequest } from "node:http";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "QHSE_");
|
||||
return {
|
||||
plugins: [vue()],
|
||||
base: "./",
|
||||
server: {
|
||||
port: 18081,
|
||||
proxy: {
|
||||
"/qhse_webapi": {
|
||||
target: "https://qhse.cncecoa.com",
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
timeout: 15000,
|
||||
proxyTimeout: 15000,
|
||||
configure(proxy) {
|
||||
// QHSE_ 变量仅存在于 Node 侧,不能使用会暴露到浏览器的 VITE_ 前缀。
|
||||
(proxy as unknown as { on: (event: string, callback: (request: ClientRequest) => void) => void }).on("proxyReq", (proxyRequest) => {
|
||||
if (env.QHSE_API_TOKEN) proxyRequest.setHeader("token", env.QHSE_API_TOKEN);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user