diff --git a/next/.vscode/settings.json b/next/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/next/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/next/README.md b/next/README.md index fd178c0..d684880 100644 --- a/next/README.md +++ b/next/README.md @@ -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/`。 diff --git a/next/src/App.vue b/next/src/App.vue index 1088a17..4507b23 100644 --- a/next/src/App.vue +++ b/next/src/App.vue @@ -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")); diff --git a/next/src/adapters/dashboard.test.ts b/next/src/adapters/dashboardAdapter.test.ts similarity index 99% rename from next/src/adapters/dashboard.test.ts rename to next/src/adapters/dashboardAdapter.test.ts index 463fe6d..7440466 100644 --- a/next/src/adapters/dashboard.test.ts +++ b/next/src/adapters/dashboardAdapter.test.ts @@ -1,6 +1,6 @@ /** 数据适配层回归测试:锁定接口兼容规则和核心统计口径。 */ import { describe, expect, it } from "vitest"; -import { normalizeMonitoring, normalizeMoveReport } from "./dashboard"; +import { normalizeMonitoring, normalizeMoveReport } from "./dashboardAdapter"; describe("normalizeMonitoring", () => { it("按业务口径归一化集团汇总、单位和指标数据", () => { diff --git a/next/src/adapters/dashboard.ts b/next/src/adapters/dashboardAdapter.ts similarity index 99% rename from next/src/adapters/dashboard.ts rename to next/src/adapters/dashboardAdapter.ts index fb2c519..19c1949 100644 --- a/next/src/adapters/dashboard.ts +++ b/next/src/adapters/dashboardAdapter.ts @@ -12,7 +12,7 @@ import type { SummaryVM, UnitAnalysisVM, UnitMetricUsageVM -} from "../types/dashboard"; +} from "../types/dashboardTypes"; type Raw = Record; diff --git a/next/src/components/DrillModal.vue b/next/src/components/DrillModal.vue index 6a67b75..6d88e85 100644 --- a/next/src/components/DrillModal.vue +++ b/next/src/components/DrillModal.vue @@ -1,15 +1,15 @@ diff --git a/next/src/config/dashboard.ts b/next/src/config/dashboardConfig.ts similarity index 100% rename from next/src/config/dashboard.ts rename to next/src/config/dashboardConfig.ts diff --git a/next/src/services/http.ts b/next/src/services/http.ts index a9dc633..3495120 100644 --- a/next/src/services/http.ts +++ b/next/src/services/http.ts @@ -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( diff --git a/next/src/stores/dashboard.ts b/next/src/stores/dashboardStore.ts similarity index 93% rename from next/src/stores/dashboard.ts rename to next/src/stores/dashboardStore.ts index ecd0e1c..95bc244 100644 --- a/next/src/stores/dashboard.ts +++ b/next/src/stores/dashboardStore.ts @@ -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 }; }); diff --git a/next/src/styles/next.css b/next/src/styles/next.css index aef1b06..7b72700 100644 --- a/next/src/styles/next.css +++ b/next/src/styles/next.css @@ -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; } } diff --git a/next/src/types/dashboard.ts b/next/src/types/dashboardTypes.ts similarity index 97% rename from next/src/types/dashboard.ts rename to next/src/types/dashboardTypes.ts index c5bfd7b..c8df5af 100644 --- a/next/src/types/dashboard.ts +++ b/next/src/types/dashboardTypes.ts @@ -1,4 +1,4 @@ -import type { DimensionKey, SystemKey } from "../config/dashboard"; +import type { DimensionKey, SystemKey } from "../config/dashboardConfig"; /** 接口统一响应外壳。 */ export interface ApiEnvelope { @@ -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; diff --git a/next/src/views/EnterpriseView.vue b/next/src/views/EnterpriseView.vue index 9a38477..dad3b7f 100644 --- a/next/src/views/EnterpriseView.vue +++ b/next/src/views/EnterpriseView.vue @@ -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'" /> { @select="store.dialog = 'online'" /> { label="未达标项目数" :value="summary.failProjectNum" unit="个" - description="指标使用率低于 80% 的项目" + description="指标使用率低于 70% 的项目" :progress="100 - summary.failProjectNum" clickable @select="store.dialog = 'belowStandard'" @@ -233,13 +232,13 @@ onMounted(() => { 按{{ unitName }}指标预警项目数、使用率和数据状态展示责任短板。 - 更多 + 更多 + store.loadMonitoring()); label="集团项目上线率" :value="summary.proInfoText" :trend="`${summary.onlineRate.toFixed(2)}%`" - description="上线项目/申请不用/应使用项目/项目总数" + description="上线项目/申请关闭/应使用项目/项目总数" :progress="summary.onlineRate" clickable @select="store.dialog = 'online'" /> store.loadMonitoring()); @select="store.dialog = 'online'" /> 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());

指标分析

- 更多 + 更多 +