Commit 79992c2e authored by 375242562@qq.com's avatar 375242562@qq.com

feat: 数据概览改为以图表为主,移除表格

安装 recharts,将 DashboardPage 所有数据展示替换为图表:
- 匹配状态分布 → 环形饼图(PieChart,4色区分符合/不符合/待审核/需补充)
- 试验招募状态 → 环形饼图(招募中 vs 其他)
- 最近批量匹配 → 柱状图(BarChart,完成对数/失败对数分色展示)
- 系统关键指标 → 径向进度图(RadialBarChart,三项指标同轴对比)
- 移除所有 Table/LinearProgress 进度条展示
Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 1e3dc6d4
......@@ -16,7 +16,8 @@
"axios": "^1.7.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.23.0"
"react-router-dom": "^6.23.0",
"recharts": "^3.7.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
......@@ -1451,6 +1452,42 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmmirror.com/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.23.2.tgz",
......@@ -1817,6 +1854,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz",
......@@ -1862,6 +1911,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
......@@ -1910,6 +2022,12 @@
"@types/react": "*"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
......@@ -2264,6 +2382,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
......@@ -2281,6 +2520,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
......@@ -2389,6 +2634,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
......@@ -2450,6 +2705,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
......@@ -2709,6 +2970,16 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmmirror.com/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz",
......@@ -2725,6 +2996,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz",
......@@ -3286,6 +3566,29 @@
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz",
......@@ -3367,6 +3670,51 @@
"node": ">=8.10.0"
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmmirror.com/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz",
......@@ -3665,6 +4013,12 @@
"node": ">=0.8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
......@@ -3794,6 +4148,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"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": "5.4.21",
"resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
......
......@@ -8,24 +8,25 @@
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^6.0.0",
"@mui/material": "^6.0.0",
"@mui/x-data-grid": "^7.0.0",
"axios": "^1.7.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.23.0",
"@mui/material": "^6.0.0",
"@mui/x-data-grid": "^7.0.0",
"@mui/icons-material": "^6.0.0",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"axios": "^1.7.0"
"recharts": "^3.7.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.4.0",
"vite": "^5.2.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
"tailwindcss": "^3.4.0",
"typescript": "^5.4.0",
"vite": "^5.2.0"
}
}
import { useState, useEffect } from 'react'
import {
Box, Typography, Grid, Card, CardContent, Stack, Chip,
LinearProgress, Divider, IconButton, Tooltip, Table,
TableBody, TableCell, TableHead, TableRow, Alert,
LinearProgress, IconButton, Tooltip, Alert,
} from '@mui/material'
import {
PieChart, Pie, Cell, Tooltip as ReTooltip, Legend, ResponsiveContainer,
BarChart, Bar, XAxis, YAxis, CartesianGrid,
RadialBarChart, RadialBar,
} from 'recharts'
import DashboardIcon from '@mui/icons-material/Dashboard'
import PeopleIcon from '@mui/icons-material/People'
import ScienceIcon from '@mui/icons-material/Science'
......@@ -11,19 +15,9 @@ import PsychologyIcon from '@mui/icons-material/Psychology'
import BatchPredictionIcon from '@mui/icons-material/BatchPrediction'
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive'
import RefreshIcon from '@mui/icons-material/Refresh'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import CancelIcon from '@mui/icons-material/Cancel'
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
import { dashboardService, DashboardStats } from '../services/dashboardService'
const JOB_STATUS_MAP: Record<string, { label: string; color: 'default' | 'info' | 'warning' | 'success' | 'error' }> = {
pending: { label: '待启动', color: 'default' },
running: { label: '运行中', color: 'info' },
completed: { label: '已完成', color: 'success' },
failed: { label: '失败', color: 'error' },
cancelled: { label: '已停止', color: 'default' },
}
// ─── KPI 卡片 ────────────────────────────────────────────────────────────────
interface StatCardProps {
title: string
......@@ -40,7 +34,8 @@ function StatCard({ title, value, subtitle, icon, color, extra }: StatCardProps)
<CardContent>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box>
<Typography variant="caption" color="text.secondary" fontWeight={500} textTransform="uppercase" letterSpacing={0.5}>
<Typography variant="caption" color="text.secondary" fontWeight={500}
textTransform="uppercase" letterSpacing={0.5}>
{title}
</Typography>
<Typography variant="h3" fontWeight={700} color={color} lineHeight={1.2} mt={0.5}>
......@@ -54,8 +49,7 @@ function StatCard({ title, value, subtitle, icon, color, extra }: StatCardProps)
<Box sx={{
width: 48, height: 48, borderRadius: 2,
bgcolor: `${color}18`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color,
display: 'flex', alignItems: 'center', justifyContent: 'center', color,
}}>
{icon}
</Box>
......@@ -65,50 +59,196 @@ function StatCard({ title, value, subtitle, icon, color, extra }: StatCardProps)
)
}
function MatchingDistribution({ matching }: { matching: DashboardStats['matching'] }) {
const total = matching.total
if (total === 0) {
return <Typography variant="body2" color="text.secondary">暂无匹配数据</Typography>
// ─── 匹配状态饼图 ─────────────────────────────────────────────────────────────
const MATCH_COLORS = ['#2e7d32', '#d32f2f', '#ed6c02', '#757575']
const MATCH_LABELS: Record<string, string> = {
eligible: '符合入组',
ineligible: '不符合',
pending_review: '待审核',
needs_more_info: '需补充',
}
function MatchingPieChart({ matching }: { matching: DashboardStats['matching'] }) {
const data = [
{ name: MATCH_LABELS.eligible, value: matching.eligible },
{ name: MATCH_LABELS.ineligible, value: matching.ineligible },
{ name: MATCH_LABELS.pending_review, value: matching.pending_review },
{ name: MATCH_LABELS.needs_more_info, value: matching.needs_more_info },
].filter(d => d.value > 0)
if (matching.total === 0) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 220 }}>
<Typography variant="body2" color="text.secondary">暂无匹配数据</Typography>
</Box>
)
}
const items = [
{ label: '符合', value: matching.eligible, color: '#2e7d32', icon: <CheckCircleIcon fontSize="small" /> },
{ label: '不符合', value: matching.ineligible, color: '#d32f2f', icon: <CancelIcon fontSize="small" /> },
{ label: '待审核', value: matching.pending_review, color: '#ed6c02', icon: <HourglassEmptyIcon fontSize="small" /> },
{ label: '需补充', value: matching.needs_more_info, color: '#757575', icon: <HelpOutlineIcon fontSize="small" /> },
return (
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={85}
paddingAngle={3}
dataKey="value"
>
{data.map((_, i) => (
<Cell key={i} fill={MATCH_COLORS[i % MATCH_COLORS.length]} />
))}
</Pie>
<ReTooltip formatter={(v: number) => [`${v} 条`, '']} />
<Legend iconType="circle" iconSize={10} />
</PieChart>
</ResponsiveContainer>
)
}
// ─── 试验状态环形图 ────────────────────────────────────────────────────────────
function TrialDonutChart({ trials }: { trials: DashboardStats['trials'] }) {
const other = trials.total - trials.recruiting
const data = [
{ name: '招募中', value: trials.recruiting, fill: '#2e7d32' },
{ name: '其他', value: other, fill: '#bdbdbd' },
].filter(d => d.value > 0)
if (trials.total === 0) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 220 }}>
<Typography variant="body2" color="text.secondary">暂无试验数据</Typography>
</Box>
)
}
return (
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={85}
paddingAngle={3}
dataKey="value"
>
{data.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Pie>
<ReTooltip formatter={(v: number) => [`${v} 项`, '']} />
<Legend iconType="circle" iconSize={10} />
</PieChart>
</ResponsiveContainer>
)
}
// ─── 最近批量任务柱状图 ───────────────────────────────────────────────────────
function BatchJobBarChart({ jobs }: { jobs: DashboardStats['recent_batch_jobs'] }) {
if (jobs.length === 0) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 220 }}>
<Typography variant="body2" color="text.secondary">暂无批量匹配记录</Typography>
</Box>
)
}
const data = [...jobs].reverse().map((job, i) => ({
name: `#${i + 1}`,
label: job.trial_title ? job.trial_title.slice(0, 10) : `任务${i + 1}`,
completed: job.completed_pairs,
failed: job.failed_pairs,
total: job.total_pairs,
}))
return (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={data} margin={{ top: 4, right: 8, left: -16, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
<ReTooltip
formatter={(v: number, name: string) => [
`${v} 对`,
name === 'completed' ? '完成' : '失败',
]}
labelFormatter={(_, payload) => payload?.[0]?.payload?.label ?? ''}
/>
<Legend
formatter={(v) => v === 'completed' ? '完成对数' : '失败对数'}
iconType="rect"
iconSize={10}
/>
<Bar dataKey="completed" fill="#2e7d32" radius={[3, 3, 0, 0]} maxBarSize={40} />
<Bar dataKey="failed" fill="#d32f2f" radius={[3, 3, 0, 0]} maxBarSize={40} />
</BarChart>
</ResponsiveContainer>
)
}
// ─── 系统关键指标径向图 ────────────────────────────────────────────────────────
function SystemRadialChart({ stats }: { stats: DashboardStats }) {
const data = [
{
name: '批量成功率',
value: stats.batch_jobs.total > 0
? Math.round((stats.batch_jobs.completed / stats.batch_jobs.total) * 100)
: 0,
fill: '#e65100',
},
{
name: '匹配符合率',
value: stats.matching.total > 0
? Math.round((stats.matching.eligible / stats.matching.total) * 100)
: 0,
fill: '#2e7d32',
},
{
name: '试验招募率',
value: stats.trials.total > 0
? Math.round((stats.trials.recruiting / stats.trials.total) * 100)
: 0,
fill: '#7b1fa2',
},
]
return (
<Stack spacing={1.5}>
{items.map(item => (
<Box key={item.label}>
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={0.5}>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ color: item.color }}>
{item.icon}
<Typography variant="body2" fontWeight={500}>{item.label}</Typography>
</Stack>
<Typography variant="body2" fontWeight={600}>
{item.value}
<Typography component="span" variant="caption" color="text.secondary" sx={{ ml: 0.5 }}>
({total > 0 ? ((item.value / total) * 100).toFixed(0) : 0}%)
</Typography>
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={total > 0 ? (item.value / total) * 100 : 0}
sx={{
height: 6, borderRadius: 3,
bgcolor: `${item.color}18`,
'& .MuiLinearProgress-bar': { bgcolor: item.color, borderRadius: 3 },
}}
/>
</Box>
))}
</Stack>
<ResponsiveContainer width="100%" height={220}>
<RadialBarChart
cx="50%"
cy="50%"
innerRadius={30}
outerRadius={100}
data={data}
startAngle={180}
endAngle={-180}
>
<RadialBar
dataKey="value"
cornerRadius={6}
background={{ fill: '#f5f5f5' }}
label={{ position: 'insideStart', fill: '#fff', fontSize: 11, fontWeight: 600 }}
/>
<ReTooltip formatter={(v: number, _: string, props: any) => [`${v}%`, props.payload.name]} />
<Legend
iconType="circle"
iconSize={10}
formatter={(_, entry: any) => entry.payload.name}
/>
</RadialBarChart>
</ResponsiveContainer>
)
}
// ─── 主页面 ───────────────────────────────────────────────────────────────────
export function DashboardPage() {
const [stats, setStats] = useState<DashboardStats | null>(null)
const [loading, setLoading] = useState(true)
......@@ -139,9 +279,7 @@ export function DashboardPage() {
<DashboardIcon color="primary" sx={{ fontSize: 32 }} />
<Box>
<Typography variant="h5">数据概览</Typography>
<Typography variant="subtitle2" color="text.secondary">
系统运行指标实时统计
</Typography>
<Typography variant="subtitle2" color="text.secondary">系统运行指标实时统计</Typography>
</Box>
</Stack>
<Stack direction="row" alignItems="center" spacing={1}>
......@@ -159,10 +297,7 @@ export function DashboardPage() {
</Stack>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{loading && !stats && (
<LinearProgress sx={{ mb: 2, borderRadius: 1 }} />
)}
{loading && !stats && <LinearProgress sx={{ mb: 2, borderRadius: 1 }} />}
{stats && (
<>
......@@ -177,7 +312,6 @@ export function DashboardPage() {
color="#1976d2"
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<StatCard
title="临床试验"
......@@ -186,22 +320,12 @@ export function DashboardPage() {
color="#7b1fa2"
extra={
<Stack direction="row" spacing={0.5} mt={1}>
<Chip
label={`招募中 ${stats.trials.recruiting}`}
size="small"
color="success"
variant="outlined"
/>
<Chip
label={`其他 ${stats.trials.total - stats.trials.recruiting}`}
size="small"
variant="outlined"
/>
<Chip label={`招募中 ${stats.trials.recruiting}`} size="small" color="success" variant="outlined" />
<Chip label={`其他 ${stats.trials.total - stats.trials.recruiting}`} size="small" variant="outlined" />
</Stack>
}
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<StatCard
title="AI 匹配记录"
......@@ -213,178 +337,86 @@ export function DashboardPage() {
color="#2e7d32"
extra={
stats.matching.pending_review > 0 ? (
<Chip
label={`${stats.matching.pending_review} 条待审核`}
size="small"
color="warning"
sx={{ mt: 1 }}
/>
<Chip label={`${stats.matching.pending_review} 条待审核`} size="small" color="warning" sx={{ mt: 1 }} />
) : undefined
}
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<StatCard
title="批量匹配任务"
value={stats.batch_jobs.total}
icon={<BatchPredictionIcon />}
color="#e65100"
title="未读通知"
value={stats.notifications.unread}
subtitle="待医生处理的推荐通知"
icon={<NotificationsActiveIcon />}
color={stats.notifications.unread > 0 ? '#d32f2f' : '#757575'}
extra={
<Stack direction="row" spacing={0.5} mt={1} flexWrap="wrap" gap={0.5}>
<Chip label={`完成 ${stats.batch_jobs.completed}`} size="small" color="success" variant="outlined" />
{stats.batch_jobs.running > 0 && (
<Chip label={`运行中 ${stats.batch_jobs.running}`} size="small" color="info" variant="outlined" />
)}
{stats.batch_jobs.failed > 0 && (
<Chip label={`失败 ${stats.batch_jobs.failed}`} size="small" color="error" variant="outlined" />
)}
</Stack>
stats.batch_jobs.running > 0 ? (
<Chip label={`${stats.batch_jobs.running} 个任务运行中`} size="small" color="info" sx={{ mt: 1 }} />
) : undefined
}
/>
</Grid>
</Grid>
{/* ── 第二行:匹配分布 + 最近批量任务 ── */}
{/* ── 第二行:匹配状态饼图 + 试验状态环形图 + 最近批量任务柱状图 ── */}
<Grid container spacing={2} mb={2}>
{/* 匹配状态分布 */}
<Grid item xs={12} md={4}>
<Card sx={{ height: '100%' }}>
<CardContent>
<Stack direction="row" alignItems="center" spacing={1} mb={2}>
<Stack direction="row" alignItems="center" spacing={1} mb={1}>
<PsychologyIcon color="action" fontSize="small" />
<Typography variant="subtitle1" fontWeight={600}>匹配状态分布</Typography>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{stats.matching.total}
</Typography>
</Stack>
<Divider sx={{ mb: 2 }} />
<MatchingDistribution matching={stats.matching} />
<MatchingPieChart matching={stats.matching} />
</CardContent>
</Card>
</Grid>
{stats.notifications.unread > 0 && (
<Box mt={3} pt={2} sx={{ borderTop: 1, borderColor: 'divider' }}>
<Stack direction="row" alignItems="center" spacing={1}>
<NotificationsActiveIcon color="warning" fontSize="small" />
<Typography variant="body2">
<strong>{stats.notifications.unread}</strong> 条未读通知待处理
</Typography>
</Stack>
</Box>
)}
<Grid item xs={12} md={4}>
<Card sx={{ height: '100%' }}>
<CardContent>
<Stack direction="row" alignItems="center" spacing={1} mb={1}>
<ScienceIcon color="action" fontSize="small" />
<Typography variant="subtitle1" fontWeight={600}>试验招募状态</Typography>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{stats.trials.total}
</Typography>
</Stack>
<TrialDonutChart trials={stats.trials} />
</CardContent>
</Card>
</Grid>
{/* 最近批量匹配任务 */}
<Grid item xs={12} md={8}>
<Grid item xs={12} md={4}>
<Card sx={{ height: '100%' }}>
<CardContent sx={{ pb: 0 }}>
<Stack direction="row" alignItems="center" spacing={1} mb={2}>
<CardContent>
<Stack direction="row" alignItems="center" spacing={1} mb={1}>
<BatchPredictionIcon color="action" fontSize="small" />
<Typography variant="subtitle1" fontWeight={600}>最近批量匹配记录</Typography>
<Typography variant="subtitle1" fontWeight={600}>最近批量匹配</Typography>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{stats.recent_batch_jobs.length}
</Typography>
</Stack>
<BatchJobBarChart jobs={stats.recent_batch_jobs} />
</CardContent>
<Divider />
{stats.recent_batch_jobs.length > 0 ? (
<Table size="small">
<TableHead>
<TableRow sx={{ '& th': { fontWeight: 600, bgcolor: 'grey.50' } }}>
<TableCell>试验项目</TableCell>
<TableCell>状态</TableCell>
<TableCell>进度</TableCell>
<TableCell>触发人</TableCell>
<TableCell>时间</TableCell>
</TableRow>
</TableHead>
<TableBody>
{stats.recent_batch_jobs.map(job => {
const s = JOB_STATUS_MAP[job.status] ?? { label: job.status, color: 'default' as const }
return (
<TableRow key={job.id} hover>
<TableCell>
<Typography variant="body2" noWrap sx={{ maxWidth: 200 }}>
{job.trial_title ?? ''}
</Typography>
</TableCell>
<TableCell>
<Chip label={s.label} color={s.color} size="small" />
</TableCell>
<TableCell>
<Typography variant="body2">
{job.completed_pairs}/{job.total_pairs}
</Typography>
{job.status === 'running' && job.total_pairs > 0 && (
<LinearProgress
variant="determinate"
value={job.progress_pct * 100}
sx={{ mt: 0.5, height: 3, borderRadius: 2 }}
/>
)}
</TableCell>
<TableCell>
<Typography variant="body2">{job.triggered_by}</Typography>
</TableCell>
<TableCell>
<Typography variant="caption" color="text.secondary">
{job.started_at
? new Date(job.started_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
: ''}
</Typography>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
) : (
<CardContent>
<Typography variant="body2" color="text.secondary">暂无批量匹配记录</Typography>
</CardContent>
)}
</Card>
</Grid>
</Grid>
{/* ── 第三行:系统概况 ── */}
{/* ── 第三行:系统关键指标径向图 ── */}
<Card>
<CardContent>
<Typography variant="subtitle1" fontWeight={600} mb={1.5}>系统概况</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={3}>
{[
{ label: '患者数据完整率', value: stats.patients.total > 0 ? 100 : 0, color: '#1976d2' },
{
label: '试验招募率',
value: stats.trials.total > 0 ? Math.round((stats.trials.recruiting / stats.trials.total) * 100) : 0,
color: '#7b1fa2',
},
{
label: '匹配符合率',
value: stats.matching.total > 0 ? Math.round((stats.matching.eligible / stats.matching.total) * 100) : 0,
color: '#2e7d32',
},
{
label: '批量任务成功率',
value: stats.batch_jobs.total > 0 ? Math.round((stats.batch_jobs.completed / stats.batch_jobs.total) * 100) : 0,
color: '#e65100',
},
].map(item => (
<Grid item xs={12} sm={6} md={3} key={item.label}>
<Box>
<Stack direction="row" justifyContent="space-between" mb={0.5}>
<Typography variant="body2" color="text.secondary">{item.label}</Typography>
<Typography variant="body2" fontWeight={700} color={item.color}>{item.value}%</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={item.value}
sx={{
height: 8, borderRadius: 4,
bgcolor: `${item.color}18`,
'& .MuiLinearProgress-bar': { bgcolor: item.color, borderRadius: 4 },
}}
/>
</Box>
</Grid>
))}
</Grid>
<Stack direction="row" alignItems="center" spacing={1} mb={1}>
<DashboardIcon color="action" fontSize="small" />
<Typography variant="subtitle1" fontWeight={600}>系统关键指标</Typography>
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
试验招募率 · 匹配符合率 · 批量成功率
</Typography>
</Stack>
<SystemRadialChart stats={stats} />
</CardContent>
</Card>
</>
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment