From 255fd7e00b5f297589e84da0a2a81a0c56dc147b Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Thu, 26 Mar 2026 08:57:37 +0800 Subject: [PATCH] feat: add report analysis and visualization feature - Add recharts library for data visualization - Implement ReportAnalyzer service to parse report data - Add report analysis IPC handler with Admin permission check - Add ReportAnalysisDialog component with charts - Add unit and E2E tests for report analyzer - Update Playwright config to exclude vitest-specific test files - Expand vitest config to include source code tests Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 323 +++++++++++- package.json | 1 + playwright.config.ts | 3 +- skills-lock.json | 7 +- src/main/ipc/report-handler.ts | 83 +++ .../report/__tests__/report-analyzer.test.ts | 307 ++++++++++++ src/main/services/report/report-analyzer.ts | 212 ++++++++ src/main/types/ipc-api.types.ts | 19 + src/preload/api/materials.ts | 3 +- .../src/components/ReportAnalysisDialog.tsx | 295 +++++++++++ .../src/components/ReportViewerDialog.tsx | 22 +- src/shared/ipc-channels.ts | 1 + tests/e2e/report-analysis.test.ts | 474 ++++++++++++++++++ tests/unit/report-analyzer.test.ts | 313 ++++++++++++ vitest.config.ts | 2 +- 15 files changed, 2059 insertions(+), 6 deletions(-) create mode 100644 src/main/services/report/__tests__/report-analyzer.test.ts create mode 100644 src/main/services/report/report-analyzer.ts create mode 100644 src/renderer/src/components/ReportAnalysisDialog.tsx create mode 100644 tests/e2e/report-analysis.test.ts create mode 100644 tests/unit/report-analyzer.test.ts diff --git a/package-lock.json b/package-lock.json index 40f9a78..77de7de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "playwright-core": "^1.58.2", "react-focus-lock": "^2.13.7", "react-markdown": "^10.1.0", + "recharts": "^2.15.4", "reflect-metadata": "^0.2.2", "rehype-autolink-headings": "^7.1.0", "rehype-highlight": "^7.0.2", @@ -4786,6 +4787,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -7108,6 +7172,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -7195,6 +7380,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -7561,6 +7752,16 @@ "node": ">=0.10.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dotenv-expand": { "version": "11.0.7", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", @@ -8574,6 +8775,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -8693,6 +8900,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -9817,6 +10033,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -11062,7 +11287,6 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "dev": true, "license": "MIT" }, "node_modules/lodash.defaults": { @@ -13627,6 +13851,37 @@ "node": ">=0.10.0" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -13690,6 +13945,44 @@ "node": ">=10" } }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -15150,6 +15443,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -16507,6 +16806,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 3a1d3da..ccd3958 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "rehype-slug": "^6.0.0", "remark-gfm": "^4.0.1", "typeorm": "^0.3.28", + "recharts": "^2.15.4", "uuid": "^13.0.0", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", diff --git a/playwright.config.ts b/playwright.config.ts index 3b61c85..485d34e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -17,7 +17,8 @@ export default defineConfig({ projects: [ { name: 'electron', - testMatch: '**/*.test.ts' + testMatch: '**/*.test.ts', + testIgnore: '**/extractor-workflow.test.ts' // This file uses vitest } ] }) diff --git a/skills-lock.json b/skills-lock.json index 355a664..58882f2 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -6,10 +6,15 @@ "sourceType": "github", "computedHash": "744549070132b3bc0602fd7121d42278ba74694b9d0943358093bde3543cbe97" }, + "find-skills": { + "source": "vercel-labs/skills", + "sourceType": "github", + "computedHash": "645b891da1edbae76ab79c7b088d4e73397464f8396edaf773c0e01971ce75d6" + }, "vercel-react-best-practices": { "source": "vercel-labs/agent-skills", "sourceType": "github", - "computedHash": "e218e50fe7057a4db91390e579c7db5aafac2394c31a3d8e5fa9444c8fa00726" + "computedHash": "9fb08ab39585f6f770d0c1c735f83e619aba2089d7ad50ba2a305eff715e08b9" } } } diff --git a/src/main/ipc/report-handler.ts b/src/main/ipc/report-handler.ts index 75d5c73..ccc7d6a 100644 --- a/src/main/ipc/report-handler.ts +++ b/src/main/ipc/report-handler.ts @@ -5,6 +5,8 @@ import { createLogger } from '../services/logger' import { ConfigManager } from '../services/config/config-manager' import { RustfsService } from '../services/rustfs' import { ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3' +import { SessionManager } from '../services/user/session-manager' +import { ReportAnalyzer, type ParsedReportData } from '../services/report/report-analyzer' const log = createLogger('ReportHandler') @@ -177,4 +179,85 @@ export function registerReportHandlers(): void { }, 'report:download') } ) + + ipcMain.handle( + IPC_CHANNELS.REPORT_ANALYZE_ALL, + async (): Promise> => { + return withErrorHandling(async () => { + // Check Admin permission + const sessionManager = SessionManager.getInstance() + if (!sessionManager.isAdmin()) { + log.warn('Non-Admin user attempted to access report analysis') + throw new Error('Unauthorized: Admin access required') + } + + const rustfs = getRustfsService() + if (!rustfs) { + throw new Error('RustFS is not configured or enabled') + } + + const configManager = ConfigManager.getInstance() + const config = configManager.getConfig() + + // Create S3Client to list objects + const client = new S3Client({ + region: config.rustfs?.region || 'us-east-1', + endpoint: config.rustfs?.endpoint || '', + credentials: { + accessKeyId: config.rustfs?.accessKey || '', + secretAccessKey: config.rustfs?.secretKey || '' + }, + forcePathStyle: true + }) + + log.info('Fetching and analyzing all reports from RustFS') + const input = { + Bucket: config.rustfs?.bucket || '', + Prefix: 'reports/cleaner/' + } + + const command = new ListObjectsV2Command(input) + const response = await client.send(command) + + const reports: Array<{ content: string; filename?: string }> = [] + + if (response.Contents) { + // Download each report file + for (const item of response.Contents) { + if (item.Key && item.Key.endsWith('.md')) { + const parts = item.Key.split('/') + if (parts.length >= 4) { + const filename = parts.slice(3).join('/') + log.debug('Downloading report for analysis', { key: item.Key }) + const downloadResult = await rustfs.downloadFile(item.Key) + + if (downloadResult.success) { + reports.push({ + content: downloadResult.content.toString('utf-8'), + filename + }) + } else { + log.warn('Failed to download report for analysis', { + key: item.Key, + error: downloadResult.error + }) + } + } + } + } + } + + // Analyze all reports + const analyzer = new ReportAnalyzer() + const analyzedData = analyzer.analyzeReports(reports) + + log.info('Report analysis completed', { + totalReports: reports.length, + successfulAnalyses: analyzedData.length + }) + + return analyzedData + }, 'report:analyzeAll') + } + ) } diff --git a/src/main/services/report/__tests__/report-analyzer.test.ts b/src/main/services/report/__tests__/report-analyzer.test.ts new file mode 100644 index 0000000..0eedeb9 --- /dev/null +++ b/src/main/services/report/__tests__/report-analyzer.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect } from 'vitest' +import { ReportAnalyzer } from '../report-analyzer' + +describe('ReportAnalyzer', () => { + const analyzer = new ReportAnalyzer() + + describe('parseMarkdownReport', () => { + it('should parse standard report with all fields', () => { + const content = `## 执行摘要 + +| **操作用户** | \`admin\` | +| **处理订单数** | \`150\` | +| **删除物料数** | \`45\` | +| **跳过物料数** | \`30\` | +| **错误数量** | \`2\` | +| **重试订单数** | \`10\` | +| **成功重试数** | \`8\` | +| **执行耗时** | \`2 分 30 秒\` | +| **执行时间** | \`2026-03-25 08:30:45\` | +` + + const result = analyzer.parseMarkdownReport(content) + + expect(result).not.toBeNull() + expect(result!.date).toBe('2026-03-25') + expect(result!.username).toBe('admin') + expect(result!.ordersProcessed).toBe(150) + expect(result!.materialsDeleted).toBe(45) + expect(result!.materialsSkipped).toBe(30) + expect(result!.errorCount).toBe(2) + expect(result!.retriedOrders).toBe(10) + expect(result!.successfulRetries).toBe(8) + expect(result!.durationSeconds).toBe(150) + }) + + it('should use defaults for missing fields', () => { + const content = `## 执行摘要 + +| **操作用户** | \`user1\` | +| **处理订单数** | \`50\` | +` + + const result = analyzer.parseMarkdownReport(content) + + expect(result).not.toBeNull() + expect(result!.username).toBe('user1') + expect(result!.ordersProcessed).toBe(50) + expect(result!.materialsDeleted).toBe(0) + expect(result!.materialsSkipped).toBe(0) + expect(result!.errorCount).toBe(0) + expect(result!.retriedOrders).toBe(0) + expect(result!.successfulRetries).toBe(0) + expect(result!.durationSeconds).toBe(0) + }) + + it('should return null for malformed format', () => { + const content = `This is not a valid report format +Just some random text without proper structure +No execution summary section here` + + const result = analyzer.parseMarkdownReport(content) + expect(result).toBeNull() + }) + + it('should return null for empty content', () => { + expect(analyzer.parseMarkdownReport('')).toBeNull() + expect(analyzer.parseMarkdownReport(' ')).toBeNull() + }) + + it('should handle special characters in fields', () => { + const content = `## 执行摘要 + +| **操作用户** | \`admin@test.com\` | +| **处理订单数** | \`100\` | +| **删除物料数** | \`25\` | +| **跳过物料数** | \`10\` | +| **错误数量** | \`0\` | +| **重试订单数** | \`5\` | +| **成功重试数** | \`5\` | +| **执行耗时** | \`1 分\` | +| **执行时间** | \`2026-03-25 10:00:00\` | +` + + const result = analyzer.parseMarkdownReport(content) + + expect(result).not.toBeNull() + expect(result!.username).toBe('admin@test.com') + expect(result!.durationSeconds).toBe(60) + }) + + it('should extract date from content when execution time missing', () => { + const content = `## 执行摘要 + +| **操作用户** | \`test\` | +| **处理订单数** | \`20\` | +| **删除物料数** | \`5\` | +| **跳过物料数** | \`2\` | +| **错误数量** | \`0\` | +| **重试订单数** | \`0\` | +| **成功重试数** | \`0\` | +| **执行耗时** | \`30 秒\` | +` + + const result = analyzer.parseMarkdownReport(content) + + expect(result).not.toBeNull() + expect(result!.date).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) + + it('should handle various duration formats', () => { + // Test "2 分 30 秒" + const content1 = `## 执行摘要 + +| **操作用户** | \`test\` | +| **处理订单数** | \`10\` | +| **删除物料数** | \`5\` | +| **跳过物料数** | \`2\` | +| **错误数量** | \`0\` | +| **重试订单数** | \`0\` | +| **成功重试数** | \`0\` | +| **执行耗时** | \`2 分 30 秒\` | +| **执行时间** | \`2026-03-25 10:00:00\` | +` + const result1 = analyzer.parseMarkdownReport(content1) + expect(result1!.durationSeconds).toBe(150) + + // Test "1 分" + const content2 = `## 执行摘要 + +| **操作用户** | \`test\` | +| **处理订单数** | \`10\` | +| **删除物料数** | \`5\` | +| **跳过物料数** | \`2\` | +| **错误数量** | \`0\` | +| **重试订单数** | \`0\` | +| **成功重试数** | \`0\` | +| **执行耗时** | \`1 分\` | +| **执行时间** | \`2026-03-25 10:00:00\` | +` + const result2 = analyzer.parseMarkdownReport(content2) + expect(result2!.durationSeconds).toBe(60) + + // Test "30 秒" - use different time to avoid confusion + const content3 = `## 执行摘要 + +| **操作用户** | \`test\` | +| **处理订单数** | \`10\` | +| **删除物料数** | \`5\` | +| **跳过物料数** | \`2\` | +| **错误数量** | \`0\` | +| **重试订单数** | \`0\` | +| **成功重试数** | \`0\` | +| **执行耗时** | \`30 秒\` | +| **执行时间** | \`2026-03-25 14:20:15\` | +` + const result3 = analyzer.parseMarkdownReport(content3) + expect(result3!.durationSeconds).toBe(30) + }) + }) + + describe('analyzeReports', () => { + it('should batch process multiple reports', () => { + const reports = [ + { + content: `## 执行摘要 + +| **操作用户** | \`user1\` | +| **处理订单数** | \`100\` | +| **删除物料数** | \`20\` | +| **跳过物料数** | \`10\` | +| **错误数量** | \`1\` | +| **重试订单数** | \`5\` | +| **成功重试数** | \`4\` | +| **执行耗时** | \`1 分\` | +| **执行时间** | \`2026-03-25 08:00:00\` | +`, + filename: 'report-2026-03-25.md' + }, + { + content: `## 执行摘要 + +| **操作用户** | \`user2\` | +| **处理订单数** | \`200\` | +| **删除物料数** | \`50\` | +| **跳过物料数** | \`20\` | +| **错误数量** | \`2\` | +| **重试订单数** | \`10\` | +| **成功重试数** | \`8\` | +| **执行耗时** | \`2 分\` | +| **执行时间** | \`2026-03-26 09:00:00\` | +`, + filename: 'report-2026-03-26.md' + } + ] + + const results = analyzer.analyzeReports(reports) + + expect(results.length).toBe(2) + expect(results[0].username).toBe('user1') + expect(results[0].date).toBe('2026-03-25') + expect(results[1].username).toBe('user2') + expect(results[1].date).toBe('2026-03-26') + }) + + it('should skip invalid reports in batch', () => { + const reports = [ + { + content: `## 执行摘要 + +| **操作用户** | \`valid\` | +| **处理订单数** | \`50\` | +| **删除物料数** | \`10\` | +| **跳过物料数** | \`5\` | +| **错误数量** | \`0\` | +| **重试订单数** | \`0\` | +| **成功重试数** | \`0\` | +| **执行耗时** | \`30 秒\` | +| **执行时间** | \`2026-03-25 10:00:00\` | +`, + filename: 'valid-report.md' + }, + { + content: 'Invalid report content', + filename: 'invalid-report.md' + } + ] + + const results = analyzer.analyzeReports(reports) + + expect(results.length).toBe(1) + expect(results[0].username).toBe('valid') + }) + + it('should use filename date to override content date', () => { + const reports = [ + { + content: `## 执行摘要 + +| **操作用户** | \`test\` | +| **处理订单数** | \`30\` | +| **删除物料数** | \`5\` | +| **跳过物料数** | \`2\` | +| **错误数量** | \`0\` | +| **重试订单数** | \`0\` | +| **成功重试数** | \`0\` | +| **执行耗时** | \`20 秒\` | +| **执行时间** | \`2026-03-20 10:00:00\` | +`, + filename: 'report-2026-03-25-08-30-45.md' + } + ] + + const results = analyzer.analyzeReports(reports) + + expect(results.length).toBe(1) + expect(results[0].date).toBe('2026-03-25') + }) + }) + + describe('date extraction', () => { + it('should extract date from filename with ISO format', () => { + const content = `## 执行摘要 + +| **操作用户** | \`test\` | +| **处理订单数** | \`10\` | +| **删除物料数** | \`5\` | +| **跳过物料数** | \`2\` | +| **错误数量** | \`0\` | +| **重试订单数** | \`0\` | +| **成功重试数** | \`0\` | +| **执行耗时** | \`10 秒\` | +| **执行时间** | \`2026-03-20 10:00:00\` | +` + + const report = { + content, + filename: 'cleaner-report-2026-03-25-08-30-45.md' + } + + const results = analyzer.analyzeReports([report]) + expect(results[0].date).toBe('2026-03-25') + }) + + it('should extract date from filename with YYYYMMDD format', () => { + const content = `## 执行摘要 + +| **操作用户** | \`test\` | +| **处理订单数** | \`10\` | +| **删除物料数** | \`5\` | +| **跳过物料数** | \`2\` | +| **错误数量** | \`0\` | +| **重试订单数** | \`0\` | +| **成功重试数** | \`0\` | +| **执行耗时** | \`10 秒\` | +| **执行时间** | \`2026-03-20 10:00:00\` | +` + + const report = { + content, + filename: 'report-20260325-100000.md' + } + + const results = analyzer.analyzeReports([report]) + expect(results[0].date).toBe('2026-03-25') + }) + }) +}) diff --git a/src/main/services/report/report-analyzer.ts b/src/main/services/report/report-analyzer.ts new file mode 100644 index 0000000..c7b9c7a --- /dev/null +++ b/src/main/services/report/report-analyzer.ts @@ -0,0 +1,212 @@ +import { createLogger } from '../logger' + +const log = createLogger('ReportAnalyzer') + +/** + * Parsed report data structure + */ +export interface ParsedReportData { + /** 报告日期 (从文件名或内容提取) */ + date: string + /** 操作用户 */ + username: string + /** 处理订单数 */ + ordersProcessed: number + /** 删除物料数 */ + materialsDeleted: number + /** 跳过物料数 */ + materialsSkipped: number + /** 错误数量 */ + errorCount: number + /** 重试订单数 */ + retriedOrders: number + /** 成功重试数 */ + successfulRetries: number + /** 执行耗时 (秒) */ + durationSeconds: number +} + +/** + * ReportAnalyzer - 解析和分析 ERP 物料清理执行报告 + */ +export class ReportAnalyzer { + /** + * 解析单个 markdown 报告内容 + * @param content markdown 报告内容 + * @returns 解析后的结构化数据,解析失败返回 null + */ + parseMarkdownReport(content: string): ParsedReportData | null { + try { + // 从执行摘要表格中提取数据 - 捕获整个表格直到空行或下一个标题 + const summarySectionMatch = content.match(/## 执行摘要\s*\n([\s\S]*?)(?=\n---|\n##|\n$)/) + if (!summarySectionMatch) { + log.warn('Failed to find execution summary section') + return null + } + + const summaryTable = summarySectionMatch[0] + + // 提取各个字段的值 + const username = this.extractFieldValue(summaryTable, '操作用户') || '' + const ordersProcessed = this.extractNumericField(summaryTable, '处理订单数') || 0 + const materialsDeleted = this.extractNumericField(summaryTable, '删除物料数') || 0 + const materialsSkipped = this.extractNumericField(summaryTable, '跳过物料数') || 0 + const errorCount = this.extractNumericField(summaryTable, '错误数量') || 0 + const retriedOrders = this.extractNumericField(summaryTable, '重试订单数') || 0 + const successfulRetries = this.extractNumericField(summaryTable, '成功重试数') || 0 + + // 提取执行耗时并转换为秒 + const durationStr = this.extractFieldValue(summaryTable, '执行耗时') + const durationSeconds = durationStr ? this.parseDuration(durationStr) : 0 + + // 从内容或执行时间字段提取日期 + const executionTimeStr = this.extractFieldValue(summaryTable, '执行时间') + const date = executionTimeStr + ? this.extractDateFromDateTime(executionTimeStr) + : this.extractDateFromContent(content) + + return { + date, + username, + ordersProcessed, + materialsDeleted, + materialsSkipped, + errorCount, + retriedOrders, + successfulRetries, + durationSeconds + } + } catch (error) { + log.error('Failed to parse markdown report', { + error: error instanceof Error ? error.message : error + }) + return null + } + } + + /** + * 批量解析多个报告 + * @param reports 报告元数据和内容列表 + * @returns 解析后的数据列表 (自动跳过解析失败的报告) + */ + analyzeReports(reports: Array<{ content: string; filename?: string }>): ParsedReportData[] { + const results: ParsedReportData[] = [] + + for (const report of reports) { + const parsed = this.parseMarkdownReport(report.content) + if (parsed) { + // 如果有文件名,尝试从文件名提取日期覆盖 + if (report.filename) { + const dateFromFilename = this.extractDateFromFilename(report.filename) + if (dateFromFilename) { + parsed.date = dateFromFilename + } + } + results.push(parsed) + } else { + log.warn('Skipping report due to parse failure', { filename: report.filename }) + } + } + + return results + } + + /** + * 从表格行中提取字段的字符串值 + */ + private extractFieldValue(tableContent: string, fieldName: string): string | null { + // 匹配格式:| **字段名** | `值` | + const pattern = new RegExp( + `\\|\\s*\\*\\*${this.escapeRegex(fieldName)}\\*\\*\\s*\\|\\s*\`([^\`]*)\``, + 'i' + ) + const match = tableContent.match(pattern) + log.debug('extractFieldValue', { fieldName, matched: match?.[1], pattern }) + return match ? match[1].trim() : null + } + + /** + * 从表格行中提取数值字段 + */ + private extractNumericField(tableContent: string, fieldName: string): number | null { + const value = this.extractFieldValue(tableContent, fieldName) + if (value === null) return null + + // 移除可能的非数字字符 (如单位) + const numStr = value.replace(/[^\d]/g, '') + if (!numStr) return null + + return parseInt(numStr, 10) + } + + /** + * 解析耗时字符串为秒数 + * 支持格式:"2 分 30 秒", "1 分", "30 秒", "5 分钟" + */ + private parseDuration(durationStr: string): number { + let totalSeconds = 0 + + // 匹配分钟 - 支持 "X 分" 或 "X 分钟" 格式 + const minMatch = durationStr.match(/(\d+) 分/) + if (minMatch) { + totalSeconds += parseInt(minMatch[1], 10) * 60 + } + + // 匹配秒 + const secMatch = durationStr.match(/(\d+) 秒/) + if (secMatch) { + totalSeconds += parseInt(secMatch[1], 10) + } + + return totalSeconds + } + + /** + * 从日期时间字符串中提取日期部分 + * 格式:"2026-03-25 08:30:45" -> "2026-03-25" + */ + private extractDateFromDateTime(dateTimeStr: string): string { + const match = dateTimeStr.match(/(\d{4}-\d{2}-\d{2})/) + return match ? match[1] : dateTimeStr + } + + /** + * 从内容中提取日期 (备选方案) + */ + private extractDateFromContent(content: string): string { + // 尝试从报告标题或执行时间提取 + const dateMatch = content.match(/(\d{4}-\d{2}-\d{2})/) + return dateMatch ? dateMatch[1] : new Date().toISOString().split('T')[0] + } + + /** + * 从文件名中提取日期 + * 格式:"cleaner-report-2026-03-25-08-30-45.md" -> "2026-03-25" + */ + private extractDateFromFilename(filename: string): string | null { + // 匹配 ISO 日期格式 + const isoMatch = filename.match(/(\d{4}-\d{2}-\d{2})/) + if (isoMatch) { + return isoMatch[1] + } + + // 匹配其他常见日期格式 + const dateMatch = filename.match(/(\d{8})/) + if (dateMatch) { + const str = dateMatch[1] + // 尝试解析 YYYYMMDD + if (str.length === 8) { + return `${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)}` + } + } + + return null + } + + /** + * 转义正则表达式特殊字符 + */ + private escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + } +} diff --git a/src/main/types/ipc-api.types.ts b/src/main/types/ipc-api.types.ts index 332c10b..17388d1 100644 --- a/src/main/types/ipc-api.types.ts +++ b/src/main/types/ipc-api.types.ts @@ -199,4 +199,23 @@ export interface ReportAPI { * @param key - Report object key in RustFS */ download: (key: string) => Promise> + + /** + * Analyze all reports and return parsed data (Admin only) + */ + analyzeAll: () => Promise< + IpcResult< + { + date: string + username: string + ordersProcessed: number + materialsDeleted: number + materialsSkipped: number + errorCount: number + retriedOrders: number + successfulRetries: number + durationSeconds: number + }[] + > + > } diff --git a/src/preload/api/materials.ts b/src/preload/api/materials.ts index db0ca19..ccb60c4 100644 --- a/src/preload/api/materials.ts +++ b/src/preload/api/materials.ts @@ -71,7 +71,8 @@ export const configApi = { export const reportApi = { listAll: () => invokeIpc(IPC_CHANNELS.REPORT_LIST_ALL), listByUser: (username: string) => invokeIpc(IPC_CHANNELS.REPORT_LIST_BY_USER, username), - download: (key: string) => invokeIpc(IPC_CHANNELS.REPORT_DOWNLOAD, key) + download: (key: string) => invokeIpc(IPC_CHANNELS.REPORT_DOWNLOAD, key), + analyzeAll: () => invokeIpc(IPC_CHANNELS.REPORT_ANALYZE_ALL) } as const export const updateApi = { diff --git a/src/renderer/src/components/ReportAnalysisDialog.tsx b/src/renderer/src/components/ReportAnalysisDialog.tsx new file mode 100644 index 0000000..28593bf --- /dev/null +++ b/src/renderer/src/components/ReportAnalysisDialog.tsx @@ -0,0 +1,295 @@ +import React, { useState, useEffect } from 'react' +import { X, FileText, Loader2, AlertCircle, RefreshCw } from 'lucide-react' +import { Checkbox } from '@headlessui/react' +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer +} from 'recharts' + +interface ParsedReportData { + date: string + username: string + ordersProcessed: number + materialsDeleted: number + materialsSkipped: number + errorCount: number + retriedOrders: number + successfulRetries: number + durationSeconds: number +} + +interface ReportAnalysisDialogProps { + isOpen: boolean + onClose: () => void + isAdmin: boolean +} + +export const ReportAnalysisDialog: React.FC = ({ + isOpen, + onClose, + isAdmin +}) => { + // Metric definitions (exclude username from numeric metrics for chart) + const numericMetrics = [ + { key: 'ordersProcessed', label: '处理订单数', color: '#3b82f6' }, + { key: 'materialsDeleted', label: '删除物料数', color: '#10b981' }, + { key: 'materialsSkipped', label: '跳过物料数', color: '#f59e0b' }, + { key: 'errorCount', label: '错误数量', color: '#ef4444' }, + { key: 'retriedOrders', label: '重试订单数', color: '#8b5cf6' }, + { key: 'successfulRetries', label: '成功重试数', color: '#06b6d4' }, + { key: 'durationSeconds', label: '执行耗时', color: '#ec4899' } + ] + + // State: selected metrics (default all) + const [selectedMetrics, setSelectedMetrics] = useState(numericMetrics.map((m) => m.key)) + + // Toggle metric selection + const toggleMetric = (metricKey: string) => { + setSelectedMetrics((prev) => + prev.includes(metricKey) ? prev.filter((k) => k !== metricKey) : [...prev, metricKey] + ) + } + + // Data state + const [reportData, setReportData] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // Fetch data when dialog opens + useEffect(() => { + if (!isOpen || !isAdmin) return + + const fetchData = async () => { + setLoading(true) + setError(null) + try { + const result = await window.electron.report.analyzeAll() + if (result.success && result.data) { + setReportData(result.data) + } else { + setError(result.error || '加载失败') + } + } catch (err) { + setError(err instanceof Error ? err.message : '未知错误') + } finally { + setLoading(false) + } + } + + fetchData() + }, [isOpen, isAdmin]) + + // Transform data for chart + const chartData = reportData.map((item) => ({ + name: item.date, + ...item + })) + + // Non-Admin users should not see anything + if (!isAdmin) { + return null + } + + if (!isOpen) return null + + return ( +
+
+ {/* Header */} +
+
+ +

报告分析

+
+ +
+ + {/* Controls */} +
+
+ +
+ {numericMetrics.map((metric) => ( + toggleMetric(metric.key)} + className="group flex items-center gap-2 p-2 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50/50 transition-all cursor-pointer" + > +
+ {selectedMetrics.includes(metric.key) && ( + + + + )} +
+ + {metric.label} + +
+ ))} +
+
+
+ + {/* Content */} +
+ {/* Loading State */} + {loading && ( +
+ +

正在加载报告数据...

+
+ )} + + {/* Error State */} + {error && ( +
+ +

加载失败

+

{error}

+ +
+ )} + + {/* Empty State */} + {!loading && !error && reportData.length === 0 && ( +
+ +

暂无报告数据

+

还没有可用的执行报告

+
+ )} + + {/* Chart */} + {!loading && !error && reportData.length > 0 && ( +
+ + + + + + } /> + + {numericMetrics + .filter((m) => selectedMetrics.includes(m.key)) + .map((metric) => ( + + ))} + + +
+ )} +
+
+
+ ) +} + +// Custom Tooltip with Chinese labels +interface CustomTooltipProps { + active?: boolean + payload?: Array<{ + name: string + value: number + color: string + }> + label?: string +} + +const CustomTooltip: React.FC = ({ active, payload, label }) => { + if (active && payload && payload.length) { + return ( +
+

{`日期:${label}`}

+ {payload.map((entry, index) => ( +

+ {`${entry.name}: ${entry.value.toLocaleString()}`} +

+ ))} +
+ ) + } + return null +} + +export default ReportAnalysisDialog diff --git a/src/renderer/src/components/ReportViewerDialog.tsx b/src/renderer/src/components/ReportViewerDialog.tsx index 0d66aca..1fbeb26 100644 --- a/src/renderer/src/components/ReportViewerDialog.tsx +++ b/src/renderer/src/components/ReportViewerDialog.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { X, FileText, Loader2, ChevronDown } from 'lucide-react' +import { X, FileText, Loader2, ChevronDown, BarChart3 } from 'lucide-react' import { Combobox, Transition } from '@headlessui/react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -8,6 +8,7 @@ import rehypeSlug from 'rehype-slug' import rehypeAutolinkHeadings from 'rehype-autolink-headings' import 'github-markdown-css/github-markdown-light.css' import 'highlight.js/styles/github.css' +import { ReportAnalysisDialog } from './ReportAnalysisDialog' interface ReportMetadata { key: string @@ -37,6 +38,7 @@ export const ReportViewerDialog: React.FC = ({ const [isLoadingContent, setIsLoadingContent] = useState(false) const [error, setError] = useState(null) const [query, setQuery] = useState('') + const [isAnalysisDialogOpen, setIsAnalysisDialogOpen] = useState(false) const loadReports = useCallback(async () => { setIsLoadingList(true) @@ -129,6 +131,16 @@ export const ReportViewerDialog: React.FC = ({

执行报告浏览器

+ {isAdmin && ( + + )}