vue3 升级

This commit is contained in:
2026-07-14 10:56:51 +08:00
parent 25ae6f610c
commit 9d02c0a840
26 changed files with 4456 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# 仅供本地 Vite 代理读取,不会通过 VITE_ 前缀打包到浏览器。
QHSE_API_TOKEN='AF17168B-87BD-4GLY-1111-F0A0A1158F9B'
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.env
.env.local
.env.*.local
+148
View File
@@ -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`。
+13
View File
@@ -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>
+2294
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
+89
View File
@@ -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>
+36
View File
@@ -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([]);
});
});
+158
View File
@@ -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) : []
}
};
}
+380
View File
@@ -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>
+43
View File
@@ -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>
+67
View File
@@ -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>
+33
View File
@@ -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>
+27
View File
@@ -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>
+47
View File
@@ -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";
+17
View File
@@ -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");
+14
View File
@@ -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") }
]
});
+54
View File
@@ -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);
}
}
+120
View File
@@ -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
};
});
+29
View File
@@ -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; }
}
+139
View File
@@ -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;
+251
View File
@@ -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>
+258
View File
@@ -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>
+154
View File
@@ -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>
+18
View File
@@ -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"]
}
+33
View File
@@ -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);
});
}
}
}
}
};
});