From 96f0f69ac38abdc0494eb67bad54438c7a8d8987 Mon Sep 17 00:00:00 2001 From: fei550 <1420031550@qq.com> Date: Tue, 28 Jul 2026 21:38:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=95=B4=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + next/.env.example | 2 + next/ARCHITECTURE.md | 27 + next/README.md | 217 +-- next/package-lock.json | 1263 +++++++++++++++++ next/package.json | 9 +- next/playwright.config.ts | 11 + next/src/App.vue | 86 +- next/src/api/dashboardApi.ts | 21 + next/src/api/endpoints.ts | 14 + .../{services/http.ts => api/httpClient.ts} | 4 +- next/src/api/projectApi.ts | 6 + next/src/{ => assets}/styles/next.css | 78 + next/src/components/{ => base}/EChart.vue | 0 .../{ => features/drilldown}/DrillModal.vue | 195 ++- .../monitoring}/IndicatorAnalysis.vue | 2 +- .../features/monitoring/KpiCard.test.ts | 12 + .../{ => features/monitoring}/KpiCard.vue | 0 .../{ => features/monitoring}/MetricStrip.vue | 2 +- .../project/ProjectMetricSnapshotModal.vue | 92 ++ .../composables/useDrilldownActions.test.ts | 63 + next/src/composables/useDrilldownActions.ts | 47 + next/src/composables/useMonitoringPage.ts | 25 + next/src/composables/useProjectPage.ts | 25 + next/src/layouts/DashboardLayout.vue | 64 + next/src/main.ts | 6 +- .../EnterprisePage.vue} | 133 +- .../GroupView.vue => pages/GroupPage.vue} | 144 +- next/src/pages/ProjectPage.vue | 73 + next/src/router/index.ts | 13 +- next/src/stores/dashboardStore.ts | 172 --- next/src/stores/drilldownStore.test.ts | 52 + next/src/stores/drilldownStore.ts | 90 ++ next/src/stores/filterStore.test.ts | 12 + next/src/stores/filterStore.ts | 30 + next/src/stores/monitoringStore.test.ts | 29 + next/src/stores/monitoringStore.ts | 52 + next/src/stores/projectStore.test.ts | 29 + next/src/stores/projectStore.ts | 40 + next/src/types/api.ts | 15 + .../types/{dashboardTypes.ts => dashboard.ts} | 19 +- next/src/types/drilldown.ts | 29 + next/src/types/project.ts | 47 + .../utils/adapters/drilldownAdapter.test.ts | 21 + next/src/utils/adapters/drilldownAdapter.ts | 38 + .../adapters/monitoringAdapter.test.ts} | 2 +- .../adapters/monitoringAdapter.ts} | 2 +- .../src/utils/adapters/projectAdapter.test.ts | 21 + next/src/utils/adapters/projectAdapter.ts | 67 + next/src/utils/charts/drilldownCharts.ts | 15 + next/src/utils/charts/monitoringCharts.ts | 51 + next/src/{config => utils}/dashboardConfig.ts | 24 +- next/src/views/ProjectView.vue | 154 -- next/src/vite-env.d.ts | 9 + next/tsconfig.json | 2 + next/vite.config.ts | 4 + next/vitest.config.ts | 14 + 57 files changed, 2802 insertions(+), 876 deletions(-) create mode 100644 next/.env.example create mode 100644 next/ARCHITECTURE.md create mode 100644 next/playwright.config.ts create mode 100644 next/src/api/dashboardApi.ts create mode 100644 next/src/api/endpoints.ts rename next/src/{services/http.ts => api/httpClient.ts} (95%) create mode 100644 next/src/api/projectApi.ts rename next/src/{ => assets}/styles/next.css (70%) rename next/src/components/{ => base}/EChart.vue (100%) rename next/src/components/{ => features/drilldown}/DrillModal.vue (71%) rename next/src/components/{ => features/monitoring}/IndicatorAnalysis.vue (97%) create mode 100644 next/src/components/features/monitoring/KpiCard.test.ts rename next/src/components/{ => features/monitoring}/KpiCard.vue (100%) rename next/src/components/{ => features/monitoring}/MetricStrip.vue (93%) create mode 100644 next/src/components/features/project/ProjectMetricSnapshotModal.vue create mode 100644 next/src/composables/useDrilldownActions.test.ts create mode 100644 next/src/composables/useDrilldownActions.ts create mode 100644 next/src/composables/useMonitoringPage.ts create mode 100644 next/src/composables/useProjectPage.ts create mode 100644 next/src/layouts/DashboardLayout.vue rename next/src/{views/EnterpriseView.vue => pages/EnterprisePage.vue} (58%) rename next/src/{views/GroupView.vue => pages/GroupPage.vue} (58%) create mode 100644 next/src/pages/ProjectPage.vue delete mode 100644 next/src/stores/dashboardStore.ts create mode 100644 next/src/stores/drilldownStore.test.ts create mode 100644 next/src/stores/drilldownStore.ts create mode 100644 next/src/stores/filterStore.test.ts create mode 100644 next/src/stores/filterStore.ts create mode 100644 next/src/stores/monitoringStore.test.ts create mode 100644 next/src/stores/monitoringStore.ts create mode 100644 next/src/stores/projectStore.test.ts create mode 100644 next/src/stores/projectStore.ts create mode 100644 next/src/types/api.ts rename next/src/types/{dashboardTypes.ts => dashboard.ts} (89%) create mode 100644 next/src/types/drilldown.ts create mode 100644 next/src/types/project.ts create mode 100644 next/src/utils/adapters/drilldownAdapter.test.ts create mode 100644 next/src/utils/adapters/drilldownAdapter.ts rename next/src/{adapters/dashboardAdapter.test.ts => utils/adapters/monitoringAdapter.test.ts} (97%) rename next/src/{adapters/dashboardAdapter.ts => utils/adapters/monitoringAdapter.ts} (99%) create mode 100644 next/src/utils/adapters/projectAdapter.test.ts create mode 100644 next/src/utils/adapters/projectAdapter.ts create mode 100644 next/src/utils/charts/drilldownCharts.ts create mode 100644 next/src/utils/charts/monitoringCharts.ts rename next/src/{config => utils}/dashboardConfig.ts (75%) delete mode 100644 next/src/views/ProjectView.vue create mode 100644 next/src/vite-env.d.ts create mode 100644 next/vitest.config.ts diff --git a/.gitignore b/.gitignore index 0c5d8c8..73958c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ next/node_modules/ next/dist/ +next/e2e/ +next/graphify-out/ +next/output/ +next/test-results/ next/.env next/.env.local next/.env.*.local diff --git a/next/.env.example b/next/.env.example new file mode 100644 index 0000000..cf7dc45 --- /dev/null +++ b/next/.env.example @@ -0,0 +1,2 @@ +# 浏览器端接口 token。构建环境必须提供该变量。 +VITE_QHSE_API_TOKEN=replace-with-qhse-api-token diff --git a/next/ARCHITECTURE.md b/next/ARCHITECTURE.md new file mode 100644 index 0000000..afe82f5 --- /dev/null +++ b/next/ARCHITECTURE.md @@ -0,0 +1,27 @@ +# Vue3 看板架构 + +## 目录职责 + +项目采用固定顶层目录的 Feature-First 结构: + +- `src/api`:HTTP 客户端、接口端点和监控/项目 API。 +- `src/assets`:全局样式及静态资源;`next.css` 放置 Vue3 增量样式。 +- `src/components/base`:与业务无关的通用组件,目前为 ECharts 生命周期封装。 +- `src/components/features`:按 monitoring、drilldown 分组的业务展示组件。 +- `src/composables`:路由页面生命周期和跨 Store 协调动作。 +- `src/layouts`:应用壳层、大屏缩放、全局筛选和弹窗挂载。 +- `src/pages`:集团、企业、项目三级路由页面。 +- `src/router`:Vue Router 哈希路由及页面懒加载。 +- `src/stores`:筛选、监控、项目详情和穿透四个 Setup Store。 +- `src/types`:API 查询类型和页面 ViewModel 类型。 +- `src/utils`:Adapter、图表 option 纯函数和业务配置。 + +## 数据流 + +页面 composable 监听路由参数和筛选状态,调用职责对应的 Store;Store 通过 API 客户端读取接口,再由 `utils/adapters` 转换为 ViewModel。页面与弹窗只读取 ViewModel,不直接兼容后端字段别名。`useDrilldownActions` 负责筛选变更、指标穿透、报告和弹窗关闭等跨 Store 操作。 + +每类异步数据请求均有独立的 `AbortController` 与请求序号。筛选、路由或弹窗关闭导致的旧请求不会覆盖最新状态。 + +## 环境变量 + +复制 `.env.example` 为本地环境文件并设置 `VITE_QHSE_API_TOKEN`。该 token 按既有内网模式由浏览器请求头携带,因此构建产物仍会包含该值;不要将真实生产 token 提交到版本库。 diff --git a/next/README.md b/next/README.md index d684880..d0a333b 100644 --- a/next/README.md +++ b/next/README.md @@ -1,118 +1,145 @@ # 数据治理看板 Vue3 版 -本目录是原版数据治理看板的 Vue3 升级版本。原版 `indexV2.html`、`res/` 和 `server.js` 保持不变,Vue3 版可独立开发和构建,并保持既有接口参数、业务口径、外部页签参数和 1920×1080 大屏布局。 +本目录是原数据治理看板的 Vue3 重构版本,可独立开发、测试和构建。项目保持原有接口协议、业务口径、外部页签参数和 1920×1080 大屏布局;上级目录中的 `indexV2.html`、`res/` 与旧版脚本不受影响。 ## 技术栈 -- Vue 3 + TypeScript + Vite:组件化开发、类型检查和静态构建。 -- Pinia:集中管理筛选条件、监控数据、项目详情和穿透弹窗状态。 -- Vue Router:集团、公司、项目三级哈希路由。 -- ECharts:柱状图和折线图按需加载,Canvas 渲染。 -- Vitest:数据适配和业务口径单元测试。 +| 分类 | 技术 | 当前版本 | 用途 | +|------|------|----------|------| +| 视图 | Vue | 3.5.39 | Composition API、` diff --git a/next/src/api/dashboardApi.ts b/next/src/api/dashboardApi.ts new file mode 100644 index 0000000..aac9adf --- /dev/null +++ b/next/src/api/dashboardApi.ts @@ -0,0 +1,21 @@ +import { API_ENDPOINTS } from "@/api/endpoints"; +import { getJson } from "@/api/httpClient"; + +export function getMonitoring(type: number, range: number, unitId: string, signal: AbortSignal) { + return getJson(unitId ? API_ENDPOINTS.unitMonitoring : API_ENDPOINTS.monitoring, { type, range, unitId }, { signal }); +} + +export function getGroupMetric(type: number, range: number, filed: string, signal: AbortSignal) { + return getJson(API_ENDPOINTS.groupMetric, { type, range, filed }, { signal }); +} + +export function getUnitMetric(type: number, range: number, filed: string, unitId: string, signal: AbortSignal) { + return getJson(API_ENDPOINTS.unitMetric, { type, range, filed, unitId }, { signal }); +} + +export function getMoveReport(type: number, range: number, signal: AbortSignal) { + return Promise.all([ + getJson(API_ENDPOINTS.groupMoveReportMetrics, { type }, { signal }), + getJson(API_ENDPOINTS.groupMoveReport, { type, range }, { signal }), + ]); +} diff --git a/next/src/api/endpoints.ts b/next/src/api/endpoints.ts new file mode 100644 index 0000000..ec2739f --- /dev/null +++ b/next/src/api/endpoints.ts @@ -0,0 +1,14 @@ +export const API_BASE = "/qhse_webapi/api/DataGovernance"; + +/** 数据治理接口令牌由 Vite 在构建期注入。 */ +export const API_TOKEN = import.meta.env.VITE_QHSE_API_TOKEN || ""; + +export const API_ENDPOINTS = { + monitoring: `${API_BASE}/GetMetricMonitoringSituation`, + unitMonitoring: `${API_BASE}/GetUnitMetricMonitoringSituation`, + groupMetric: `${API_BASE}/GetGroupMetricAnalysis`, + groupMoveReportMetrics: `${API_BASE}/GetGroupMoveReportMetricData`, + groupMoveReport: `${API_BASE}/GetGroupMetricAnalysisMoveReport`, + unitMetric: `${API_BASE}/GetUnitMetricAnalysis`, + projectMonitoring: `${API_BASE}/GetProjectMonitoringSituation`, +} as const; diff --git a/next/src/services/http.ts b/next/src/api/httpClient.ts similarity index 95% rename from next/src/services/http.ts rename to next/src/api/httpClient.ts index 3495120..9e1ac55 100644 --- a/next/src/services/http.ts +++ b/next/src/api/httpClient.ts @@ -2,8 +2,8 @@ * 同源 GET 请求封装。 * 负责查询参数编码、请求 ID、超时/取消和业务错误归一化,不承载具体业务字段转换。 */ -import type { ApiEnvelope } from "../types/dashboardTypes"; -import { API_TOKEN } from "../config/dashboardConfig"; +import type { ApiEnvelope } from "@/types/api"; +import { API_TOKEN } from "@/api/endpoints"; export class ApiError extends Error { constructor( diff --git a/next/src/api/projectApi.ts b/next/src/api/projectApi.ts new file mode 100644 index 0000000..542e338 --- /dev/null +++ b/next/src/api/projectApi.ts @@ -0,0 +1,6 @@ +import { API_ENDPOINTS } from "@/api/endpoints"; +import { getJson } from "@/api/httpClient"; + +export function getProject(type: number, unitId: string, projectId: string, signal: AbortSignal) { + return getJson(API_ENDPOINTS.projectMonitoring, { type, unitId, projectId }, { signal }); +} diff --git a/next/src/styles/next.css b/next/src/assets/styles/next.css similarity index 70% rename from next/src/styles/next.css rename to next/src/assets/styles/next.css index 7b72700..9db9381 100644 --- a/next/src/styles/next.css +++ b/next/src/assets/styles/next.css @@ -35,6 +35,84 @@ .green-row { background: rgba(50, 175, 112, .16); } .table-link { color: var(--cyan); } .chart-click { cursor: pointer; } +.comparison-column-emphasis { color: #ffffff; font-weight: 900; } + +/* 项目指标快照弹窗按日期切换卡片,异常指标使用与集团指标弹窗一致的红色状态语义。 */ +.project-snapshot-panel { width: min(1320px, calc(100vw - 68px)); } +.project-snapshot-body { display: grid; grid-template-rows: auto 1fr; gap: 18px; min-height: 0; } +.snapshot-period-tabs { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 8px; + padding: 4px; + border: 1px solid rgba(91, 180, 255, 0.24); + border-radius: 6px; + background: rgba(0, 18, 37, 0.48); +} +.snapshot-period-tabs button { + min-width: 0; + min-height: 38px; + border: 1px solid transparent; + border-radius: 4px; + color: #b8d6e8; + background: transparent; + font: inherit; + font-size: 13px; + font-weight: 800; + cursor: pointer; +} +.snapshot-period-tabs button:hover { color: #ffffff; background: rgba(28, 153, 217, 0.2); } +.snapshot-period-tabs button.active { + border-color: rgba(91, 214, 255, 0.56); + color: #ffffff; + background: #08639a; +} +.project-snapshot-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 12px; + align-content: start; +} +.project-snapshot-card { + min-width: 0; + min-height: 126px; + padding: 15px; + border: 1px solid rgba(91, 180, 255, 0.38); + border-radius: 6px; + background: #08375d; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); +} +.project-snapshot-card.abnormal { + background: #0B4974; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 0 16px rgba(255, 75, 91, 0.16); +} +.project-snapshot-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } +.project-snapshot-head h3 { + min-width: 0; + margin: 0; + overflow: hidden; + color: #ffffff; + font-size: 15px; + line-height: 1.45; + letter-spacing: 0; + text-overflow: ellipsis; + white-space: nowrap; +} +.project-snapshot-card > strong { + display: block; + margin-top: 22px; + overflow: hidden; + color: #ffffff; + font-family: var(--num); + font-size: 26px; + text-overflow: ellipsis; + white-space: nowrap; +} +.snapshot-empty { min-height: 320px; } + +@media (max-width: 900px) { + .snapshot-period-tabs { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} /* “动不动”报告按接口指标数量横向扩展,固定列和动态列保持稳定宽度。 */ .move-report-panel .table-wrap { max-height: 610px; } diff --git a/next/src/components/EChart.vue b/next/src/components/base/EChart.vue similarity index 100% rename from next/src/components/EChart.vue rename to next/src/components/base/EChart.vue diff --git a/next/src/components/DrillModal.vue b/next/src/components/features/drilldown/DrillModal.vue similarity index 71% rename from next/src/components/DrillModal.vue rename to next/src/components/features/drilldown/DrillModal.vue index 6d88e85..bd34110 100644 --- a/next/src/components/DrillModal.vue +++ b/next/src/components/features/drilldown/DrillModal.vue @@ -4,69 +4,35 @@ * 根据 store.dialog 渲染上线统计、指标卡片、报告或指标穿透,避免每个页面维护独立弹窗状态。 */ import { computed } from "vue"; -import type { EChartsOption } from "echarts"; -import { useDashboardStore } from "../stores/dashboardStore"; -import EChart from "./EChart.vue"; -import { normalizeMetric, numberValue } from "../adapters/dashboardAdapter"; -import { EXTERNAL_LINKS, UNIT_REPORT_FILES } from "../config/dashboardConfig"; -import type { MasterRecordVM } from "../types/dashboardTypes"; +import EChart from "@/components/base/EChart.vue"; +import { useDrilldownActions } from "@/composables/useDrilldownActions"; +import { useDrilldownStore } from "@/stores/drilldownStore"; +import { useMonitoringStore } from "@/stores/monitoringStore"; +import { createUnitMetricOption } from "@/utils/charts/drilldownCharts"; +import { EXTERNAL_LINKS, UNIT_REPORT_FILES } from "@/utils/dashboardConfig"; +import { numberValue } from "@/utils/adapters/monitoringAdapter"; +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) - : store.selectedMetricVM; +const monitoring = useMonitoringStore(); +const drilldown = useDrilldownStore(); +const actions = useDrilldownActions(); +const metric = computed(() => drilldown.metricAnalysis?.metric || actions.selectedMetricVM.value); +const unitRows = computed(() => drilldown.metricAnalysis?.units || []); +const projectRows = computed(() => drilldown.metricAnalysis?.projects || []); +const emphasizedPeriod = computed(() => { + if (drilldown.dialog !== "unitMetric") return ""; + return ({ + 1: "threeDaysAgo", + 7: "weekAgo", + 30: "monthAgo", + 90: "threeMonthsAgo", + } as Record)[metric.value?.frequency || 0] || ""; }); -const unitRows = computed(() => - Array.isArray(raw.value.UnitAnalysises) - ? (raw.value.UnitAnalysises as Record[]) - : [], -); -const projectRows = computed(() => - Array.isArray(raw.value.ProjectMetrics) - ? (raw.value.ProjectMetrics as Record[]) - : [], -); -const unitChart = computed(() => ({ - 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 unitChart = computed(() => createUnitMetricOption(unitRows.value)); const title = computed(() => { - if (store.dialog === "indicatorAnalysis") { - return store.currentUnitId - ? `${store.currentUnitName || "企业"} · 指标分析` + if (drilldown.dialog === "indicatorAnalysis") { + return monitoring.currentUnitId + ? `${monitoring.currentUnitName || "企业"} · 指标分析` : "集团指标分析"; } const labels = { @@ -77,31 +43,31 @@ const title = computed(() => { groupMetric: "指标穿透分析", unitMetric: "企业指标穿透分析", }; - return store.dialog ? labels[store.dialog] : ""; + return drilldown.dialog ? labels[drilldown.dialog] : ""; }); const dialogLoading = computed(() => - store.dialog === "moveReport" ? store.moveReportLoading : store.metricLoading, + drilldown.dialog === "moveReport" ? drilldown.moveReportLoading : drilldown.metricLoading, ); const dialogError = computed(() => - store.dialog === "moveReport" ? store.moveReportError : store.metricError, + drilldown.dialog === "moveReport" ? drilldown.moveReportError : drilldown.metricError, ); const loadingText = computed(() => - store.dialog === "moveReport" ? "正在获取动不动分析数据..." : "正在获取指标穿透数据...", + drilldown.dialog === "moveReport" ? "正在获取动不动分析数据..." : "正在获取指标穿透数据...", ); const onlineRateTotal = computed(() => { const total = sum("total"); return total ? sum("onlineAutoTotal") / total * 100 : 0; }); const moveTableMinWidth = computed(() => - Math.max(980, 420 + store.moveReport.metrics.length * 138), + Math.max(980, 420 + drilldown.moveReport.metrics.length * 138), ); const lowUsageMetricCount = computed(() => - store.allMetrics.filter((item) => item.usageRate < 70).length, + monitoring.allMetrics.filter((item) => item.usageRate < 60).length, ); const sum = (field: keyof MasterRecordVM) => - store.monitoring.records.reduce( + monitoring.monitoring.records.reduce( (total, row) => total + numberValue(row[field]), 0, ); @@ -142,7 +108,7 @@ function previewUnitReport(unit: string) {