代码整合

This commit is contained in:
2026-07-24 09:43:52 +08:00
parent 8fd4cca652
commit 9d447346ad
16 changed files with 197 additions and 47 deletions
+2
View File
@@ -0,0 +1,2 @@
{
}
+5 -4
View File
@@ -65,12 +65,13 @@ next/
### 状态管理
`stores/dashboard.ts` 是唯一业务状态入口,主要状态分为:
`stores/dashboardStore.ts` 是唯一业务状态入口,主要状态分为:
- 筛选状态:`system`、`dimension`、`allowedSystems`。
- 监控状态:集团或当前公司的 `monitoring`、`loading`、`error`。
- 项目状态:`projectData`、`projectLoading`、`projectError`。
- 穿透状态:`dialog`、`selectedMetric`、`metricAnalysis`、`metricLoading`。
- 指标列表弹窗:集团和企业复用 `allMetrics`,使用率低于 70% 的指标显示红色告警卡片。
- 动不动报告状态:`moveReport`、`moveReportLoading`、`moveReportError`。
集团和公司监控共用 `loadMonitoring`。新请求开始时会取消旧请求,并通过递增请求序号阻止旧响应覆盖最新筛选结果。项目详情和指标穿透分别使用独立 Action,避免页面组件自行维护请求状态。
@@ -89,7 +90,7 @@ next/
集团监控接口要求查询字符串始终包含 `unitId=`,即使值为空也不能省略。`services/http.ts` 因此只过滤 `undefined`,显式空字符串会被保留。
`adapters/dashboard.ts` 负责兼容 PascalCase/camelCase 字段、非法数字、布尔字符串和空数组,并集中维护上线率口径:
`adapters/dashboardAdapter.ts` 负责兼容 PascalCase/camelCase 字段、非法数字、布尔字符串和空数组,并集中维护上线率口径:
```text
(已关联项目数 + 预立项项目数) / (主数据项目数 + 预立项项目数 - 申请不用项目数)
@@ -108,7 +109,7 @@ next/
### 安全与部署边界
- 浏览器只访问同源 `/qhse_webapi/`,由开发服务器或生产网关转发到正式接口。
- 接口 token 固定在 `config/dashboard.ts`,由 `services/http.ts` 写入每个请求的 `token` 请求头。
- 接口 token 固定在 `config/dashboardConfig.ts`,由 `services/http.ts` 写入每个请求的 `token` 请求头。
- token 会进入浏览器构建产物,只适用于当前受控内网部署环境。
- 生产环境仍需将 `/qhse_webapi/` 反向代理到 `https://qhse.cncecoa.com/qhse_webapi`,但无需在网关重复注入 token。
- 网关应配置上游超时、请求 ID 透传、访问日志和 5xx 告警。
@@ -143,7 +144,7 @@ npm run build
## 开发约定
- 新接口先在 `config/dashboard.ts` 注册,再通过 `services/http.ts` 请求。
- 新接口先在 `config/dashboardConfig.ts` 注册,再通过 `services/http.ts` 请求。
- 后端响应必须先经过 `adapters/`,页面不得直接依赖原始字段命名。
- 跨页面状态进入 Pinia;仅组件内部使用的展示状态保留在组件中。
- 通用展示提取到 `components/`,集团、公司、项目编排保留在 `views/`。
+2 -2
View File
@@ -5,8 +5,8 @@
*/
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";
import { EXTERNAL_LINKS, SYSTEMS, type DimensionKey, type SystemKey } from "./config/dashboardConfig";
import { useDashboardStore } from "./stores/dashboardStore";
const DrillModal = defineAsyncComponent(() => import("./components/DrillModal.vue"));
@@ -1,6 +1,6 @@
/** 数据适配层回归测试:锁定接口兼容规则和核心统计口径。 */
import { describe, expect, it } from "vitest";
import { normalizeMonitoring, normalizeMoveReport } from "./dashboard";
import { normalizeMonitoring, normalizeMoveReport } from "./dashboardAdapter";
describe("normalizeMonitoring", () => {
it("按业务口径归一化集团汇总、单位和指标数据", () => {
@@ -12,7 +12,7 @@ import type {
SummaryVM,
UnitAnalysisVM,
UnitMetricUsageVM
} from "../types/dashboard";
} from "../types/dashboardTypes";
type Raw = Record<string, unknown>;
+58 -6
View File
@@ -1,15 +1,15 @@
<script setup lang="ts">
/**
* 全局穿透弹窗。
* 根据 store.dialog 渲染上线统计、未达标清单、单位使用率或指标穿透,避免每个页面维护独立弹窗状态。
* 根据 store.dialog 渲染上线统计、指标卡片、报告或指标穿透,避免每个页面维护独立弹窗状态。
*/
import { computed } from "vue";
import type { EChartsOption } from "echarts";
import { useDashboardStore } from "../stores/dashboard";
import { useDashboardStore } from "../stores/dashboardStore";
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";
import { normalizeMetric, numberValue } from "../adapters/dashboardAdapter";
import { EXTERNAL_LINKS, UNIT_REPORT_FILES } from "../config/dashboardConfig";
import type { MasterRecordVM } from "../types/dashboardTypes";
const store = useDashboardStore();
const raw = computed(() => store.metricAnalysis || {});
@@ -64,6 +64,11 @@ const unitChart = computed<EChartsOption>(() => ({
],
}));
const title = computed(() => {
if (store.dialog === "indicatorAnalysis") {
return store.currentUnitId
? `${store.currentUnitName || "企业"} · 指标分析`
: "集团指标分析";
}
const labels = {
online: "集团项目上线统计",
moveReport: "数据治理“动不动”分析报告",
@@ -91,6 +96,9 @@ const onlineRateTotal = computed(() => {
const moveTableMinWidth = computed(() =>
Math.max(980, 420 + store.moveReport.metrics.length * 138),
);
const lowUsageMetricCount = computed(() =>
store.allMetrics.filter((item) => item.usageRate < 70).length,
);
const sum = (field: keyof MasterRecordVM) =>
store.monitoring.records.reduce(
@@ -308,7 +316,7 @@ function previewUnitReport(unit: string) {
class="panel drill-panel"
>
<div v-if="!store.monitoring.failProjects.length" class="empty-state">
当前暂无指标使用率低于 80% 的项目。
当前暂无指标使用率低于 70% 的项目。
</div>
<div v-else class="table-wrap">
<table>
@@ -372,6 +380,50 @@ function previewUnitReport(unit: string) {
</div>
</section>
<section
v-else-if="store.dialog === 'indicatorAnalysis'"
class="indicator-card-panel"
>
<div class="indicator-card-summary">
<span>指标总数:{{ store.allMetrics.length }}</span>
<span>使用率低于70%:{{ lowUsageMetricCount }}</span>
</div>
<div v-if="!store.allMetrics.length" class="empty-state">
当前暂无指标分析数据。
</div>
<div v-else class="indicator-card-grid">
<button
v-for="metricItem in store.allMetrics"
:key="metricItem.field || metricItem.name"
type="button"
class="indicator-summary-card"
:class="{ 'low-usage': metricItem.usageRate < 70 }"
@click="store.openMetric(metricItem.name)"
>
<div class="indicator-summary-head">
<h3 :title="metricItem.name">{{ metricItem.name }}</h3>
<span class="indicator-usage-state">
{{ metricItem.usageRate < 70 ? "低于70%" : "正常" }}
</span>
</div>
<div class="indicator-summary-stats">
<div>
<span>项目数</span>
<strong>{{ metricItem.projects }}</strong>
</div>
<div>
<span>使用率</span>
<strong>{{ metricItem.usageRate.toFixed(1) }}%</strong>
</div>
<div>
<span>准确率</span>
<strong>{{ metricItem.accuracyRate.toFixed(1) }}%</strong>
</div>
</div>
</button>
</div>
</section>
<template v-else-if="metric">
<section class="drill-hero">
<article class="drill-title-card">
+1 -1
View File
@@ -4,7 +4,7 @@
* 每行固定展示项目覆盖量、使用率和准确率三条进度,用于集团页与公司页复用。
*/
import { computed } from "vue";
import type { MetricVM } from "../types/dashboard";
import type { MetricVM } from "../types/dashboardTypes";
const props = withDefaults(
defineProps<{ metrics: MetricVM[]; limit?: number }>(),
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/** 顶部重点指标条,点击指标后由父页面发起集团或公司级穿透。 */
import type { MetricVM } from "../types/dashboard";
import type { MetricVM } from "../types/dashboardTypes";
defineProps<{ metrics: MetricVM[] }>();
defineEmits<{ select: [name: string] }>();
</script>
+2 -2
View File
@@ -2,8 +2,8 @@
* 同源 GET 请求封装。
* 负责查询参数编码、请求 ID、超时/取消和业务错误归一化,不承载具体业务字段转换。
*/
import type { ApiEnvelope } from "../types/dashboard";
import { API_TOKEN } from "../config/dashboard";
import type { ApiEnvelope } from "../types/dashboardTypes";
import { API_TOKEN } from "../config/dashboardConfig";
export class ApiError extends Error {
constructor(
@@ -4,10 +4,10 @@
*/
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { API_ENDPOINTS, SYSTEMS, type DimensionKey, type SystemKey } from "../config/dashboard";
import { normalizeMonitoring, normalizeMoveReport } from "../adapters/dashboard";
import { API_ENDPOINTS, SYSTEMS, type DimensionKey, type SystemKey } from "../config/dashboardConfig";
import { normalizeMonitoring, normalizeMoveReport } from "../adapters/dashboardAdapter";
import { getJson } from "../services/http";
import type { DialogKind, MasterRecordVM, MonitoringVM, MoveReportVM } from "../types/dashboard";
import type { DialogKind, MasterRecordVM, MonitoringVM, MoveReportVM } from "../types/dashboardTypes";
const emptyMonitoring = (): MonitoringVM => normalizeMonitoring({});
const emptyMoveReport = (): MoveReportVM => ({ metrics: [], rows: [] });
@@ -109,6 +109,13 @@ export const useDashboardStore = defineStore("dashboard", () => {
}
}
function openIndicatorAnalysis() {
// 集团和企业共用当前监控数据,弹窗不重复请求;点击卡片后再按层级调用指标穿透接口。
metricError.value = "";
metricAnalysis.value = null;
dialog.value = "indicatorAnalysis";
}
async function openMoveReport() {
// 动态表头和企业数据必须使用同一组 type/range 快照,防止切换筛选时新旧结果混用。
const request = ++moveReportRequest;
@@ -160,6 +167,6 @@ export const useDashboardStore = defineStore("dashboard", () => {
allowedSystems, system, dimension, loading, error, monitoring, currentUnitId, currentUnitName,
projectData, projectLoading, projectError, dialog, selectedMetric, selectedMetricVM, metricAnalysis,
metricLoading, metricError, moveReport, moveReportLoading, moveReportError, allMetrics,
loadMonitoring, loadProject, openMetric, openMoveReport, closeDialog, setFilters, findRecord
loadMonitoring, loadProject, openMetric, openIndicatorAnalysis, openMoveReport, closeDialog, setFilters, findRecord
};
});
+90
View File
@@ -55,6 +55,96 @@
.move-report-table td:first-child { width: 112px; min-width: 112px; }
.move-report-table tbody tr:first-child td { color: #ffffff; font-weight: 900; background: rgba(73, 214, 255, 0.14); }
/* 指标分析弹窗使用响应式卡片网格,低使用率指标采用明确的红色告警背景。 */
.indicator-card-panel { min-height: 100%; }
.indicator-card-summary {
display: flex;
justify-content: flex-end;
gap: 18px;
margin-bottom: 12px;
color: #bfeaff;
font-size: 13px;
font-weight: 700;
}
.indicator-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
align-content: start;
}
.indicator-summary-card {
min-width: 0;
min-height: 138px;
padding: 15px;
border: 1px solid rgba(91, 180, 255, 0.38);
border-radius: 6px;
color: #effaff;
background: #08375d;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
font: inherit;
text-align: left;
cursor: pointer;
}
.indicator-summary-card:hover { border-color: #72d9ff; background: #0b4974; }
.indicator-summary-card.low-usage {
/* border-color: #ff7b87; */
background: #0B4974;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 0 16px rgba(255, 75, 91, 0.16);
}
.indicator-summary-card.low-usage:hover { background: #86293a; }
.indicator-summary-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
min-height: 48px;
}
.indicator-summary-head h3 {
min-width: 0;
margin: 0;
overflow: hidden;
color: #ffffff;
font-size: 16px;
line-height: 1.45;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.indicator-usage-state {
flex: 0 0 auto;
padding: 3px 7px;
border-radius: 4px;
color: #bdf9e4;
background: rgba(34, 139, 103, 0.46);
font-size: 11px;
font-weight: 800;
}
.low-usage .indicator-usage-state { color: #ffffff; background: #b93246; }
.indicator-summary-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding-top: 12px;
border-top: 1px solid rgba(207, 239, 255, 0.16);
}
.indicator-summary-stats div { min-width: 0; }
.indicator-summary-stats span {
display: block;
margin-bottom: 4px;
color: #b8d6e8;
font-size: 11px;
}
.indicator-summary-stats strong {
display: block;
overflow: hidden;
color: #ffffff;
font-family: var(--num);
font-size: 17px;
text-overflow: ellipsis;
white-space: nowrap;
}
.low-usage .indicator-summary-stats span { color: #ffd6db; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; }
}
@@ -1,4 +1,4 @@
import type { DimensionKey, SystemKey } from "../config/dashboard";
import type { DimensionKey, SystemKey } from "../config/dashboardConfig";
/** 接口统一响应外壳。 */
export interface ApiEnvelope<T> {
@@ -159,4 +159,4 @@ export interface AppFilters {
dimension: DimensionKey;
}
export type DialogKind = "online" | "moveReport" | "belowStandard" | "unitUsage" | "groupMetric" | "unitMetric" | null;
export type DialogKind = "online" | "moveReport" | "belowStandard" | "unitUsage" | "indicatorAnalysis" | "groupMetric" | "unitMetric" | null;
+10 -11
View File
@@ -3,8 +3,7 @@
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 { useDashboardStore } from "../stores/dashboardStore";
import EChart from "../components/EChart.vue";
import IndicatorAnalysis from "../components/IndicatorAnalysis.vue";
import KpiCard from "../components/KpiCard.vue";
@@ -120,13 +119,13 @@ onMounted(() => {
label="项目上线率"
:value="summary.proInfoText"
:trend="`${summary.onlineRate.toFixed(2)}%`"
description="上线项目/申请不用/应使用项目/项目总数"
description="上线项目/申请关闭/应使用项目/项目总数"
:progress="summary.onlineRate"
clickable
@select="store.dialog = 'online'"
/>
<KpiCard
label="预立项项目数"
label="待关联项目数"
:value="summary.preProjectTotal"
unit="个"
:progress="summary.preProjectTotal / 2"
@@ -134,7 +133,7 @@ onMounted(() => {
@select="store.dialog = 'online'"
/>
<KpiCard
label="待建项目数"
label="待上线项目数"
:value="summary.pendingTotal"
unit="个"
:progress="summary.pendingTotal"
@@ -145,7 +144,7 @@ onMounted(() => {
label="未达标项目数"
:value="summary.failProjectNum"
unit="个"
description="指标使用率低于 80% 的项目"
description="指标使用率低于 70% 的项目"
:progress="100 - summary.failProjectNum"
clickable
@select="store.dialog = 'belowStandard'"
@@ -233,13 +232,13 @@ onMounted(() => {
按{{ unitName }}指标预警项目数、使用率和数据状态展示责任短板。
</div>
</div>
<a
<button
type="button"
class="more-btn"
:href="EXTERNAL_LINKS.enterpriseIndicatorMore"
target="_blank"
rel="noopener noreferrer"
>更多</a
@click="store.openIndicatorAnalysis()"
>
更多
</button>
</div>
<IndicatorAnalysis
:metrics="store.allMetrics"
+10 -11
View File
@@ -3,8 +3,7 @@
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 { useDashboardStore } from "../stores/dashboardStore";
import EChart from "../components/EChart.vue";
import IndicatorAnalysis from "../components/IndicatorAnalysis.vue";
import KpiCard from "../components/KpiCard.vue";
@@ -114,13 +113,13 @@ onMounted(() => store.loadMonitoring());
label="集团项目上线率"
:value="summary.proInfoText"
:trend="`${summary.onlineRate.toFixed(2)}%`"
description="上线项目/申请不用/应使用项目/项目总数"
description="上线项目/申请关闭/应使用项目/项目总数"
:progress="summary.onlineRate"
clickable
@select="store.dialog = 'online'"
/>
<KpiCard
label="预立项项目数"
label="待关联项目数"
:value="summary.preProjectTotal"
unit="个"
description="点击查看集团项目上线统计"
@@ -129,7 +128,7 @@ onMounted(() => store.loadMonitoring());
@select="store.dialog = 'online'"
/>
<KpiCard
label="待建项目数"
label="待上线项目数"
:value="summary.pendingTotal"
unit="个"
description="点击查看集团项目上线统计"
@@ -141,7 +140,7 @@ onMounted(() => store.loadMonitoring());
label="未达标项目数"
:value="summary.failProjectNum"
unit="个"
description="指标使用率低于 80% 的项目"
description="指标使用率低于 70% 的项目"
:progress="100 - summary.failProjectNum"
clickable
@select="store.dialog = 'belowStandard'"
@@ -245,13 +244,13 @@ onMounted(() => store.loadMonitoring());
<article class="panel risk-panel">
<div class="panel-header">
<h2 class="panel-title">指标分析</h2>
<a
<button
type="button"
class="more-btn"
:href="EXTERNAL_LINKS.indicatorMore"
target="_blank"
rel="noopener noreferrer"
>更多</a
@click="store.openIndicatorAnalysis()"
>
更多
</button>
</div>
<IndicatorAnalysis
:metrics="store.allMetrics"
+1 -1
View File
@@ -2,7 +2,7 @@
/** 项目级页面:展示项目基本信息、治理指标历史值和异常状态。 */
import { computed, onMounted } from "vue";
import { useRoute } from "vue-router";
import { useDashboardStore } from "../stores/dashboard";
import { useDashboardStore } from "../stores/dashboardStore";
const store = useDashboardStore();
const route = useRoute();