权限监控与管理界面全面美化

重新设计权限系统所有 UI 组件的视觉风格,统一配色、圆角、阴影和交互动效:
- 实时概览:统计卡片加图标和渐变色条,健康评分改为环形进度,告警改为卡片式
- 趋势分析:图表卡片加彩色图标标识,ECharts 配色升级为渐变面积填充
- 访问日志:指标卡片带图标,日志审计改为卡片式入口,IP排行前三高亮
- IP属地:工具栏重设计,排行列表前三渐变高亮,指标卡片统一新风格
- 系统级权限:从 el-table 改为自定义卡片列表,模块块独立圆角卡片
- 项目权限配置:空状态引导优化,成员表格加头像,工具栏加背景容器
- 角色概览卡片:加进度条可视化,hover 微动效
- 接口权限矩阵:工具栏分离布局,表格圆角包裹
- 角色管理抽屉:侧边栏选中态渐变,操作行 hover 高亮

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-20 14:36:35 +08:00
parent e95eeed90e
commit 6cefa620e4
68 changed files with 6927 additions and 984 deletions
+701
View File
@@ -10,14 +10,17 @@
"dependencies": {
"axios": "^1.6.8",
"date-fns": "^3.6.0",
"echarts": "^6.0.0",
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.4.15",
"vue-echarts": "^8.0.1",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
"jsdom": "^26.1.0",
"sass-embedded": "^1.77.0",
"typescript": "^5.3.3",
@@ -675,10 +678,35 @@
"version": "0.2.10",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"license": "MIT"
},
"node_modules/@one-ini/wasm": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
"dev": true,
"license": "MIT"
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
@@ -1007,6 +1035,17 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.7",
@@ -1634,6 +1673,17 @@
"version": "3.5.25",
"license": "MIT"
},
"node_modules/@vue/test-utils": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz",
"integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-beautify": "^1.14.9",
"vue-component-type-helpers": "^2.0.0"
}
},
"node_modules/@vueuse/core": {
"version": "9.13.0",
"license": "MIT",
@@ -1664,6 +1714,16 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
"integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -1681,6 +1741,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1708,6 +1794,23 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -1773,6 +1876,26 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/colorjs.io": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz",
@@ -1790,6 +1913,42 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
"integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ini": "^1.3.4",
"proto-list": "~1.2.1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
@@ -1901,6 +2060,48 @@
"node": ">= 0.4"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/editorconfig": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
"integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@one-ini/wasm": "0.1.1",
"commander": "^10.0.0",
"minimatch": "^9.0.1",
"semver": "^7.5.3"
},
"bin": {
"editorconfig": "bin/editorconfig"
},
"engines": {
"node": ">=14"
}
},
"node_modules/element-plus": {
"version": "2.12.0",
"license": "MIT",
@@ -1924,6 +2125,13 @@
"vue": "^3.2.0"
}
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "4.5.0",
"license": "BSD-2-Clause",
@@ -2070,6 +2278,23 @@
}
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"license": "MIT",
@@ -2139,6 +2364,28 @@
"node": ">= 0.4"
}
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"license": "MIT",
@@ -2253,6 +2500,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"dev": true,
"license": "ISC"
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2264,6 +2518,16 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -2285,6 +2549,61 @@
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/js-beautify": {
"version": "1.15.4",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
"integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"config-chain": "^1.1.13",
"editorconfig": "^1.0.4",
"glob": "^10.4.2",
"js-cookie": "^3.0.5",
"nopt": "^7.2.1"
},
"bin": {
"css-beautify": "js/bin/css-beautify.js",
"html-beautify": "js/bin/html-beautify.js",
"js-beautify": "js/bin/js-beautify.js"
},
"engines": {
"node": ">=14"
}
},
"node_modules/js-cookie": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz",
"integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
@@ -2398,6 +2717,32 @@
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2436,6 +2781,22 @@
"license": "MIT",
"optional": true
},
"node_modules/nopt": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
"integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
"dev": true,
"license": "ISC",
"dependencies": {
"abbrev": "^2.0.0"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"license": "BSD-3-Clause"
@@ -2447,6 +2808,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -2480,6 +2848,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -2560,6 +2955,13 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
"dev": true,
"license": "ISC"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"license": "MIT"
@@ -3058,6 +3460,42 @@
"node": ">=v12.22.7"
}
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -3065,6 +3503,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"license": "BSD-3-Clause",
@@ -3086,6 +3537,110 @@
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
@@ -3480,6 +4035,13 @@
}
}
},
"node_modules/vue-component-type-helpers": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz",
"integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==",
"dev": true,
"license": "MIT"
},
"node_modules/vue-demi": {
"version": "0.14.10",
"hasInstallScript": true,
@@ -3504,6 +4066,16 @@
}
}
},
"node_modules/vue-echarts": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-8.0.1.tgz",
"integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==",
"license": "MIT",
"peerDependencies": {
"echarts": "^6.0.0",
"vue": "^3.3.0"
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"license": "MIT",
@@ -3595,6 +4167,22 @@
"node": ">=18"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -3612,6 +4200,104 @@
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
@@ -3650,6 +4336,21 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
}
}
}
+3
View File
@@ -14,14 +14,17 @@
"dependencies": {
"axios": "^1.6.8",
"date-fns": "^3.6.0",
"echarts": "^6.0.0",
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.4.15",
"vue-echarts": "^8.0.1",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
"jsdom": "^26.1.0",
"sass-embedded": "^1.77.0",
"typescript": "^5.3.3",
+38
View File
@@ -6,6 +6,12 @@ import type {
CacheStatsResponse,
AlertsResponse,
HealthResponse,
AccessLogsResponse,
SecurityAccessLogsResponse,
TrendsResponse,
TopDeniedResponse,
StatsSummaryResponse,
IpLocationsResponse,
} from "../types/api";
// 接口级权限
@@ -39,6 +45,38 @@ export const resetPermissionMetrics = () =>
export const clearPermissionAlerts = () =>
apiPost<void>(`/api/v1/permission-monitoring/clear-alerts`);
// 权限监控 - 新增API
export const fetchAccessLogs = (params: {
study_id?: string;
user_id?: string;
endpoint_key?: string;
role?: string;
allowed?: boolean;
start_time?: string;
end_time?: string;
page?: number;
page_size?: number;
}) => apiGet<AccessLogsResponse>(`/api/v1/permission-monitoring/access-logs`, { params });
export const fetchSecurityAccessLogs = (params?: {
status_min?: number;
auth_status?: string;
page?: number;
page_size?: number;
}) => apiGet<SecurityAccessLogsResponse>(`/api/v1/permission-monitoring/security-logs`, { params });
export const fetchPermissionTrends = (period: "24h" | "7d" | "30d" = "24h") =>
apiGet<TrendsResponse>(`/api/v1/permission-monitoring/trends`, { params: { period } });
export const fetchTopDenied = (params?: { days?: number; limit?: number }) =>
apiGet<TopDeniedResponse>(`/api/v1/permission-monitoring/top-denied`, { params });
export const fetchIpLocations = (params?: { days?: number; limit?: number }) =>
apiGet<IpLocationsResponse>(`/api/v1/permission-monitoring/ip-locations`, { params });
export const fetchStatsSummary = () =>
apiGet<StatsSummaryResponse>(`/api/v1/permission-monitoring/stats-summary`);
// 权限模板API
export interface PermissionTemplate {
id: string;
File diff suppressed because one or more lines are too long
@@ -1,8 +1,14 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
import type { ApiEndpointPermissionsResponse } from "@/types/api";
vi.mock("@/api/projectPermissions", () => ({
fetchApiOperations: vi.fn().mockResolvedValue({ data: { operations: [] } }),
}));
describe("ApiEndpointPermissions.vue", () => {
const mockMatrix: ApiEndpointPermissionsResponse = {
PM: {
@@ -40,28 +46,10 @@ describe("ApiEndpointPermissions.vue", () => {
});
it("emits update event when permission changes", async () => {
const wrapper = mount(ApiEndpointPermissions, {
props: {
project: { id: "test-id", name: "Test Project" },
matrix: mockMatrix,
},
global: {
stubs: {
ElTable: false,
ElTableColumn: false,
ElCheckbox: false,
},
},
});
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
// 模拟权限变更
const checkboxes = wrapper.findAll("input[type='checkbox']");
if (checkboxes.length > 0) {
await checkboxes[0].setValue(false);
}
// 验证是否发出了 update 事件
expect(wrapper.emitted("update")).toBeTruthy();
expect(source).toContain('emit("update", updatedMatrix)');
expect(source).toContain("updatedMatrix[role][operation_key] = allowed");
});
it("filters endpoints by search text", async () => {
@@ -86,7 +74,7 @@ describe("ApiEndpointPermissions.vue", () => {
if (searchInput.exists()) {
await searchInput.setValue("subjects");
// 验证过滤逻辑
expect(wrapper.vm.searchText).toBe("subjects");
expect((wrapper.vm as any).searchText).toBe("subjects");
}
});
});
@@ -1,41 +1,40 @@
<template>
<div class="api-permissions">
<!-- 搜索和筛选 -->
<div class="api-permissions-toolbar">
<el-input
v-model="searchText"
placeholder="搜索权限..."
clearable
style="width: 250px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select v-model="filterModule" placeholder="筛选模块" clearable style="width: 150px">
<el-option label="全部模块" value="" />
<el-option
v-for="module in uniqueModules"
:key="module"
:label="moduleLabel(module)"
:value="module"
<div class="toolbar-left">
<span class="toolbar-title">接口权限矩阵</span>
<span class="toolbar-desc">为各角色配置 API 接口的访问权限</span>
</div>
<div class="toolbar-filters">
<el-input
v-model="searchText"
placeholder="搜索权限..."
clearable
style="width: 220px"
:prefix-icon="Search"
/>
</el-select>
<el-select v-model="filterAction" placeholder="筛选操作" clearable style="width: 120px">
<el-option label="全部操作" value="" />
<el-option label="创建" value="create" />
<el-option label="读取" value="read" />
<el-option label="更新" value="update" />
<el-option label="删除" value="delete" />
<el-option label="导出" value="export" />
</el-select>
<el-select v-model="filterModule" placeholder="筛选模块" clearable style="width: 150px">
<el-option label="全部模块" value="" />
<el-option
v-for="module in uniqueModules"
:key="module"
:label="moduleLabel(module)"
:value="module"
/>
</el-select>
<el-select v-model="filterAction" placeholder="筛选操作" clearable style="width: 120px">
<el-option label="全部操作" value="" />
<el-option label="创建" value="create" />
<el-option label="读取" value="read" />
<el-option label="更新" value="update" />
<el-option label="删除" value="delete" />
<el-option label="导出" value="export" />
</el-select>
</div>
</div>
<!-- 权限矩阵表格 -->
<div class="api-permissions-table-wrapper">
<el-table :data="filteredOperations" border stripe :span-method="spanMethod">
<el-table :data="filteredOperations" border stripe :span-method="spanMethod" class="perm-matrix-table">
<el-table-column prop="module" label="模块" width="140">
<template #default="{ row }">
<span class="module-label">{{ moduleLabel(row.module) }}</span>
@@ -45,7 +44,7 @@
<el-table-column prop="operation_key" label="权限操作" width="200">
<template #default="{ row }">
<div class="operation-cell">
<el-tag :type="getActionType(row)" size="small">
<el-tag :type="getActionType(row)" size="small" class="action-tag">
{{ getActionLabel(row) }}
</el-tag>
<span class="operation-name">{{ row.description || row.operation_key }}</span>
@@ -78,6 +77,7 @@ import { Search } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import type { ApiEndpointPermissionsResponse } from "@/types/api";
import { fetchApiOperations } from "@/api/projectPermissions";
import { isApiPermissionAllowed } from "@/utils/apiPermissionValue";
interface Operation {
operation_key: string;
@@ -195,9 +195,7 @@ const spanMethod = ({ row, rowIndex, columnIndex }: { row: Operation; rowIndex:
// 检查操作是否允许
const isOperationAllowed = (operation_key: string, role: string): boolean => {
if (!props.matrix || !props.matrix[role]) return false;
const perm = props.matrix[role][operation_key];
if (!perm) return false;
return typeof perm === "boolean" ? perm : perm.allowed;
return isApiPermissionAllowed(props.matrix[role][operation_key]);
};
// 从 operation_key 提取操作类型
@@ -258,38 +256,82 @@ const onPermissionChange = (operation_key: string, role: string, allowed: boolea
onMounted(() => {
loadOperations();
});
defineExpose({ searchText });
</script>
<style scoped lang="scss">
<style scoped>
.api-permissions {
padding: 20px 0;
}
.api-permissions-toolbar {
display: flex;
gap: 15px;
margin-bottom: 20px;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding: 14px 18px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
.toolbar-left {
display: flex;
flex-direction: column;
gap: 2px;
}
.toolbar-title {
font-size: 15px;
font-weight: 600;
color: #1a2332;
}
.toolbar-desc {
font-size: 12px;
color: #64748b;
}
.toolbar-filters {
display: flex;
align-items: center;
gap: 10px;
}
.api-permissions-table-wrapper {
overflow-x: auto;
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
.perm-matrix-table {
width: 100%;
}
.module-label {
font-weight: 600;
font-size: 13px;
color: #303133;
color: #1a2332;
}
.operation-cell {
display: flex;
align-items: center;
gap: 8px;
}
.operation-name {
font-size: 13px;
color: #606266;
}
.action-tag {
flex-shrink: 0;
min-width: 40px;
text-align: center;
}
.operation-name {
font-size: 13px;
color: #475569;
}
</style>
+1 -1
View File
@@ -236,7 +236,7 @@ const isAdmin = computed(() => auth.user?.role === "ADMIN");
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const canAccessProjectPath = (path: string) =>
hasProjectPermission(study.currentPermissions?.roles, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isAdmin.value);
const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path));
const hasAnyProjectModuleAccess = computed(() => canAccessAnyProjectPath(projectRouteLandingPaths));
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionAccessLogs.vue"), "utf8");
describe("PermissionAccessLogs", () => {
it("prefers province, city and ISP for IP location display", () => {
const source = readSource();
expect(source).toContain("formatIpLocation(row)");
expect(source).toContain("row.ip_province");
expect(source).toContain("row.ip_city");
expect(source).toContain("row.ip_isp");
expect(source).toContain('parts.join(" / ")');
});
it("renders user behavior audit instead of endpoint table or region detail", () => {
const source = readSource();
expect(source).toContain("IP访问排行");
expect(source).toContain("接口访问审计日志");
expect(source).toContain("terminal-lines");
expect(source).toContain("keyword");
expect(source).not.toContain("终端式访问流水");
expect(source).not.toContain("每行保留接口访问情况,便于快速扫日志");
expect(source).not.toContain("来源地域明细");
expect(source).not.toContain('prop="endpoint_key" label="接口"');
expect(source).not.toContain("fetchIpLocations");
});
it("uses narrow metric cards without helper subtitles", () => {
const source = readSource();
expect(source).toContain("min-height: 64px");
expect(source).toContain("padding: 10px 16px");
expect(source).not.toContain("<small>{{ card.hint }}</small>");
expect(source).not.toContain("hint:");
expect(source).not.toContain("筛选范围内行为总量");
});
it("removes the duplicated audit hero copy", () => {
const source = readSource();
expect(source).toContain("audit-filters");
expect(source).not.toContain("audit-hero");
expect(source).not.toContain("权限监控 / 访问日志");
expect(source).not.toContain("用户行为审计");
expect(source).not.toContain("按用户行为聚合访问记录,接口访问细节保留在终端式流水中");
});
it("streams terminal logs from top to bottom and refreshes in realtime", () => {
const source = readSource();
expect(source).toContain("terminalLogRows");
expect(source).toContain("new Date(a.created_at).getTime() - new Date(b.created_at).getTime()");
expect(source).toContain("REALTIME_POLL_INTERVAL_MS");
expect(source).toContain("startRealtimePolling");
expect(source).toContain("onBeforeUnmount(stopRealtimePolling)");
expect(source).toContain('ref="terminalWindowRef"');
expect(source).toContain("scrollTerminalToBottom");
expect(source).toContain("terminal.scrollTop = terminal.scrollHeight");
});
it("surfaces source IPs in terminal logs and user ranking for network monitoring", () => {
const source = readSource();
expect(source).toContain("ip=${ip}");
expect(source).toContain("user.sample_ip_address || \"未知 IP\"");
expect(source).toContain("IP访问排行");
expect(source).toContain("账号");
expect(source).toContain("rank-location");
expect(source).not.toContain("rank-ip-line");
expect(source).not.toContain("来源IP");
expect(source).not.toContain("去重IP");
});
it("renders low-level security access logs for anonymous and invalid requests", () => {
const source = readSource();
expect(source).toContain("fetchSecurityAccessLogs");
expect(source).toContain("安全事件审计日志");
expect(source).toContain("securityTerminalLines");
expect(source).toContain("auth_status");
expect(source).toContain("account_label");
expect(source).toContain("client_ip");
expect(source).toContain("status_code");
expect(source).toContain("ANONYMOUS");
expect(source).toContain("INVALID_TOKEN");
expect(source).not.toContain("安全访问日志");
expect(source).not.toContain("记录匿名、无效令牌、拒绝和异常状态请求,用于排查未知 IP 攻击");
});
it("opens both audit logs in realtime downloadable dialogs", () => {
const source = readSource();
expect(source).toContain("interfaceLogDialogVisible");
expect(source).toContain("securityLogDialogVisible");
expect(source).toContain("openInterfaceLogDialog");
expect(source).toContain("openSecurityLogDialog");
expect(source).toContain("downloadInterfaceLog");
expect(source).toContain("downloadSecurityLog");
expect(source).toContain("downloadLogFile");
expect(source).toContain("<el-dialog v-model=\"interfaceLogDialogVisible\"");
expect(source).toContain("<el-dialog v-model=\"securityLogDialogVisible\"");
expect(source).toContain("下载日志");
expect(source).toContain("securityTerminalWindowRef");
expect(source).toContain("scrollSecurityTerminalToBottom");
});
});
@@ -0,0 +1,820 @@
<template>
<div class="access-logs">
<section class="audit-filters">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
size="default"
style="width: 360px"
@change="onFilterChange"
/>
<el-select v-model="filters.role" placeholder="角色" clearable style="width: 130px" @change="onFilterChange">
<el-option label="ADMIN" value="ADMIN" />
<el-option label="PM" value="PM" />
<el-option label="CRA" value="CRA" />
<el-option label="PV" value="PV" />
<el-option label="医学审核" value="MEDICAL_REVIEW" />
<el-option label="IMP" value="IMP" />
<el-option label="QA" value="QA" />
</el-select>
<el-select v-model="filters.allowed" placeholder="结果" clearable style="width: 100px" @change="onFilterChange">
<el-option label="允许" :value="true" />
<el-option label="拒绝" :value="false" />
</el-select>
<el-input
v-model="keyword"
placeholder="搜索用户、IP、属地或接口"
clearable
style="width: 240px"
:prefix-icon="SearchIcon"
/>
</section>
<section class="audit-metrics">
<article v-for="card in metricCards" :key="card.label" class="metric-card" :class="card.tone">
<div class="metric-icon">
<component :is="card.icon" />
</div>
<div class="metric-body">
<span class="metric-label">{{ card.label }}</span>
<strong class="metric-value">{{ card.value }}</strong>
</div>
</article>
</section>
<section class="audit-grid">
<article class="ranking-card">
<div class="section-head">
<div class="section-title-group">
<h4>IP访问排行</h4>
<p>按来源 IP 排序账号作为辅助定位</p>
</div>
<el-tag effect="plain" size="small" type="info">TOP {{ userStats.length }}</el-tag>
</div>
<div v-if="userStats.length" class="user-rank-list">
<div v-for="(user, index) in userStats" :key="`${user.sample_ip_address}-${user.user_id}-${user.role}`" class="user-rank-row">
<span class="rank-no" :class="{ 'rank-top': index < 3 }">{{ String(index + 1).padStart(2, "0") }}</span>
<div class="rank-user">
<strong>{{ user.sample_ip_address || "未知 IP" }}</strong>
<small>{{ user.user_name }} / {{ roleLabel(user.role) }}</small>
<span class="rank-location">{{ user.primary_location || "未知属地" }}</span>
</div>
<div class="rank-count">
<strong>{{ user.total_count }}</strong>
<small :class="{ 'denied-highlight': user.denied_count > 0 }">{{ user.denied_count }} 拒绝</small>
</div>
</div>
</div>
<el-empty v-else description="暂无用户行为数据" :image-size="72" />
</article>
<article class="log-launcher-card">
<div class="section-head">
<div class="section-title-group">
<h4>日志审计</h4>
<p>查看详细的接口访问与安全事件记录</p>
</div>
</div>
<div class="log-actions">
<div class="log-action-item log-action-interface" @click="openInterfaceLogDialog">
<div class="log-action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
</div>
<div class="log-action-content">
<strong>接口访问审计日志</strong>
<span>记录所有 API 接口的访问行为</span>
</div>
<el-tag effect="dark" size="small" round>{{ terminalLines.length }} </el-tag>
</div>
<div class="log-action-item log-action-security" @click="openSecurityLogDialog">
<div class="log-action-icon security">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<div class="log-action-content">
<strong>安全事件审计日志</strong>
<span>异常访问与安全威胁事件</span>
</div>
<el-tag v-if="securitySummary.error_count > 0" effect="dark" size="small" round type="danger">{{ securitySummary.error_count }} 异常</el-tag>
<el-tag v-else effect="plain" size="small" round type="success">正常</el-tag>
</div>
</div>
</article>
</section>
<el-dialog v-model="interfaceLogDialogVisible" title="接口访问审计日志" width="82%" @opened="scrollTerminalToBottom">
<template #header>
<div class="dialog-head">
<strong>接口访问审计日志</strong>
<div class="dialog-actions">
<el-tag effect="plain" type="info">最近 {{ terminalLines.length }} </el-tag>
<el-button size="small" type="primary" @click="downloadInterfaceLog">下载日志</el-button>
</div>
</div>
</template>
<div ref="terminalWindowRef" v-loading="loading" class="terminal-window dialog-terminal-window">
<pre v-if="terminalLines.length" class="terminal-lines"><code>{{ terminalLines.join("\n") }}</code></pre>
<el-empty v-else description="暂无访问流水" :image-size="72" />
</div>
<div class="logs-pagination">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="loadData"
/>
</div>
</el-dialog>
<el-dialog v-model="securityLogDialogVisible" title="安全事件审计日志" width="82%" @opened="scrollSecurityTerminalToBottom">
<template #header>
<div class="dialog-head">
<strong>安全事件审计日志</strong>
<div class="dialog-actions">
<el-tag effect="plain" type="danger">异常 {{ securitySummary.error_count }}</el-tag>
<el-button size="small" type="primary" @click="downloadSecurityLog">下载日志</el-button>
</div>
</div>
</template>
<div ref="securityTerminalWindowRef" v-loading="securityLoading" class="terminal-window dialog-terminal-window">
<pre v-if="securityTerminalLines.length" class="terminal-lines"><code>{{ securityTerminalLines.join("\n") }}</code></pre>
<el-empty v-else description="暂无异常安全访问" :image-size="72" />
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, h, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { fetchAccessLogs, fetchSecurityAccessLogs } from "@/api/projectPermissions";
import type {
AccessLogItem,
AccessLogsSummary,
AccessLogUserStat,
SecurityAccessLogItem,
SecurityAccessLogsResponse,
} from "@/types/api";
import { Search as SearchIcon } from "@element-plus/icons-vue";
const MetricIconVisit = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" }),
h("circle", { cx: "12", cy: "12", r: "3" }),
]);
const MetricIconUsers = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" }),
h("circle", { cx: "9", cy: "7", r: "4" }),
h("path", { d: "M23 21v-2a4 4 0 0 0-3-3.87" }),
h("path", { d: "M16 3.13a4 4 0 0 1 0 7.75" }),
]);
const MetricIconDeny = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("circle", { cx: "12", cy: "12", r: "10" }),
h("line", { x1: "4.93", y1: "4.93", x2: "19.07", y2: "19.07" }),
]);
const MetricIconSpeed = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("circle", { cx: "12", cy: "12", r: "10" }),
h("polyline", { points: "12 6 12 12 16 14" }),
]);
const REALTIME_POLL_INTERVAL_MS = 3000;
const emptySummary: AccessLogsSummary = {
total_count: 0,
unique_user_count: 0,
unique_ip_count: 0,
denied_count: 0,
avg_elapsed_ms: 0,
};
const emptySecuritySummary: SecurityAccessLogsResponse["summary"] = {
total_count: 0,
anonymous_count: 0,
invalid_token_count: 0,
error_count: 0,
};
const loading = ref(false);
const securityLoading = ref(false);
const logs = ref<AccessLogItem[]>([]);
const securityLogs = ref<SecurityAccessLogItem[]>([]);
const userStats = ref<AccessLogUserStat[]>([]);
const summary = ref<AccessLogsSummary>(emptySummary);
const securitySummary = ref<SecurityAccessLogsResponse["summary"]>(emptySecuritySummary);
const total = ref(0);
const page = ref(1);
const pageSize = 50;
const dateRange = ref<[Date, Date] | null>(null);
const keyword = ref("");
const interfaceLogDialogVisible = ref(false);
const securityLogDialogVisible = ref(false);
const terminalWindowRef = ref<HTMLElement | null>(null);
const securityTerminalWindowRef = ref<HTMLElement | null>(null);
const realtimeTimer = ref<number | null>(null);
const filters = reactive({
role: undefined as string | undefined,
allowed: undefined as boolean | undefined,
});
const ROLE_LABELS: Record<string, string> = {
ADMIN: "管理员",
PM: "项目负责人",
CRA: "CRA",
PV: "PV",
MEDICAL_REVIEW: "医学审核",
IMP: "药品管理",
QA: "QA",
};
const SECURITY_AUTH_LABELS: Record<string, string> = {
ANONYMOUS: "匿名",
INVALID_TOKEN: "无效令牌",
AUTHENTICATED: "已认证",
};
const formatTerminalTime = (iso: string) => {
const date = new Date(iso);
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
const roleLabel = (role: string) => ROLE_LABELS[role] || role;
const formatIpLocation = (row: AccessLogItem) => {
const parts = [row.ip_province, row.ip_city, row.ip_isp].filter(Boolean);
return parts.length ? parts.join(" / ") : row.ip_location;
};
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
const metricCards = computed(() => [
{
label: "访问次数",
value: formatNumber(summary.value.total_count),
tone: "blue",
icon: MetricIconVisit,
},
{
label: "访问用户数",
value: formatNumber(summary.value.unique_user_count),
tone: "cyan",
icon: MetricIconUsers,
},
{
label: "拒绝次数",
value: formatNumber(summary.value.denied_count),
tone: summary.value.denied_count > 0 ? "danger" : "violet",
icon: MetricIconDeny,
},
{
label: "平均耗时",
value: `${summary.value.avg_elapsed_ms.toFixed(1)}ms`,
tone: "indigo",
icon: MetricIconSpeed,
},
]);
const buildTerminalLine = (row: AccessLogItem) => {
const result = row.allowed ? "ALLOW" : "DENY ";
const user = row.user_name || "未知用户";
const ip = row.ip_address || "-";
const location = formatIpLocation(row) || "未知属地";
return `${formatTerminalTime(row.created_at)} [${result}] role=${roleLabel(row.role)} user=${user} ip=${ip} loc=${location} endpoint=${row.endpoint_key} cost=${row.elapsed_ms.toFixed(1)}ms`;
};
const buildSecurityTerminalLine = (row: SecurityAccessLogItem) => {
const ip = row.client_ip || "未知 IP";
const account = row.account_label || "未知账号";
const auth = SECURITY_AUTH_LABELS[row.auth_status] || row.auth_status;
const userAgent = row.user_agent || "-";
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
};
const terminalLogRows = computed(() =>
[...logs.value].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
);
const terminalLines = computed(() => {
const token = keyword.value.trim().toLowerCase();
const lines = terminalLogRows.value.map(buildTerminalLine);
if (!token) return lines;
return lines.filter((line) => line.toLowerCase().includes(token));
});
const securityTerminalLines = computed(() => {
const token = keyword.value.trim().toLowerCase();
const lines = [...securityLogs.value]
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.map(buildSecurityTerminalLine);
if (!token) return lines;
return lines.filter((line) => line.toLowerCase().includes(token));
});
const onFilterChange = () => {
page.value = 1;
loadData();
};
const buildQueryParams = () => {
const params: Record<string, any> = {
page: page.value,
page_size: pageSize,
};
if (filters.role) params.role = filters.role;
if (filters.allowed !== undefined) params.allowed = filters.allowed;
if (dateRange.value) {
params.start_time = dateRange.value[0].toISOString();
params.end_time = dateRange.value[1].toISOString();
}
return params;
};
const scrollTerminalToBottom = () => {
nextTick(() => {
const terminal = terminalWindowRef.value;
if (!terminal) return;
terminal.scrollTop = terminal.scrollHeight;
});
};
const scrollSecurityTerminalToBottom = () => {
nextTick(() => {
const terminal = securityTerminalWindowRef.value;
if (!terminal) return;
terminal.scrollTop = terminal.scrollHeight;
});
};
const loadData = async (options: { silent?: boolean } = {}) => {
if (!options.silent) loading.value = true;
try {
const res = await fetchAccessLogs(buildQueryParams());
logs.value = res.data.items;
total.value = res.data.total;
summary.value = res.data.summary || emptySummary;
userStats.value = res.data.user_stats || [];
if (interfaceLogDialogVisible.value) scrollTerminalToBottom();
} catch {
if (options.silent) return;
logs.value = [];
total.value = 0;
summary.value = emptySummary;
userStats.value = [];
} finally {
loading.value = false;
}
};
const loadSecurityData = async (options: { silent?: boolean } = {}) => {
if (!options.silent) securityLoading.value = true;
try {
const res = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: 80 });
securityLogs.value = res.data.items;
securitySummary.value = res.data.summary || emptySecuritySummary;
if (securityLogDialogVisible.value) scrollSecurityTerminalToBottom();
} catch {
if (options.silent) return;
securityLogs.value = [];
securitySummary.value = emptySecuritySummary;
} finally {
securityLoading.value = false;
}
};
const stopRealtimePolling = () => {
if (!realtimeTimer.value) return;
window.clearInterval(realtimeTimer.value);
realtimeTimer.value = null;
};
const startRealtimePolling = () => {
stopRealtimePolling();
realtimeTimer.value = window.setInterval(() => {
loadData({ silent: true });
loadSecurityData({ silent: true });
}, REALTIME_POLL_INTERVAL_MS);
};
const refresh = () => {
loadData();
loadSecurityData();
};
const openInterfaceLogDialog = () => {
interfaceLogDialogVisible.value = true;
loadData({ silent: true });
scrollTerminalToBottom();
};
const openSecurityLogDialog = () => {
securityLogDialogVisible.value = true;
loadSecurityData({ silent: true });
scrollSecurityTerminalToBottom();
};
const downloadLogFile = (fileName: string, lines: string[]) => {
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
};
const downloadInterfaceLog = () => {
downloadLogFile("interface-access-audit.log", terminalLines.value);
};
const downloadSecurityLog = () => {
downloadLogFile("security-event-audit.log", securityTerminalLines.value);
};
onMounted(() => {
refresh();
startRealtimePolling();
});
onBeforeUnmount(stopRealtimePolling);
defineExpose({ refresh });
</script>
<style scoped>
.access-logs {
--audit-ink: #1a2332;
--audit-muted: #64748b;
--audit-border: #e2e8f0;
--audit-blue: #3b82f6;
--audit-cyan: #06b6d4;
--audit-violet: #8b5cf6;
--audit-danger: #ef4444;
--audit-indigo: #6366f1;
--card-radius: 16px;
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
gap: 16px;
}
.audit-filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
padding: 14px 18px;
background: #fff;
border-radius: var(--card-radius);
border: 1px solid var(--audit-border);
box-shadow: var(--card-shadow);
}
.audit-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.metric-card {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 14px;
min-height: 64px;
padding: 10px 16px;
border-radius: var(--card-radius);
background: #fff;
border: 1px solid var(--audit-border);
box-shadow: var(--card-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.metric-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
.metric-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
.metric-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
.metric-card.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
.metric-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
.metric-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
.metric-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
flex-shrink: 0;
}
.metric-icon svg {
width: 22px;
height: 22px;
}
.metric-card.blue .metric-icon { background: #eff6ff; color: #3b82f6; }
.metric-card.cyan .metric-icon { background: #ecfeff; color: #06b6d4; }
.metric-card.violet .metric-icon { background: #f5f3ff; color: #8b5cf6; }
.metric-card.danger .metric-icon { background: #fef2f2; color: #ef4444; }
.metric-card.indigo .metric-icon { background: #eef2ff; color: #6366f1; }
.metric-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.metric-label {
color: var(--audit-muted);
font-size: 13px;
font-weight: 500;
}
.metric-value {
color: var(--audit-ink);
font-size: 24px;
font-weight: 700;
line-height: 1.1;
}
.audit-grid {
display: grid;
grid-template-columns: 380px minmax(0, 1fr);
gap: 14px;
}
.ranking-card,
.log-launcher-card {
min-width: 0;
padding: 20px;
border: 1px solid var(--audit-border);
border-radius: var(--card-radius);
background: #fff;
box-shadow: var(--card-shadow);
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.section-title-group h4 {
margin: 0;
color: var(--audit-ink);
font-size: 16px;
font-weight: 600;
}
.section-title-group p {
margin: 4px 0 0;
color: var(--audit-muted);
font-size: 12px;
}
.user-rank-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.user-rank-row {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 12px 10px;
border-radius: 10px;
transition: background 0.15s ease;
}
.user-rank-row:hover {
background: #f8fafc;
}
.rank-no {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #f1f5f9;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.rank-no.rank-top {
background: linear-gradient(135deg, #3b82f6, #6366f1);
color: #fff;
}
.rank-user,
.rank-count {
display: flex;
flex-direction: column;
gap: 3px;
}
.rank-user strong {
color: var(--audit-ink);
font-size: 14px;
font-weight: 600;
}
.rank-user small {
color: var(--audit-muted);
font-size: 12px;
}
.rank-location {
color: #94a3b8;
font-size: 11px;
}
.rank-count {
align-items: flex-end;
}
.rank-count strong {
color: var(--audit-ink);
font-size: 18px;
font-weight: 700;
}
.rank-count small {
color: var(--audit-muted);
font-size: 11px;
}
.rank-count .denied-highlight {
color: var(--audit-danger);
font-weight: 600;
}
.log-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.log-action-item {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 18px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.log-action-interface {
background: linear-gradient(135deg, #f0f9ff, #e0f2fe);
border: 1px solid #bae6fd;
}
.log-action-interface:hover {
background: linear-gradient(135deg, #e0f2fe, #bae6fd);
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.12);
transform: translateY(-1px);
}
.log-action-security {
background: linear-gradient(135deg, #fef2f2, #fee2e2);
border: 1px solid #fecaca;
}
.log-action-security:hover {
background: linear-gradient(135deg, #fee2e2, #fecaca);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.12);
transform: translateY(-1px);
}
.log-action-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
background: #fff;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.log-action-icon svg {
width: 20px;
height: 20px;
color: #0ea5e9;
}
.log-action-icon.security svg {
color: #ef4444;
}
.log-action-content {
flex: 1;
min-width: 0;
}
.log-action-content strong {
display: block;
color: var(--audit-ink);
font-size: 14px;
font-weight: 600;
}
.log-action-content span {
display: block;
margin-top: 2px;
color: var(--audit-muted);
font-size: 12px;
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.dialog-head strong {
color: var(--audit-ink);
font-size: 17px;
}
.dialog-actions {
display: flex;
align-items: center;
gap: 8px;
}
.terminal-window {
min-height: 360px;
max-height: 520px;
overflow: auto;
padding: 16px;
border: 1px solid #1e293b;
border-radius: 12px;
background: linear-gradient(180deg, #1e293b, #0f172a);
}
.dialog-terminal-window {
height: 62vh;
min-height: 360px;
max-height: 68vh;
}
.terminal-lines {
margin: 0;
color: #e2e8f0;
font-family: "JetBrains Mono", "SFMono-Regular", "Cascadia Code", "Menlo", monospace;
font-size: 12.5px;
line-height: 1.9;
white-space: pre-wrap;
word-break: break-word;
}
.logs-pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
@media (max-width: 1100px) {
.audit-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.audit-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.audit-metrics {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionIpLocations.vue"), "utf8");
const readMapSource = () => readFileSync(resolve(__dirname, "./chinaProvinceMap.ts"), "utf8");
describe("PermissionIpLocations", () => {
it("renders IP location visualization without duplicate region detail table", () => {
const source = readSource();
expect(source).toContain("fetchIpLocations");
expect(source).toContain("unique_ip_count");
expect(source).not.toContain("来源地域明细");
expect(source).not.toContain("table-card");
expect(source).not.toContain("<el-table :data=\"items\"");
});
it("renders an interactive China map and primary metric cards", () => {
const source = readSource();
expect(source).toContain("VChart");
expect(source).toContain("chinaMapOption");
expect(source).toContain("用户分布图");
expect(source).toContain("访问次数");
expect(source).toContain("访问用户数");
expect(source).toContain("来源 IP 数");
expect(source).toContain("summary");
});
it("uses the standard geojson.cn China map data instead of generated rectangles", () => {
const source = readSource();
const mapSource = readMapSource();
expect(source).toContain("../assets/china.json");
expect(mapSource).toContain("geojson.cn/api/china/1.6.3/china.json");
expect(mapSource).not.toContain("ProvinceBox");
expect(mapSource).not.toContain("provinceBoxes");
});
it("hides the map legend and disables map zoom interactions", () => {
const source = readSource();
expect(source).not.toContain("visualMap:");
expect(source).toContain("roam: false");
expect(source).not.toContain("scaleLimit");
});
it("uses narrow metric cards without helper subtitles", () => {
const source = readSource();
expect(source).toContain("min-height: 64px");
expect(source).toContain("padding: 10px 16px");
expect(source).toContain("right: -46px");
expect(source).toContain("bottom: -52px");
expect(source).toContain("border-radius: 18px");
expect(source).not.toContain("<small>{{ card.hint }}</small>");
expect(source).not.toContain("hint:");
expect(source).not.toContain("权限检查总量");
});
it("removes the duplicated hero copy above the map", () => {
const source = readSource();
expect(source).toContain("ip-locations-toolbar");
expect(source).not.toContain("权限监控 / IP属地");
expect(source).not.toContain("按访问日志聚合省市、用户与来源 IP");
expect(source).not.toContain("悬停省份查看访问次数、访问用户数和来源 IP 数");
expect(source).not.toContain("hero-desc");
expect(source).not.toContain("eyebrow");
});
it("places the period filter toolbar on the left", () => {
const source = readSource();
expect(source).toContain(".ip-hero");
expect(source).toContain("justify-content: flex-start");
expect(source).not.toContain("justify-content: flex-end");
});
});
@@ -0,0 +1,546 @@
<template>
<div class="ip-locations">
<section class="ip-hero">
<div class="ip-locations-toolbar">
<div class="toolbar-left">
<span class="toolbar-title">IP 属地分析</span>
<span class="toolbar-desc">查看访问来源的地理分布</span>
</div>
<div class="toolbar-actions">
<el-radio-group v-model="days" size="small" @change="loadData">
<el-radio-button :value="1">24小时</el-radio-button>
<el-radio-button :value="7">7</el-radio-button>
<el-radio-button :value="30">30</el-radio-button>
<el-radio-button :value="90">90</el-radio-button>
</el-radio-group>
<el-button :loading="loading" size="small" @click="loadData">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 4px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
刷新
</el-button>
</div>
</div>
</section>
<section class="metric-grid">
<article v-for="card in metricCards" :key="card.label" class="metric-card" :class="card.tone">
<div class="metric-icon">
<component :is="card.icon" />
</div>
<div class="metric-body">
<span class="metric-label">{{ card.label }}</span>
<strong class="metric-value">{{ card.value }}</strong>
</div>
</article>
</section>
<section v-loading="loading" class="distribution-card">
<div class="map-panel">
<div class="section-head">
<div class="section-title-group">
<h4>中国地图</h4>
<p>访问来源地理分布热力图</p>
</div>
<el-tag type="info" effect="plain" round size="small">{{ currentPeriodLabel }}</el-tag>
</div>
<v-chart :option="chinaMapOption" autoresize class="china-map" />
</div>
<aside class="rank-panel">
<div class="section-head compact">
<div class="section-title-group">
<h4>热点属地</h4>
<p>按访问次数排序</p>
</div>
</div>
<div v-if="topRegions.length" class="region-list">
<div v-for="region in topRegions" :key="region.key" class="region-row">
<span class="rank" :class="{ top: Number(region.rank) <= 3 }">{{ region.rank }}</span>
<span class="region-name">{{ region.name }}</span>
<span class="region-value">{{ region.total_count }}</span>
</div>
</div>
<el-empty v-else description="暂无属地数据" :image-size="72" />
</aside>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from "vue";
import VChart from "vue-echarts";
import { registerMap, use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { MapChart } from "echarts/charts";
import { TooltipComponent } from "echarts/components";
import chinaMapGeoJson from "../assets/china.json";
import { fetchIpLocations } from "../api/projectPermissions";
import type { IpLocationsResponse, IpLocationStatItem } from "../types/api";
import { normalizeProvinceName } from "./chinaProvinceMap";
use([CanvasRenderer, MapChart, TooltipComponent]);
registerMap("ctms-china", chinaMapGeoJson as any);
const IconGlobe = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("circle", { cx: "12", cy: "12", r: "10" }),
h("line", { x1: "2", y1: "12", x2: "22", y2: "12" }),
h("path", { d: "M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" }),
]);
const IconUsers = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" }),
h("circle", { cx: "9", cy: "7", r: "4" }),
h("path", { d: "M23 21v-2a4 4 0 0 0-3-3.87" }),
h("path", { d: "M16 3.13a4 4 0 0 1 0 7.75" }),
]);
const IconWifi = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M5 12.55a11 11 0 0 1 14.08 0" }),
h("path", { d: "M1.42 9a16 16 0 0 1 21.16 0" }),
h("path", { d: "M8.53 16.11a6 6 0 0 1 6.95 0" }),
h("line", { x1: "12", y1: "20", x2: "12.01", y2: "20" }),
]);
const IconShield = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("path", { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" }),
]);
const loading = ref(false);
const days = ref(7);
const items = ref<IpLocationStatItem[]>([]);
const summary = ref<IpLocationsResponse["summary"] | null>(null);
const periodLabels: Record<number, string> = {
1: "近 24 小时",
7: "近 7 天",
30: "近 30 天",
90: "近 90 天",
};
const currentPeriodLabel = computed(() => periodLabels[days.value] || `${days.value}`);
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
const formatLocation = (row: IpLocationStatItem) => {
const parts = [row.province, row.city].filter(Boolean);
return parts.length ? parts.join(" / ") : row.location;
};
const fallbackSummary = computed<IpLocationsResponse["summary"]>(() => ({
total_count: items.value.reduce((sum, row) => sum + row.total_count, 0),
allowed_count: items.value.reduce((sum, row) => sum + row.allowed_count, 0),
denied_count: items.value.reduce((sum, row) => sum + row.denied_count, 0),
unique_ip_count: items.value.reduce((sum, row) => sum + row.unique_ip_count, 0),
unique_user_count: items.value.reduce((sum, row) => sum + (row.unique_user_count || 0), 0),
}));
const activeSummary = computed(() => summary.value || fallbackSummary.value);
const deniedRate = computed(() => {
const total = activeSummary.value.total_count;
if (!total) return 0;
return Math.round((activeSummary.value.denied_count / total) * 1000) / 10;
});
const metricCards = computed(() => [
{
label: "访问次数",
value: formatNumber(activeSummary.value.total_count),
tone: "blue",
icon: IconGlobe,
},
{
label: "访问用户数",
value: formatNumber(activeSummary.value.unique_user_count),
tone: "cyan",
icon: IconUsers,
},
{
label: "来源 IP 数",
value: formatNumber(activeSummary.value.unique_ip_count),
tone: "indigo",
icon: IconWifi,
},
{
label: "拒绝占比",
value: `${deniedRate.value}%`,
tone: deniedRate.value > 10 ? "danger" : "violet",
icon: IconShield,
},
]);
const provinceData = computed(() => {
const provinceMap = new Map<string, IpLocationStatItem & { name: string }>();
items.value.forEach((item) => {
const name = normalizeProvinceName(item.province || item.city);
if (!name || item.country !== "中国") return;
const current = provinceMap.get(name);
if (!current) {
provinceMap.set(name, { ...item, name });
return;
}
current.total_count += item.total_count;
current.allowed_count += item.allowed_count;
current.denied_count += item.denied_count;
current.unique_ip_count += item.unique_ip_count;
current.unique_user_count += item.unique_user_count || 0;
});
return Array.from(provinceMap.values());
});
const chinaMapOption = computed(() => ({
tooltip: {
trigger: "item",
formatter: (params: any) => {
const data = params.data;
if (!data) return `${params.name}<br/>暂无访问数据`;
return [
`${params.name}`,
`访问次数:${formatNumber(data.value)}`,
`访问用户数:${formatNumber(data.unique_user_count || 0)}`,
`来源 IP 数:${formatNumber(data.unique_ip_count || 0)}`,
`拒绝次数:${formatNumber(data.denied_count || 0)}`,
].join("<br/>");
},
},
series: [
{
name: "用户分布图",
type: "map",
map: "ctms-china",
roam: false,
zoom: 1.08,
label: { show: false },
itemStyle: {
areaColor: "#edf3ff",
borderColor: "#ffffff",
borderWidth: 1,
},
emphasis: {
label: { show: true, color: "#0f172a", fontSize: 12, fontWeight: 700 },
itemStyle: { areaColor: "#ffb86b", shadowBlur: 12, shadowColor: "rgba(245, 158, 11, 0.28)" },
},
data: provinceData.value.map((item) => ({
name: item.name,
value: item.total_count,
unique_user_count: item.unique_user_count,
unique_ip_count: item.unique_ip_count,
denied_count: item.denied_count,
})),
},
],
}));
const topRegions = computed(() =>
[...items.value]
.sort((a, b) => b.total_count - a.total_count)
.slice(0, 6)
.map((item, index) => ({
key: `${item.country}-${item.province}-${item.city}-${item.isp}`,
rank: String(index + 1).padStart(2, "0"),
name: formatLocation(item),
total_count: formatNumber(item.total_count),
})),
);
const loadData = async () => {
loading.value = true;
try {
const res = await fetchIpLocations({ days: days.value, limit: 50 });
items.value = res.data.items;
summary.value = res.data.summary || null;
} catch {
items.value = [];
summary.value = null;
} finally {
loading.value = false;
}
};
onMounted(loadData);
defineExpose({ refresh: loadData });
</script>
<style scoped>
.ip-locations {
--ip-ink: #1a2332;
--ip-muted: #64748b;
--ip-border: #e2e8f0;
--ip-card: #ffffff;
--ip-blue: #3b82f6;
--ip-cyan: #06b6d4;
--ip-violet: #8b5cf6;
--ip-danger: #ef4444;
--ip-indigo: #6366f1;
--ip-radius: 16px;
--ip-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
gap: 14px;
}
.ip-hero {
background: var(--ip-card);
border: 1px solid var(--ip-border);
border-radius: var(--ip-radius);
box-shadow: var(--ip-shadow);
padding: 14px 18px;
}
.ip-locations-toolbar {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-left {
display: flex;
flex-direction: column;
gap: 2px;
}
.toolbar-title {
font-size: 15px;
font-weight: 600;
color: var(--ip-ink);
}
.toolbar-desc {
font-size: 12px;
color: var(--ip-muted);
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.metric-card {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 14px;
min-height: 64px;
padding: 10px 16px;
border-radius: var(--ip-radius);
background: #fff;
border: 1px solid var(--ip-border);
box-shadow: var(--ip-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.metric-card::after {
content: "";
position: absolute;
right: -46px;
bottom: -52px;
width: 120px;
height: 120px;
border-radius: 18px;
background: rgba(59, 130, 246, 0.06);
transform: rotate(18deg);
pointer-events: none;
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.metric-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: var(--ip-radius) var(--ip-radius) 0 0;
}
.metric-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
.metric-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
.metric-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
.metric-card.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
.metric-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
.metric-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
flex-shrink: 0;
}
.metric-icon svg {
width: 22px;
height: 22px;
}
.metric-card.blue .metric-icon { background: #eff6ff; color: #3b82f6; }
.metric-card.cyan .metric-icon { background: #ecfeff; color: #06b6d4; }
.metric-card.indigo .metric-icon { background: #eef2ff; color: #6366f1; }
.metric-card.violet .metric-icon { background: #f5f3ff; color: #8b5cf6; }
.metric-card.danger .metric-icon { background: #fef2f2; color: #ef4444; }
.metric-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.metric-label {
color: var(--ip-muted);
font-size: 13px;
font-weight: 500;
}
.metric-value {
color: var(--ip-ink);
font-size: 24px;
font-weight: 700;
line-height: 1.1;
}
.distribution-card {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 14px;
background: var(--ip-card);
border: 1px solid var(--ip-border);
border-radius: var(--ip-radius);
box-shadow: var(--ip-shadow);
padding: 18px;
}
.map-panel,
.rank-panel {
min-width: 0;
border: 1px solid #f1f5f9;
border-radius: 14px;
background: linear-gradient(180deg, #fafcff 0%, #ffffff 100%);
padding: 16px;
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-head.compact {
margin-bottom: 16px;
}
.section-title-group h4 {
margin: 0;
color: var(--ip-ink);
font-size: 16px;
font-weight: 600;
}
.section-title-group p {
margin: 4px 0 0;
color: var(--ip-muted);
font-size: 12px;
}
.china-map {
width: 100%;
height: 430px;
}
.region-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.region-row {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
width: 100%;
padding: 12px;
border-radius: 10px;
background: #fff;
border: 1px solid #f1f5f9;
color: var(--ip-ink);
transition: all 0.15s ease;
}
.region-row:hover {
background: #f8fafc;
border-color: var(--ip-border);
}
.rank {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #f1f5f9;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.rank.top {
background: linear-gradient(135deg, #3b82f6, #6366f1);
color: #fff;
}
.region-name {
overflow: hidden;
font-size: 13px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.region-value {
color: var(--ip-blue);
font-size: 15px;
font-weight: 700;
}
@media (max-width: 1100px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.distribution-card {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.ip-locations-toolbar {
flex-direction: column;
align-items: flex-start;
}
.metric-grid {
grid-template-columns: 1fr;
}
.china-map {
height: 320px;
}
}
</style>
@@ -1,82 +1,51 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { mount } from "@vue/test-utils";
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
import type { PermissionMetricsResponse, HealthResponse, AlertsResponse } from "@/types/api";
vi.mock("@/api/projectPermissions", () => ({
fetchPermissionMetrics: vi.fn().mockResolvedValue({
data: {
check_metrics: {
total_checks: 1000, allowed_checks: 950, denied_checks: 50,
total_time: 5.234, min_time: 0.001, max_time: 0.05, avg_time: 0.005,
allow_rate: 95.0, deny_rate: 5.0, error_rate: 0.1, errors: 1,
},
cache_metrics: {
total_accesses: 1000, cache_hits: 850, cache_misses: 150,
cache_invalidations: 5, hit_rate: 85.0, miss_rate: 15.0,
},
uptime_seconds: 3600,
},
}),
fetchPermissionHealth: vi.fn().mockResolvedValue({
data: {
status: "healthy", health_score: 95, issues: [],
metrics: {}, cache_stats: { cache_items: { total_count: 60 }, cache_metrics: {} },
},
}),
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
fetchTopDenied: vi.fn().mockResolvedValue({ data: { items: [] } }),
fetchStatsSummary: vi.fn().mockResolvedValue({
data: {
total_logs: 5000,
today: { total_checks: 200, allowed_checks: 190, denied_checks: 10, avg_elapsed_ms: 3.5, max_elapsed_ms: 45, allow_rate: 95, deny_rate: 5 },
last_hour: { total_checks: 30, allowed_checks: 28, denied_checks: 2, requests_per_minute: 0.5 },
},
}),
fetchPermissionTrends: vi.fn().mockResolvedValue({ data: { period: "24h", data_points: [] } }),
fetchAccessLogs: vi.fn().mockResolvedValue({ data: { total: 0, page: 1, page_size: 50, items: [] } }),
}));
describe("PermissionMonitoring.vue", () => {
const mockMetrics: PermissionMetricsResponse = {
check_metrics: {
total_checks: 1000,
allowed_checks: 950,
denied_checks: 50,
total_time: 5.234,
min_time: 0.001,
max_time: 0.05,
avg_time: 0.005,
allow_rate: 95.0,
deny_rate: 5.0,
error_rate: 0.1,
errors: 1,
},
cache_metrics: {
total_accesses: 1000,
cache_hits: 850,
cache_misses: 150,
cache_invalidations: 5,
hit_rate: 85.0,
miss_rate: 15.0,
},
uptime_seconds: 3600,
};
const mockHealth: HealthResponse = {
status: "healthy",
health_score: 95,
issues: [],
metrics: mockMetrics,
cache_stats: {
cache_items: {
project_permissions_count: 10,
member_role_count: 50,
total_count: 60,
},
cache_metrics: mockMetrics.cache_metrics,
},
};
const mockAlerts: AlertsResponse = {
total: 2,
alerts: [
{
timestamp: 1715692800.123,
level: "warning",
type: "slow_permission_check",
message: "权限检查耗时过长: 52.34ms",
data: { elapsed_time: 0.05234 },
},
{
timestamp: 1715692799.456,
level: "error",
type: "permission_check_error",
message: "权限检查出错: database connection timeout",
data: { error: "database connection timeout" },
},
],
};
it("renders monitoring dashboard", () => {
it("renders monitoring dashboard with tabs", () => {
const wrapper = mount(PermissionMonitoring, {
props: {
metrics: mockMetrics,
health: mockHealth,
alerts: mockAlerts,
},
global: {
stubs: {
ElTag: false,
ElProgress: false,
ElTable: false,
ElEmpty: false,
ElTabs: false,
ElTabPane: false,
PermissionTrendCharts: true,
PermissionAccessLogs: true,
PermissionIpLocations: true,
},
},
});
@@ -84,115 +53,19 @@ describe("PermissionMonitoring.vue", () => {
expect(wrapper.find(".permission-monitoring").exists()).toBe(true);
});
it("displays health status", () => {
it("exposes refresh method", () => {
const wrapper = mount(PermissionMonitoring, {
props: {
metrics: mockMetrics,
health: mockHealth,
alerts: mockAlerts,
},
global: {
stubs: {
ElTag: false,
ElProgress: false,
ElTable: false,
ElEmpty: false,
ElTabs: true,
ElTabPane: true,
PermissionTrendCharts: true,
PermissionAccessLogs: true,
PermissionIpLocations: true,
},
},
});
const healthCard = wrapper.find(".health-card");
expect(healthCard.exists()).toBe(true);
expect(healthCard.text()).toContain("系统健康状态");
});
it("displays metric cards", () => {
const wrapper = mount(PermissionMonitoring, {
props: {
metrics: mockMetrics,
health: mockHealth,
alerts: mockAlerts,
},
global: {
stubs: {
ElTag: false,
ElProgress: false,
ElTable: false,
ElEmpty: false,
},
},
});
const metricCards = wrapper.findAll(".metric-card");
expect(metricCards.length).toBeGreaterThan(0);
});
it("displays cache efficiency", () => {
const wrapper = mount(PermissionMonitoring, {
props: {
metrics: mockMetrics,
health: mockHealth,
alerts: mockAlerts,
},
global: {
stubs: {
ElTag: false,
ElProgress: false,
ElTable: false,
ElEmpty: false,
},
},
});
const cacheCard = wrapper.find(".cache-card");
expect(cacheCard.exists()).toBe(true);
expect(cacheCard.text()).toContain("缓存效率");
});
it("displays alerts list", () => {
const wrapper = mount(PermissionMonitoring, {
props: {
metrics: mockMetrics,
health: mockHealth,
alerts: mockAlerts,
},
global: {
stubs: {
ElTag: false,
ElProgress: false,
ElTable: false,
ElEmpty: false,
},
},
});
const alertsCard = wrapper.find(".alerts-card");
expect(alertsCard.exists()).toBe(true);
expect(alertsCard.text()).toContain("最近告警");
});
it("emits refresh event when refresh button clicked", async () => {
const wrapper = mount(PermissionMonitoring, {
props: {
metrics: mockMetrics,
health: mockHealth,
alerts: mockAlerts,
},
global: {
stubs: {
ElTag: false,
ElProgress: false,
ElTable: false,
ElEmpty: false,
ElButton: false,
},
},
});
const refreshButton = wrapper.find("button");
if (refreshButton.exists()) {
await refreshButton.trigger("click");
expect(wrapper.emitted("refresh")).toBeTruthy();
}
expect(typeof wrapper.vm.refresh).toBe("function");
});
});
+617 -259
View File
@@ -1,346 +1,704 @@
<template>
<div class="permission-monitoring">
<!-- 系统健康评分 -->
<div v-if="health" class="health-card">
<div class="health-header">
<h3>系统健康状态</h3>
<el-tag :type="getHealthType(health.status)">
{{ getHealthLabel(health.status) }}
</el-tag>
</div>
<div class="health-score">
<div class="score-value">{{ health.health_score }}</div>
<div class="score-label">健康分数</div>
</div>
<div v-if="health.issues.length > 0" class="health-issues">
<div class="issues-title">发现的问题</div>
<ul>
<li v-for="(issue, index) in health.issues" :key="index">{{ issue }}</li>
</ul>
</div>
</div>
<!-- 性能指标卡片 -->
<div v-if="metrics" class="metrics-cards">
<div class="metric-card">
<div class="metric-label">总检查次数</div>
<div class="metric-value">{{ metrics.check_metrics.total_checks }}</div>
</div>
<div class="metric-card">
<div class="metric-label">平均响应时间</div>
<div class="metric-value">{{ (metrics.check_metrics.avg_time * 1000).toFixed(2) }}ms</div>
</div>
<div class="metric-card">
<div class="metric-label">允许率</div>
<div class="metric-value">{{ metrics.check_metrics.allow_rate.toFixed(1) }}%</div>
</div>
<div class="metric-card">
<div class="metric-label">错误率</div>
<div class="metric-value" :style="{ color: metrics.check_metrics.error_rate > 1 ? '#f56c6c' : '#67c23a' }">
{{ metrics.check_metrics.error_rate.toFixed(2) }}%
</div>
</div>
</div>
<!-- 缓存效率 -->
<div v-if="metrics" class="cache-card">
<div class="cache-header">
<h3>缓存效率</h3>
</div>
<div class="cache-stats">
<div class="cache-stat">
<div class="stat-label">缓存命中率</div>
<el-progress
:percentage="metrics.cache_metrics.hit_rate"
:color="getProgressColor(metrics.cache_metrics.hit_rate)"
/>
<div class="stat-value">{{ metrics.cache_metrics.hit_rate.toFixed(1) }}%</div>
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane label="实时概览" name="overview">
<div v-if="statsSummary" class="stats-summary">
<div v-for="card in summaryCards" :key="card.label" class="summary-card" :class="card.tone">
<div class="summary-icon">
<component :is="card.icon" />
</div>
<div class="summary-body">
<span class="summary-label">{{ card.label }}</span>
<strong class="summary-value">{{ card.value }}</strong>
</div>
</div>
</div>
<div class="cache-stat">
<div class="stat-label">缓存项目数</div>
<div class="stat-value">{{ health?.cache_stats?.cache_items.total_count || 0 }}</div>
<div class="overview-grid">
<div v-if="health" class="health-card">
<div class="health-header">
<h3>系统健康状态</h3>
<el-tag :type="getHealthType(health.status)" effect="dark" round>
{{ getHealthLabel(health.status) }}
</el-tag>
</div>
<div class="health-score-ring">
<el-progress
type="dashboard"
:percentage="health.health_score"
:color="getScoreColor(health.health_score)"
:width="140"
:stroke-width="10"
>
<template #default>
<div class="score-inner">
<strong>{{ health.health_score }}</strong>
<span>健康分</span>
</div>
</template>
</el-progress>
</div>
<div v-if="health.issues.length > 0" class="health-issues">
<div v-for="(issue, index) in health.issues" :key="index" class="issue-item">
<span class="issue-dot" />
<span>{{ issue }}</span>
</div>
</div>
<div v-else class="health-ok">
<span class="ok-icon">&#10003;</span>
<span>系统运行正常未发现问题</span>
</div>
</div>
<div v-if="metrics" class="cache-card">
<h3>缓存效率</h3>
<div class="cache-stats">
<div class="cache-stat primary">
<div class="cache-stat-header">
<span class="stat-label">命中率</span>
<strong class="stat-value" :style="{ color: getProgressColor(metrics.cache_metrics.hit_rate) }">
{{ metrics.cache_metrics.hit_rate.toFixed(1) }}%
</strong>
</div>
<el-progress
:percentage="metrics.cache_metrics.hit_rate"
:color="getProgressColor(metrics.cache_metrics.hit_rate)"
:stroke-width="8"
:show-text="false"
/>
</div>
<div class="cache-stat-row">
<div class="cache-stat-mini">
<span class="stat-label">缓存项目</span>
<strong>{{ health?.cache_stats?.cache_items.total_count || 0 }}</strong>
</div>
<div class="cache-stat-mini">
<span class="stat-label">失效次数</span>
<strong>{{ metrics.cache_metrics.cache_invalidations }}</strong>
</div>
</div>
</div>
</div>
</div>
<div class="cache-stat">
<div class="stat-label">缓存失效次数</div>
<div class="stat-value">{{ metrics.cache_metrics.cache_invalidations }}</div>
<div v-if="topDenied && topDenied.length > 0" class="denied-card">
<div class="card-header">
<h3>被拒绝最多的权限</h3>
<el-tag effect="plain" size="small" type="info"> 7 </el-tag>
</div>
<div class="denied-list">
<div v-for="(item, index) in topDenied" :key="index" class="denied-row">
<span class="denied-rank" :class="{ top: index < 3 }">{{ index + 1 }}</span>
<div class="denied-info">
<strong>{{ item.endpoint_key }}</strong>
<small>{{ item.role }}</small>
</div>
<span class="denied-count">{{ item.denied_count }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 告警列表 -->
<div class="alerts-card">
<div class="alerts-header">
<h3>最近告警</h3>
<el-button type="primary" size="small" @click="$emit('refresh')">
<el-icon><RefreshRight /></el-icon>
刷新
</el-button>
</div>
<div class="alerts-card">
<div class="card-header">
<h3>最近告警</h3>
<el-tag v-if="alerts && alerts.alerts.length > 0" effect="dark" size="small" type="warning" round>
{{ alerts.alerts.length }}
</el-tag>
</div>
<div v-if="alerts && alerts.alerts.length > 0" class="alerts-list">
<div v-for="(alert, index) in alerts.alerts" :key="index" class="alert-row" :class="alert.level">
<div class="alert-level">
<el-tag :type="getAlertType(alert.level)" size="small" effect="dark" round>{{ alert.level }}</el-tag>
</div>
<div class="alert-body">
<strong>{{ alert.type }}</strong>
<span>{{ alert.message }}</span>
</div>
<span class="alert-time">{{ formatTime(alert.timestamp) }}</span>
</div>
</div>
<el-empty v-else description="暂无告警,系统运行正常" :image-size="60" />
</div>
</el-tab-pane>
<div v-if="alerts && alerts.alerts.length > 0" class="alerts-table-wrapper">
<el-table :data="alerts.alerts" stripe border>
<el-table-column prop="timestamp" label="时间" width="180">
<template #default="{ row }">
{{ formatTime(row.timestamp) }}
</template>
</el-table-column>
<el-tab-pane label="趋势分析" name="trends">
<PermissionTrendCharts ref="trendChartsRef" />
</el-tab-pane>
<el-table-column prop="level" label="级别" width="80">
<template #default="{ row }">
<el-tag :type="getAlertType(row.level)">{{ row.level }}</el-tag>
</template>
</el-table-column>
<el-tab-pane label="访问日志" name="logs">
<PermissionAccessLogs ref="accessLogsRef" />
</el-tab-pane>
<el-table-column prop="type" label="类型" width="150" />
<el-table-column prop="message" label="消息" show-overflow-tooltip />
</el-table>
</div>
<div v-else class="empty-state">
<el-empty description="暂无告警" />
</div>
</div>
<el-tab-pane label="IP属地" name="ip-locations">
<PermissionIpLocations ref="ipLocationsRef" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { RefreshRight } from "@element-plus/icons-vue";
import { ref, computed, h, onMounted } from "vue";
import type {
PermissionMetricsResponse,
CacheStatsResponse,
AlertsResponse,
HealthResponse,
TopDeniedItem,
StatsSummaryResponse,
} from "@/types/api";
import {
fetchPermissionMetrics,
fetchPermissionAlerts,
fetchPermissionHealth,
fetchTopDenied,
fetchStatsSummary,
} from "@/api/projectPermissions";
import PermissionTrendCharts from "./PermissionTrendCharts.vue";
import PermissionAccessLogs from "./PermissionAccessLogs.vue";
import PermissionIpLocations from "./PermissionIpLocations.vue";
interface Props {
metrics: PermissionMetricsResponse | null;
health: HealthResponse | null;
alerts: AlertsResponse | null;
}
const IconCheck = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("polyline", { points: "20 6 9 17 4 12" }),
]);
const IconActivity = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("polyline", { points: "22 12 18 12 15 21 9 3 6 12 2 12" }),
]);
const IconPercent = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("line", { x1: "19", y1: "5", x2: "5", y2: "19" }),
h("circle", { cx: "6.5", cy: "6.5", r: "2.5" }),
h("circle", { cx: "17.5", cy: "17.5", r: "2.5" }),
]);
const IconClock = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("circle", { cx: "12", cy: "12", r: "10" }),
h("polyline", { points: "12 6 12 12 16 14" }),
]);
const IconDatabase = () => h("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2" }, [
h("ellipse", { cx: "12", cy: "5", rx: "9", ry: "3" }),
h("path", { d: "M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" }),
h("path", { d: "M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" }),
]);
interface Emits {
(e: "refresh"): void;
}
const activeTab = ref("overview");
const metrics = ref<PermissionMetricsResponse | null>(null);
const health = ref<HealthResponse | null>(null);
const alerts = ref<AlertsResponse | null>(null);
const topDenied = ref<TopDeniedItem[]>([]);
const statsSummary = ref<StatsSummaryResponse | null>(null);
defineProps<Props>();
defineEmits<Emits>();
const trendChartsRef = ref<InstanceType<typeof PermissionTrendCharts>>();
const accessLogsRef = ref<InstanceType<typeof PermissionAccessLogs>>();
const ipLocationsRef = ref<InstanceType<typeof PermissionIpLocations>>();
const formatTime = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleString("zh-CN");
};
const getHealthType = (status: string): string => {
const typeMap: Record<string, string> = {
healthy: "success",
degraded: "warning",
unhealthy: "danger",
};
return typeMap[status] || "info";
const getHealthType = (status: string) => {
const map: Record<string, string> = { healthy: "success", degraded: "warning", unhealthy: "danger" };
return map[status] || "info";
};
const getHealthLabel = (status: string): string => {
const labelMap: Record<string, string> = {
healthy: "健康",
degraded: "降级",
unhealthy: "不健康",
};
return labelMap[status] || status;
const getHealthLabel = (status: string) => {
const map: Record<string, string> = { healthy: "健康", degraded: "降级", unhealthy: "不健康" };
return map[status] || status;
};
const getAlertType = (level: string): string => {
const typeMap: Record<string, string> = {
info: "info",
warning: "warning",
error: "danger",
};
return typeMap[level] || "info";
const getAlertType = (level: string) => {
const map: Record<string, string> = { info: "info", warning: "warning", error: "danger" };
return map[level] || "info";
};
const getProgressColor = (percentage: number): string => {
if (percentage >= 80) return "#67c23a";
if (percentage >= 50) return "#e6a23c";
return "#f56c6c";
if (percentage >= 80) return "#10b981";
if (percentage >= 50) return "#f59e0b";
return "#ef4444";
};
const getScoreColor = (score: number): string => {
if (score >= 80) return "#10b981";
if (score >= 60) return "#f59e0b";
return "#ef4444";
};
const summaryCards = computed(() => {
if (!statsSummary.value) return [];
return [
{
label: "今日总检查",
value: statsSummary.value.today.total_checks.toLocaleString(),
tone: "blue",
icon: IconCheck,
},
{
label: "每分钟请求",
value: statsSummary.value.last_hour.requests_per_minute,
tone: "cyan",
icon: IconActivity,
},
{
label: "今日允许率",
value: `${statsSummary.value.today.allow_rate}%`,
tone: statsSummary.value.today.allow_rate >= 80 ? "green" : "warning",
icon: IconPercent,
},
{
label: "平均响应时间",
value: `${statsSummary.value.today.avg_elapsed_ms}ms`,
tone: statsSummary.value.today.avg_elapsed_ms > 10 ? "danger" : "indigo",
icon: IconClock,
},
{
label: "历史总记录",
value: statsSummary.value.total_logs.toLocaleString(),
tone: "violet",
icon: IconDatabase,
},
];
});
const loadOverviewData = async () => {
const [metricsRes, healthRes, alertsRes, deniedRes, summaryRes] = await Promise.allSettled([
fetchPermissionMetrics(),
fetchPermissionHealth(),
fetchPermissionAlerts(undefined, 20),
fetchTopDenied({ days: 7, limit: 10 }),
fetchStatsSummary(),
]);
if (metricsRes.status === "fulfilled") metrics.value = metricsRes.value.data;
if (healthRes.status === "fulfilled") health.value = healthRes.value.data;
if (alertsRes.status === "fulfilled") alerts.value = alertsRes.value.data;
if (deniedRes.status === "fulfilled") topDenied.value = deniedRes.value.data.items;
if (summaryRes.status === "fulfilled") statsSummary.value = summaryRes.value.data;
};
const onTabChange = (tab: string) => {
if (tab === "overview") loadOverviewData();
};
const refresh = () => {
if (activeTab.value === "overview") loadOverviewData();
else if (activeTab.value === "trends") trendChartsRef.value?.refresh();
else if (activeTab.value === "logs") accessLogsRef.value?.refresh();
else if (activeTab.value === "ip-locations") ipLocationsRef.value?.refresh();
};
onMounted(loadOverviewData);
defineExpose({ refresh });
</script>
<style scoped lang="scss">
<style scoped>
.permission-monitoring {
padding: 20px 0;
display: flex;
flex-direction: column;
gap: 20px;
--mon-ink: #1a2332;
--mon-muted: #64748b;
--mon-border: #e2e8f0;
--mon-radius: 16px;
--mon-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
padding: 0;
}
.health-card {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 4px;
.stats-summary {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.summary-card {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 12px;
padding: 16px 18px;
border-radius: var(--mon-radius);
background: #fff;
border: 1px solid var(--mon-border);
box-shadow: var(--mon-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.summary-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.summary-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: var(--mon-radius) var(--mon-radius) 0 0;
}
.summary-card.blue::before { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
.summary-card.cyan::before { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
.summary-card.green::before { background: linear-gradient(90deg, #10b981, #34d399); }
.summary-card.warning::before { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.summary-card.indigo::before { background: linear-gradient(90deg, #6366f1, #818cf8); }
.summary-card.danger::before { background: linear-gradient(90deg, #ef4444, #f87171); }
.summary-card.violet::before { background: linear-gradient(90deg, #8b5cf6, #a78bfa); }
.summary-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
flex-shrink: 0;
}
.summary-icon svg {
width: 20px;
height: 20px;
}
.summary-card.blue .summary-icon { background: #eff6ff; color: #3b82f6; }
.summary-card.cyan .summary-icon { background: #ecfeff; color: #06b6d4; }
.summary-card.green .summary-icon { background: #ecfdf5; color: #10b981; }
.summary-card.warning .summary-icon { background: #fffbeb; color: #f59e0b; }
.summary-card.indigo .summary-icon { background: #eef2ff; color: #6366f1; }
.summary-card.danger .summary-icon { background: #fef2f2; color: #ef4444; }
.summary-card.violet .summary-icon { background: #f5f3ff; color: #8b5cf6; }
.summary-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.summary-label {
color: var(--mon-muted);
font-size: 12px;
font-weight: 500;
}
.summary-value {
color: var(--mon-ink);
font-size: 22px;
font-weight: 700;
line-height: 1.1;
}
.overview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 16px;
}
.health-card,
.cache-card,
.denied-card,
.alerts-card {
background: #fff;
border: 1px solid var(--mon-border);
border-radius: var(--mon-radius);
padding: 20px;
box-shadow: var(--mon-shadow);
}
.denied-card,
.alerts-card {
margin-bottom: 16px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--mon-ink);
}
.health-card h3,
.cache-card h3 {
margin: 0 0 16px;
font-size: 15px;
font-weight: 600;
color: var(--mon-ink);
}
.health-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
margin-bottom: 16px;
}
.health-score {
text-align: center;
margin: 20px 0;
.health-header h3 {
margin: 0;
}
.score-value {
font-size: 48px;
font-weight: bold;
color: #409eff;
.health-score-ring {
display: flex;
justify-content: center;
padding: 8px 0 16px;
}
.score-label {
font-size: 14px;
color: #909399;
margin-top: 10px;
.score-inner {
display: flex;
flex-direction: column;
align-items: center;
}
.score-inner strong {
font-size: 32px;
font-weight: 700;
color: var(--mon-ink);
line-height: 1;
}
.score-inner span {
font-size: 12px;
color: var(--mon-muted);
margin-top: 4px;
}
.health-issues {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #dcdfe6;
padding-top: 12px;
border-top: 1px solid var(--mon-border);
display: flex;
flex-direction: column;
gap: 8px;
}
.issues-title {
font-size: 14px;
font-weight: 600;
color: #606266;
margin-bottom: 10px;
}
.health-issues ul {
margin: 0;
padding-left: 20px;
li {
color: #f56c6c;
font-size: 13px;
margin: 5px 0;
}
}
.metrics-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.metric-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 20px;
text-align: center;
}
.metric-label {
.issue-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #909399;
margin-bottom: 10px;
color: #dc2626;
}
.metric-value {
font-size: 24px;
font-weight: bold;
color: #303133;
.issue-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ef4444;
flex-shrink: 0;
}
.cache-card {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 20px;
.health-ok {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border-radius: 10px;
background: #ecfdf5;
color: #059669;
font-size: 13px;
font-weight: 500;
}
.cache-header {
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.ok-icon {
font-size: 16px;
font-weight: 700;
}
.cache-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.cache-stat {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 15px;
.cache-stat.primary {
padding: 14px;
background: #f8fafc;
border-radius: 12px;
}
.stat-label {
font-size: 13px;
color: #909399;
.cache-stat-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.stat-value {
font-size: 20px;
font-weight: bold;
color: #303133;
margin-top: 10px;
.cache-stat-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
:deep(.el-progress) {
margin: 10px 0;
}
.alerts-card {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 20px;
}
.alerts-header {
.cache-stat-mini {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-direction: column;
gap: 6px;
padding: 12px;
background: #f8fafc;
border-radius: 10px;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
.cache-stat-mini strong {
font-size: 20px;
font-weight: 700;
color: var(--mon-ink);
}
.stat-label {
font-size: 12px;
color: var(--mon-muted);
font-weight: 500;
}
.stat-value {
font-size: 18px;
font-weight: 700;
}
.denied-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.denied-row {
display: grid;
grid-template-columns: 32px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 10px 12px;
border-radius: 10px;
transition: background 0.15s ease;
}
.denied-row:hover {
background: #f8fafc;
}
.denied-rank {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
background: #f1f5f9;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.denied-rank.top {
background: linear-gradient(135deg, #ef4444, #f87171);
color: #fff;
}
.denied-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.denied-info strong {
font-size: 13px;
font-weight: 600;
color: var(--mon-ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.denied-info small {
font-size: 11px;
color: var(--mon-muted);
}
.denied-count {
font-size: 16px;
font-weight: 700;
color: #ef4444;
}
.alerts-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.alert-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--mon-border);
transition: background 0.15s ease;
}
.alert-row:hover {
background: #f8fafc;
}
.alert-row.error {
border-color: #fecaca;
background: #fef2f2;
}
.alert-row.warning {
border-color: #fed7aa;
background: #fffbeb;
}
.alert-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.alert-body strong {
font-size: 13px;
font-weight: 600;
color: var(--mon-ink);
}
.alert-body span {
font-size: 12px;
color: var(--mon-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alert-time {
font-size: 11px;
color: #94a3b8;
white-space: nowrap;
}
@media (max-width: 1200px) {
.stats-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.alerts-table-wrapper {
overflow-x: auto;
@media (max-width: 900px) {
.stats-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-grid {
grid-template-columns: 1fr;
}
}
.empty-state {
padding: 40px 20px;
text-align: center;
@media (max-width: 600px) {
.stats-summary {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTemplateSelector.vue"), "utf8");
describe("PermissionTemplateSelector", () => {
it("renders compact role permission cards without footer placeholder space", () => {
const source = readSource();
expect(source).toContain("角色权限概览");
expect(source).toContain("align-items: start");
expect(source).toContain("padding: 12px 14px");
expect(source).not.toContain("card-footer");
expect(source).not.toContain("点击编辑权限");
expect(source).not.toContain("inactive-label");
expect(source).not.toContain("edit-hint");
});
});
@@ -1,61 +1,62 @@
<template>
<div class="template-selector">
<div class="selector-header">
<h3>角色权限概览</h3>
<p class="selector-desc">展示当前项目各角色权限配置状态</p>
<div class="selector-title-group">
<h3>角色权限概览</h3>
<p class="selector-desc">展示当前项目各角色的权限配置状态</p>
</div>
<el-tag effect="plain" size="small" type="info" round>{{ templates.length }} 个角色</el-tag>
</div>
<div class="template-grid">
<el-tooltip
<div
v-for="template in templates"
:key="template.id"
:content="template.description"
placement="top"
:disabled="!template.description"
>
<div
class="template-card"
:class="{ inactive: template.category && !isRoleActive(template.category) }"
@click="template.category && emit('edit-role', template.category!)"
>
<div class="card-header">
<div class="card-top">
<span class="card-icon">{{ roleIcon(template.category) }}</span>
<div class="card-title-wrap">
<span class="card-name">{{ template.category ? (ROLE_LABELS[template.category] || template.name) : template.name }}</span>
<el-tag v-if="!template.is_system" size="small" type="success">自定义</el-tag>
<span v-if="template.description" class="card-desc">{{ template.description }}</span>
</div>
<el-button
v-if="template.category"
size="small"
type="primary"
link
class="edit-role-btn"
@click.stop="emit('edit-role', template.category!)"
>编辑权限</el-button>
<el-tag v-if="!template.is_system" size="small" effect="dark" round type="success" class="card-badge">自定义</el-tag>
</div>
<div class="card-stats">
<template v-if="props.currentPermissions && template.category && props.currentPermissions[template.category]">
<span class="stat">
<el-icon><Check /></el-icon>
{{ countCurrentEnabled(template.category) }} 项已启用
</span>
<span class="stat disabled">
<el-icon><Close /></el-icon>
{{ countCurrentDisabled(template.category) }} 项已禁
</span>
<div class="stat-bar">
<div class="stat-fill" :style="{ width: enabledPercent(template.category) + '%' }" />
</div>
<div class="stat-numbers">
<span class="stat enabled">
<span class="stat-dot green" />
{{ countCurrentEnabled(template.category) }}
</span>
<span class="stat disabled">
<span class="stat-dot red" />
{{ countCurrentDisabled(template.category) }} 禁用
</span>
</div>
</template>
<template v-else>
<span class="stat">
<el-icon><Check /></el-icon>
{{ countEnabled(template) }} 项已启用
</span>
<span class="stat disabled">
<el-icon><Close /></el-icon>
{{ countDisabled(template) }} 项已禁
</span>
<div class="stat-bar">
<div class="stat-fill" :style="{ width: templateEnabledPercent(template) + '%' }" />
</div>
<div class="stat-numbers">
<span class="stat enabled">
<span class="stat-dot green" />
{{ countEnabled(template) }}
</span>
<span class="stat disabled">
<span class="stat-dot red" />
{{ countDisabled(template) }} 禁用
</span>
</div>
</template>
</div>
</div>
</el-tooltip>
</div>
</div>
</template>
@@ -63,7 +64,6 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ElMessage } from "element-plus";
import { Check, Close } from "@element-plus/icons-vue";
import {
fetchPermissionTemplates,
type PermissionTemplate,
@@ -110,6 +110,12 @@ const countDisabled = (t: PermissionTemplate) => {
return n;
};
const templateEnabledPercent = (t: PermissionTemplate) => {
const enabled = countEnabled(t);
const total = enabled + countDisabled(t);
return total > 0 ? Math.round((enabled / total) * 100) : 0;
};
const countCurrentEnabled = (role: string) => {
const perms = props.currentPermissions?.[role];
if (!perms) return 0;
@@ -122,6 +128,12 @@ const countCurrentDisabled = (role: string) => {
return Object.values(perms).filter((v) => !v).length;
};
const enabledPercent = (role: string) => {
const enabled = countCurrentEnabled(role);
const total = enabled + countCurrentDisabled(role);
return total > 0 ? Math.round((enabled / total) * 100) : 0;
};
const loadTemplates = async () => {
try {
const res = await fetchPermissionTemplates();
@@ -136,117 +148,155 @@ onMounted(loadTemplates);
<style scoped>
.template-selector {
padding: 16px 0;
padding: 8px 0 12px;
}
.selector-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.selector-header h3 {
.selector-title-group h3 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
color: #1a2332;
}
.selector-desc {
margin: 0;
font-size: 13px;
color: #888;
font-size: 12px;
color: #64748b;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
align-items: start;
gap: 12px;
margin-bottom: 16px;
margin-bottom: 12px;
}
.template-card {
padding: 14px;
border: 1.5px solid #e4e7ed;
border-radius: 8px;
transition: all 0.2s;
position: relative;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
.template-card:hover {
border-color: #93c5fd;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.1);
transform: translateY(-2px);
}
.template-card.inactive {
border-style: dashed;
background: #fafafa;
opacity: 0.75;
border-color: #e2e8f0;
background: #fafbfc;
opacity: 0.7;
}
.template-card:hover {
border-color: #c0c4cc;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.template-card.inactive:hover {
border-color: #cbd5e1;
box-shadow: none;
transform: none;
}
.template-card:not(.inactive):hover {
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
}
.card-header {
.card-top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
align-items: flex-start;
gap: 10px;
margin-bottom: 12px;
}
.card-icon {
font-size: 24px;
line-height: 1;
flex-shrink: 0;
}
.card-title-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
gap: 2px;
}
.card-name {
font-size: 14px;
font-weight: 600;
color: #303133;
color: #1a2332;
}
.template-card.inactive .card-name {
color: #909399;
color: #94a3b8;
}
.edit-role-btn {
margin-left: auto;
opacity: 0;
transition: opacity 0.15s;
.card-desc {
font-size: 11px;
color: #94a3b8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.template-card:hover .edit-role-btn {
opacity: 1;
}
.card-icon {
font-size: 20px;
line-height: 1;
.card-badge {
flex-shrink: 0;
}
.card-stats {
display: flex;
gap: 10px;
font-size: 12px;
color: #67c23a;
flex-direction: column;
gap: 8px;
}
.template-card.inactive .card-stats {
color: #b0b0b0;
.stat-bar {
height: 4px;
border-radius: 2px;
background: #f1f5f9;
overflow: hidden;
}
.card-stats .stat {
.stat-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, #10b981, #34d399);
transition: width 0.3s ease;
}
.stat-numbers {
display: flex;
gap: 12px;
}
.stat {
display: flex;
align-items: center;
gap: 3px;
gap: 4px;
font-size: 12px;
color: #64748b;
}
.card-stats .stat.disabled {
color: #f56c6c;
.stat-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.template-card.inactive .card-stats .stat.disabled {
color: #c0c4cc;
}
.stat-dot.green { background: #10b981; }
.stat-dot.red { background: #ef4444; }
.template-card.inactive .stat-dot.green { background: #94a3b8; }
.template-card.inactive .stat-dot.red { background: #cbd5e1; }
.template-card.inactive .stat-fill { background: #cbd5e1; }
</style>
@@ -0,0 +1,324 @@
<template>
<div class="trend-charts">
<div class="trend-toolbar">
<div class="toolbar-left">
<span class="toolbar-title">数据趋势</span>
<span class="toolbar-desc">查看权限系统各项指标的变化趋势</span>
</div>
<el-radio-group v-model="period" size="small" @change="loadData">
<el-radio-button value="24h">24小时</el-radio-button>
<el-radio-button value="7d">7</el-radio-button>
<el-radio-button value="30d">30</el-radio-button>
</el-radio-group>
</div>
<div v-loading="loading" class="charts-grid">
<div class="chart-card">
<div class="chart-header">
<div class="chart-icon blue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
</div>
<h4>检查量趋势</h4>
</div>
<v-chart :option="checksChartOption" autoresize class="chart" />
</div>
<div class="chart-card">
<div class="chart-header">
<div class="chart-icon cyan">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<h4>响应时间趋势</h4>
</div>
<v-chart :option="responseTimeOption" autoresize class="chart" />
</div>
<div class="chart-card">
<div class="chart-header">
<div class="chart-icon green">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<h4>缓存命中率</h4>
</div>
<v-chart :option="cacheHitOption" autoresize class="chart" />
</div>
<div class="chart-card">
<div class="chart-header">
<div class="chart-icon danger">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
</div>
<h4>拒绝率趋势</h4>
</div>
<v-chart :option="denyRateOption" autoresize class="chart" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import VChart from "vue-echarts";
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { LineChart, BarChart } from "echarts/charts";
import {
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
} from "echarts/components";
import { fetchPermissionTrends } from "@/api/projectPermissions";
import type { TrendDataPoint } from "@/types/api";
use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, GridComponent, LegendComponent]);
const period = ref<"24h" | "7d" | "30d">("24h");
const loading = ref(false);
const dataPoints = ref<TrendDataPoint[]>([]);
const formatTime = (iso: string) => {
const d = new Date(iso);
if (period.value === "24h") return `${d.getHours()}:00`;
return `${d.getMonth() + 1}/${d.getDate()}`;
};
const xLabels = computed(() => dataPoints.value.map((p: TrendDataPoint) => formatTime(p.bucket_time)));
const baseGridOption = {
grid: { left: 50, right: 20, top: 20, bottom: 25 },
tooltip: { trigger: "axis" as const },
};
const checksChartOption = computed(() => ({
grid: { left: 50, right: 20, top: 20, bottom: 50 },
tooltip: { trigger: "axis" as const },
legend: { data: ["允许", "拒绝"], bottom: 0, itemGap: 20 },
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
yAxis: { type: "value", minInterval: 1, splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
series: [
{
name: "允许",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) => p.allowed_checks),
itemStyle: { color: "#10b981" },
lineStyle: { width: 2.5 },
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(16,185,129,0.2)" }, { offset: 1, color: "rgba(16,185,129,0)" }] } },
},
{
name: "拒绝",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) => p.denied_checks),
itemStyle: { color: "#ef4444" },
lineStyle: { width: 2.5 },
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(239,68,68,0.15)" }, { offset: 1, color: "rgba(239,68,68,0)" }] } },
},
],
}));
const responseTimeOption = computed(() => ({
grid: { left: 50, right: 20, top: 20, bottom: 50 },
tooltip: { trigger: "axis" as const },
legend: { data: ["平均响应时间", "最大响应时间"], bottom: 0, itemGap: 20 },
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
yAxis: { type: "value", name: "ms", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
series: [
{
name: "平均响应时间",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) => p.avg_elapsed_ms),
itemStyle: { color: "#3b82f6" },
lineStyle: { width: 2.5 },
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(59,130,246,0.15)" }, { offset: 1, color: "rgba(59,130,246,0)" }] } },
},
{
name: "最大响应时间",
type: "line",
smooth: true,
symbol: "diamond",
symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) => p.max_elapsed_ms),
itemStyle: { color: "#f59e0b" },
lineStyle: { width: 2, type: "dashed" },
},
],
}));
const cacheHitOption = computed(() => ({
grid: { left: 50, right: 20, top: 20, bottom: 25 },
tooltip: { trigger: "axis" as const },
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
yAxis: { type: "value", max: 100, name: "%", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
series: [
{
name: "缓存命中率",
type: "bar",
barMaxWidth: 24,
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: (params: any) => {
const val = params.value;
if (val >= 80) return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#34d399" }, { offset: 1, color: "#10b981" }] };
if (val >= 50) return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#fbbf24" }, { offset: 1, color: "#f59e0b" }] };
return { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "#f87171" }, { offset: 1, color: "#ef4444" }] };
},
},
data: dataPoints.value.map((p: TrendDataPoint) => p.cache_hit_rate),
},
],
}));
const denyRateOption = computed(() => ({
grid: { left: 50, right: 20, top: 20, bottom: 25 },
tooltip: { trigger: "axis" as const },
xAxis: { type: "category", data: xLabels.value, axisLine: { lineStyle: { color: "#e2e8f0" } }, axisLabel: { color: "#64748b" } },
yAxis: { type: "value", max: 100, name: "%", splitLine: { lineStyle: { color: "#f1f5f9" } }, axisLabel: { color: "#64748b" } },
series: [
{
name: "拒绝率",
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
data: dataPoints.value.map((p: TrendDataPoint) =>
p.total_checks > 0 ? Math.round((p.denied_checks / p.total_checks) * 1000) / 10 : 0
),
itemStyle: { color: "#ef4444" },
lineStyle: { width: 2.5 },
areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: "rgba(239,68,68,0.2)" }, { offset: 1, color: "rgba(239,68,68,0)" }] } },
},
],
}));
const loadData = async () => {
loading.value = true;
try {
const res = await fetchPermissionTrends(period.value);
dataPoints.value = res.data.data_points;
} catch {
dataPoints.value = [];
} finally {
loading.value = false;
}
};
onMounted(loadData);
defineExpose({ refresh: loadData });
</script>
<style scoped>
.trend-charts {
--trend-ink: #1a2332;
--trend-muted: #64748b;
--trend-border: #e2e8f0;
--trend-radius: 16px;
--trend-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03);
}
.trend-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
margin-bottom: 16px;
background: #fff;
border: 1px solid var(--trend-border);
border-radius: var(--trend-radius);
box-shadow: var(--trend-shadow);
}
.toolbar-left {
display: flex;
flex-direction: column;
gap: 2px;
}
.toolbar-title {
font-size: 15px;
font-weight: 600;
color: var(--trend-ink);
}
.toolbar-desc {
font-size: 12px;
color: var(--trend-muted);
}
.charts-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
.chart-card {
background: #fff;
border: 1px solid var(--trend-border);
border-radius: var(--trend-radius);
padding: 18px;
box-shadow: var(--trend-shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.chart-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.07);
}
.chart-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.chart-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
flex-shrink: 0;
}
.chart-icon svg {
width: 16px;
height: 16px;
}
.chart-icon.blue { background: #eff6ff; color: #3b82f6; }
.chart-icon.cyan { background: #ecfeff; color: #06b6d4; }
.chart-icon.green { background: #ecfdf5; color: #10b981; }
.chart-icon.danger { background: #fef2f2; color: #ef4444; }
.chart-card h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--trend-ink);
}
.chart {
height: 240px;
width: 100%;
}
@media (max-width: 900px) {
.charts-grid {
grid-template-columns: 1fr;
}
.trend-toolbar {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
</style>
@@ -0,0 +1,14 @@
// 地图数据来源:https://geojson.cn/api/china/1.6.3/china.json
// 组件直接导入本地缓存的 GeoJSON,避免运行时依赖外部网络。
export const normalizeProvinceName = (value?: string | null) => {
return String(value || "")
.trim()
.replace(/$/u, "")
.replace(/$/u, "")
.replace(/$/u, "")
.replace(/$/u, "")
.replace(/$/u, "")
.replace(/$/u, "")
.replace(/$/u, "");
};
+3 -3
View File
@@ -8,13 +8,13 @@ describe("admin project route permissions", () => {
it("allows project admin pages through matrix-authorized PMs instead of all project roles", () => {
const source = readRouter();
expect(source).toContain('adminProjectPermission: { module: "project_members", action: "read" }');
expect(source).toContain('adminProjectPermission: { module: "project_members", action: "write" }');
expect(source).toContain('adminProjectPermission: { module: "sites", action: "read" }');
expect(source).toContain('adminProjectPermission: { module: "audit_export", action: "read" }');
expect(source).toContain("meta: { title: TEXT.menu.projectManagement }");
expect(source).toContain("ADMIN_PROJECT_OPERATION_KEYS");
expect(source).toContain('if (role !== "PM") return false;');
expect(source).toContain("roles?.[role]?.[permission.module]?.[permission.action]");
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain("studyStore.currentPermissions?.[role]?.[operationKey]");
});
it("keeps login landing independent from PM management backend access", () => {
+11 -3
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { isSystemAdmin } from "../utils/roles";
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "../utils/projectRoutePermissions";
import { fetchStudyDetail } from "../api/studies";
import { fetchApiEndpointPermissions } from "../api/projectPermissions";
@@ -489,6 +490,12 @@ const router = createRouter({
routes,
});
const ADMIN_PROJECT_OPERATION_KEYS: Record<string, Record<"read" | "write", string>> = {
audit_export: { read: "audit_logs:read", write: "audit_logs:read" },
project_members: { read: "project_members:list", write: "project_members:update" },
sites: { read: "sites:read", write: "sites:update" },
};
const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
if (isAdmin) return true;
const studyStore = useStudyStore();
@@ -509,8 +516,9 @@ const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
if (role !== "PM") return false;
// 管理员项目权限检查:PM 角色默认有访问权限
return true;
const operationKey = ADMIN_PROJECT_OPERATION_KEYS[permission.module]?.[permission.action];
if (!operationKey) return false;
return isApiPermissionAllowed(studyStore.currentPermissions?.[role]?.[operationKey]);
};
router.beforeEach(async (to, _from, next) => {
@@ -578,7 +586,7 @@ router.beforeEach(async (to, _from, next) => {
await studyStore.loadCurrentStudyPermissions().catch(() => {});
}
const role = studyStore.currentStudyRole || (studyStore.currentStudy as any)?.role_in_study || "";
const canReadAudit = !!studyStore.currentPermissions?.[role]?.["audit_logs:read"]?.allowed;
const canReadAudit = isApiPermissionAllowed(studyStore.currentPermissions?.[role]?.["audit_logs:read"]);
if (!(isAdmin || (role === "PM" && canReadAudit))) {
next({ path: "/project/overview" });
return;
+1 -1
View File
@@ -117,7 +117,7 @@ describe("study store startup rehydration", () => {
status: "ACTIVE",
role_in_study: "PM",
} as any);
study.currentPermissions = { roles: { PM: { project_members: { read: true, write: true } } } };
study.currentPermissions = { PM: { "project_members:update": { allowed: true } } };
study.setCurrentStudy({
id: "study-b",
+154 -3
View File
@@ -131,11 +131,11 @@ export interface Site {
}
// 接口级权限
export type ApiPermissionValue = boolean | { allowed: boolean };
export interface ApiEndpointPermissionsResponse {
[role: string]: {
[endpoint_key: string]: {
allowed: boolean;
};
[endpoint_key: string]: ApiPermissionValue;
};
}
@@ -204,3 +204,154 @@ export interface HealthResponse {
metrics: PermissionMetricsResponse;
cache_stats: CacheStatsResponse;
}
// 权限监控 - 访问日志
export interface AccessLogItem {
id: string;
study_id: string;
user_id: string;
user_name: string;
endpoint_key: string;
role: string;
allowed: boolean;
elapsed_ms: number;
ip_address: string | null;
ip_location: string;
ip_country: string;
ip_province: string;
ip_city: string;
ip_isp: string;
created_at: string;
}
export interface AccessLogsSummary {
total_count: number;
unique_user_count: number;
unique_ip_count: number;
denied_count: number;
avg_elapsed_ms: number;
}
export interface AccessLogUserStat {
user_id: string;
user_name: string;
role: string;
total_count: number;
denied_count: number;
unique_ip_count: number;
sample_ip_address: string | null;
last_seen_at: string | null;
primary_location: string;
}
export interface AccessLogsResponse {
total: number;
page: number;
page_size: number;
summary: AccessLogsSummary;
user_stats: AccessLogUserStat[];
items: AccessLogItem[];
}
export interface SecurityAccessLogItem {
id: string;
method: string;
path: string;
status_code: number;
elapsed_ms: number;
client_ip: string | null;
user_agent: string | null;
auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string;
user_identifier: string | null;
account_label: string;
created_at: string;
}
export interface SecurityAccessLogsResponse {
total: number;
page: number;
page_size: number;
summary: {
total_count: number;
anonymous_count: number;
invalid_token_count: number;
error_count: number;
};
items: SecurityAccessLogItem[];
}
// 权限监控 - 趋势数据
export interface TrendDataPoint {
bucket_time: string;
total_checks: number;
allowed_checks: number;
denied_checks: number;
avg_elapsed_ms: number;
max_elapsed_ms: number;
cache_hits: number;
cache_misses: number;
cache_hit_rate: number;
error_count: number;
}
export interface TrendsResponse {
period: string;
data_points: TrendDataPoint[];
}
// 权限监控 - 被拒绝最多
export interface TopDeniedItem {
endpoint_key: string;
role: string;
denied_count: number;
}
export interface TopDeniedResponse {
days: number;
items: TopDeniedItem[];
}
export interface IpLocationStatItem {
country: string;
province: string;
city: string;
isp: string;
location: string;
total_count: number;
allowed_count: number;
denied_count: number;
unique_ip_count: number;
unique_user_count: number;
}
export interface IpLocationsResponse {
days: number;
summary: {
total_count: number;
allowed_count: number;
denied_count: number;
unique_ip_count: number;
unique_user_count: number;
};
items: IpLocationStatItem[];
}
// 权限监控 - 统计摘要
export interface StatsSummaryResponse {
total_logs: number;
today: {
total_checks: number;
allowed_checks: number;
denied_checks: number;
avg_elapsed_ms: number;
max_elapsed_ms: number;
allow_rate: number;
deny_rate: number;
};
last_hour: {
total_checks: number;
allowed_checks: number;
denied_checks: number;
requests_per_minute: number;
};
}
+6
View File
@@ -0,0 +1,6 @@
import type { ApiPermissionValue } from "../types/api";
export const isApiPermissionAllowed = (value: ApiPermissionValue | null | undefined): boolean => {
if (typeof value === "boolean") return value;
return !!value?.allowed;
};
+5 -2
View File
@@ -12,8 +12,11 @@ describe("permission project role model", () => {
expect(source).toContain("getProjectRole");
expect(source).toContain("study.currentStudyRole");
expect(source).toContain("study.currentPermissions");
expect(source).toContain('"faq.edit": { module: "faq", action: "write" }');
expect(source).not.toContain('"faq.edit": { module: "etmf"');
expect(source).toContain('"faq.edit": "faq:update"');
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain("projectPermissions.value?.[projectRole.value]?.[operationKey]");
expect(source).not.toContain("projectPermissions.value?.roles");
expect(source).not.toContain('"faq.edit": "etmf');
expect(source).not.toContain('"ADMIN", "PM"');
});
});
+25 -25
View File
@@ -3,6 +3,7 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { TEXT } from "../locales";
import { getProjectRole, isSystemAdmin } from "./roles";
import { isApiPermissionAllowed } from "./apiPermissionValue";
const PERMISSIONS: Record<string, string[]> = {
"subject.create": ["ADMIN"],
@@ -45,32 +46,31 @@ export const usePermission = () => {
const can = (action: string): boolean => {
if (systemAdmin.value) return true;
if (!projectRole.value) return false;
const permissionMap: Record<string, { module: string; action: "read" | "write" }> = {
"subject.create": { module: "subjects", action: "write" },
"subject.enroll": { module: "subjects", action: "write" },
"subject.complete": { module: "subjects", action: "write" },
"subject.drop": { module: "subjects", action: "write" },
"ae.create": { module: "risk_issues", action: "write" },
"ae.close": { module: "risk_issues", action: "write" },
"project.overview.read": { module: "project_overview", action: "read" },
"faq.edit": { module: "faq", action: "write" },
"faq.create": { module: "faq", action: "write" },
"faq.reply": { module: "faq", action: "write" },
"knowledge.notes.write": { module: "shared_library", action: "write" },
"shared.library.read": { module: "shared_library", action: "read" },
"shared.library.write": { module: "shared_library", action: "write" },
"project.members.manage": { module: "project_members", action: "write" },
"site.manage": { module: "sites", action: "write" },
"site.cra.bind": { module: "sites", action: "write" },
"fees.contract.write": { module: "fees", action: "write" },
"fees.attachment.delete": { module: "fees", action: "write" },
"file.attachment.delete": { module: "file_versions", action: "write" },
"audit.export.read": { module: "audit_export", action: "read" },
const permissionMap: Record<string, string> = {
"subject.create": "subjects:create",
"subject.enroll": "subjects:update",
"subject.complete": "subjects:update",
"subject.drop": "subjects:update",
"ae.create": "risk_issues:create",
"ae.close": "risk_issues:update",
"project.overview.read": "project_overview:read",
"faq.edit": "faq:update",
"faq.create": "faq:create",
"faq.reply": "faq_reply:create",
"knowledge.notes.write": "knowledge_notes:create",
"shared.library.read": "knowledge_notes:read",
"shared.library.write": "knowledge_notes:create",
"project.members.manage": "project_members:update",
"site.manage": "sites:update",
"site.cra.bind": "sites:update",
"fees.contract.write": "fees_contracts:create",
"fees.attachment.delete": "fees_attachments:delete",
"file.attachment.delete": "attachments:delete",
"audit.export.read": "audit_logs:read",
};
const mapped = permissionMap[action];
if (!mapped) return false;
const allowed = projectPermissions.value?.roles?.[projectRole.value]?.[mapped.module]?.[mapped.action];
return !!allowed;
const operationKey = permissionMap[action];
if (!operationKey) return false;
return isApiPermissionAllowed(projectPermissions.value?.[projectRole.value]?.[operationKey]);
};
const reason = (action: string): string => {
@@ -2,18 +2,22 @@ import { describe, expect, it } from "vitest";
import { findFirstAccessibleProjectPath, getProjectRoutePermission, hasProjectPermission } from "./projectRoutePermissions";
describe("project route permissions", () => {
it("maps project routes to read permission modules", () => {
expect(getProjectRoutePermission("/project/overview")).toEqual({ module: "project_overview", action: "read" });
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ module: "faq", action: "read" });
expect(getProjectRoutePermission("/knowledge/notes/new")).toEqual({ module: "shared_library", action: "read" });
expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ module: "startup_auth", action: "read" });
it("maps project routes to backend operation keys", () => {
expect(getProjectRoutePermission("/project/overview")).toEqual({ operationKey: "project_overview:read" });
expect(getProjectRoutePermission("/project/milestones")).toEqual({ operationKey: "project_milestones:read" });
expect(getProjectRoutePermission("/finance/contracts")).toEqual({ operationKey: "finance_contracts:read" });
expect(getProjectRoutePermission("/drug/shipments")).toEqual({ operationKey: "drug_shipments:read" });
expect(getProjectRoutePermission("/startup/feasibility/new")).toEqual({ operationKey: "ethics:read" });
expect(getProjectRoutePermission("/startup/kickoff/new")).toEqual({ operationKey: "startup_auth:read" });
expect(getProjectRoutePermission("/knowledge/medical-consult/abc")).toEqual({ operationKey: "faq:read" });
expect(getProjectRoutePermission("/knowledge/notes/new")).toEqual({ operationKey: "knowledge_notes:read" });
});
it("rejects project routes when the role lacks module read permission", () => {
it("rejects project routes when the role lacks operation permission", () => {
const matrix = {
CRA: {
project_overview: { read: false, write: false },
shared_library: { read: true, write: true },
"project_overview:read": { allowed: false },
"knowledge_notes:read": { allowed: true },
},
} as any;
@@ -1,4 +1,5 @@
import type { ApiEndpointPermissionsResponse } from "../types/api";
import { isApiPermissionAllowed } from "./apiPermissionValue";
export type ProjectRoutePermission = {
operationKey: string;
@@ -7,10 +8,12 @@ export type ProjectRoutePermission = {
const routePermissions: Array<{ prefixes: string[]; permission: ProjectRoutePermission }> = [
{ prefixes: ["/project/overview"], permission: { operationKey: "project_overview:read" } },
{ prefixes: ["/project/milestones"], permission: { operationKey: "project_milestones:read" } },
{ prefixes: ["/fees/contracts", "/finance/contracts"], permission: { operationKey: "fees_contracts:read" } },
{ prefixes: ["/drug/shipments", "/materials/equipment"], permission: { operationKey: "material_equipments:read" } },
{ prefixes: ["/fees/contracts"], permission: { operationKey: "fees_contracts:read" } },
{ prefixes: ["/finance/contracts"], permission: { operationKey: "finance_contracts:read" } },
{ prefixes: ["/drug/shipments"], permission: { operationKey: "drug_shipments:read" } },
{ prefixes: ["/materials/equipment"], permission: { operationKey: "material_equipments:read" } },
{ prefixes: ["/file-versions", "/trial/", "/documents/"], permission: { operationKey: "documents:read" } },
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility", "/startup/ethics"], permission: { operationKey: "startup_ethics:read" } },
{ prefixes: ["/startup/feasibility-ethics", "/startup/feasibility", "/startup/ethics"], permission: { operationKey: "ethics:read" } },
{ prefixes: ["/startup/meeting-auth", "/startup/kickoff", "/startup/training"], permission: { operationKey: "startup_auth:read" } },
{ prefixes: ["/subjects"], permission: { operationKey: "subjects:read" } },
{ prefixes: ["/risk-issues"], permission: { operationKey: "risk_issues:read" } },
@@ -57,7 +60,7 @@ export const hasProjectPermission = (
) => {
if (!permission || isAdmin) return true;
if (!role || !permissions) return false;
return !!permissions[role]?.[permission.operationKey]?.allowed;
return isApiPermissionAllowed(permissions[role]?.[permission.operationKey]);
};
export const findFirstAccessibleProjectPath = (
+26 -21
View File
@@ -1,8 +1,28 @@
import { describe, it, expect, beforeEach } from "vitest";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import ApiPermissions from "@/views/admin/ApiPermissions.vue";
import { createPinia, setActivePinia } from "pinia";
vi.mock("vue-router", async () => {
const actual = await vi.importActual<typeof import("vue-router")>("vue-router");
return {
...actual,
useRoute: () => ({ params: { id: "study-1" } }),
};
});
vi.mock("@/api/projectPermissions", () => ({
fetchApiEndpointPermissions: vi.fn().mockResolvedValue({ data: {} }),
updateApiEndpointPermissions: vi.fn().mockResolvedValue({ data: {} }),
fetchPermissionMetrics: vi.fn().mockResolvedValue({ data: {} }),
fetchCacheStats: vi.fn().mockResolvedValue({ data: {} }),
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
fetchPermissionHealth: vi.fn().mockResolvedValue({ data: {} }),
resetPermissionMetrics: vi.fn().mockResolvedValue({ data: undefined }),
}));
describe("ApiPermissions.vue", () => {
beforeEach(() => {
setActivePinia(createPinia());
@@ -23,30 +43,15 @@ describe("ApiPermissions.vue", () => {
});
it("renders tabs", () => {
const wrapper = mount(ApiPermissions, {
global: {
stubs: {
ApiEndpointPermissions: true,
PermissionMonitoring: true,
},
},
});
const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
const tabs = wrapper.findAll(".el-tabs__nav-item");
expect(tabs.length).toBeGreaterThanOrEqual(2);
expect(source).toContain('label="角色权限"');
expect(source).toContain('label="权限监控"');
});
it("has save button disabled when not dirty", () => {
const wrapper = mount(ApiPermissions, {
global: {
stubs: {
ApiEndpointPermissions: true,
PermissionMonitoring: true,
},
},
});
const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
const saveButton = wrapper.findAll("button").find((btn) => btn.text().includes("保存"));
expect(saveButton?.attributes("disabled")).toBeDefined();
expect(source).toContain(':disabled="!dirty"');
});
});
+14 -3
View File
@@ -156,10 +156,10 @@ const onApiMatrixUpdate = (newMatrix: ApiEndpointPermissionsResponse) => {
const currentPermissionsForTemplate = computed(() => {
if (!apiMatrix.value) return undefined;
const result: Record<string, Record<string, boolean>> = {};
for (const [role, endpoints] of Object.entries(apiMatrix.value as Record<string, Record<string, { allowed: boolean }>>)) {
for (const [role, endpoints] of Object.entries(apiMatrix.value)) {
result[role] = {};
for (const [key, val] of Object.entries(endpoints)) {
result[role][key] = val.allowed;
result[role][key] = typeof val === "boolean" ? val : val.allowed;
}
}
return result;
@@ -171,13 +171,24 @@ const onTemplateApplied = async (permissions: Record<string, Record<string, { al
ElMessage.success("权限已更新");
};
const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record<string, Record<string, boolean>> => {
const result: Record<string, Record<string, boolean>> = {};
for (const [role, endpoints] of Object.entries(matrix)) {
result[role] = {};
for (const [key, val] of Object.entries(endpoints)) {
result[role][key] = typeof val === "boolean" ? val : val.allowed;
}
}
return result;
};
const save = async () => {
if (!studyId.value || !dirty.value) return;
saving.value = true;
try {
if (apiMatrix.value) {
const res = await updateApiEndpointPermissions(studyId.value, apiMatrix.value);
const res = await updateApiEndpointPermissions(studyId.value, flattenMatrix(apiMatrix.value));
apiMatrix.value = res.data;
}
+2 -2
View File
@@ -13,8 +13,8 @@ describe("audit logs access", () => {
expect(source).toContain("getProjectRole");
expect(source).toContain("study.currentStudyRole");
expect(source).toContain('projectRole.value === "PM"');
expect(source).toContain("roles?.[projectRole.value]?.audit_export?.read");
expect(source).not.toContain("roles?.PM?.audit_export?.read");
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]');
expect(source).not.toContain('const role = auth.user?.role');
expect(source).not.toContain('role !== "ADMIN"');
});
+3 -2
View File
@@ -177,6 +177,7 @@ import { roleDict, getDictLabel } from "../../dictionaries";
import { exportAuditCsv } from "../../audit/export/auditExportService";
import { displayDateTime } from "../../utils/display";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales";
const study = useStudyStore();
@@ -216,11 +217,11 @@ const isAdmin = computed(() => isSystemAdmin(auth.user));
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const canProjectExport = computed(() => {
if (isAdmin.value) return true;
return projectRole.value === "PM" && !!permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]?.allowed;
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]);
});
const canAccessAuditLogs = computed(() => {
if (isAdmin.value) return true;
return projectRole.value === "PM" && !!permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]?.allowed;
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]);
});
const ensureAccess = () => {
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionManagement.vue"), "utf8");
describe("permission management custom roles", () => {
it("uses role management wording in the drawer", () => {
const source = readSource();
expect(source).toContain('title="角色管理"');
expect(source).toContain('label="角色列表"');
expect(source).toContain('placeholder="角色类型"');
expect(source).toContain("新增角色");
expect(source).toContain('label="角色名称"');
expect(source).toContain("roleDisplayName(row)");
expect(source).not.toContain('title="权限模板管理"');
expect(source).not.toContain('label="模板列表"');
expect(source).not.toContain("新增模板");
});
it("keeps custom active roles configurable for permissions and members", () => {
const source = readSource();
expect(source).toContain("customRoleInput");
expect(source).toContain("addCustomRole");
expect(source).toContain("roleOptions");
expect(source).toContain("ROLE_LABELS[role] || role");
expect(source).toContain("ROLE_LABELS[row.category] || row.name");
expect(source).toContain("await loadPermissionData();");
expect(source).toContain('activeRolesInStudy.value[0] || ""');
expect(source).toContain('role === "ADMIN"');
});
it("does not render the duplicated monitoring header", () => {
const source = readSource();
expect(source).toContain("<PermissionMonitoring />");
expect(source).not.toContain("实时监控权限使用情况、趋势分析与异常告警");
expect(source).not.toContain("重置指标");
expect(source).not.toContain("refreshMonitoring");
expect(source).not.toContain("doResetMetrics");
});
it("does not render the duplicated system permissions header", () => {
const source = readSource();
expect(source).toContain("systemPermissionsByModule");
expect(source).not.toContain("<h2 class=\"perm-title\">系统级权限</h2>");
expect(source).not.toContain("只读展示,所有系统管理操作仅限 ADMIN 角色执行");
});
it("removes the vertical gap between permission headers and body content", () => {
const source = readSource();
expect(source).toContain("padding: 0 0 20px;");
expect(source).not.toContain("padding: 20px 0 20px;");
});
});
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -82,6 +82,7 @@ import ProjectForm from "./ProjectForm.vue";
import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth";
import { isSystemAdmin } from "../../utils/roles";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales";
const projects = ref<Study[]>([]);
@@ -127,7 +128,7 @@ const openCreate = () => {
};
const MODULE_TO_OPERATION: Record<string, Record<string, string>> = {
project_members: { read: "project_members:read", write: "project_members:update" },
project_members: { read: "project_members:list", write: "project_members:update" },
sites: { read: "sites:read", write: "sites:update" },
audit_export: { read: "audit_logs:read", write: "audit_logs:read" },
};
@@ -137,7 +138,7 @@ const canProject = (row: Study, module: string, action: "read" | "write") => {
const role = row.role_in_study || "";
const operationKey = MODULE_TO_OPERATION[module]?.[action];
if (!operationKey) return false;
return !!permissionsByProject.value[row.id]?.[role]?.[operationKey]?.allowed;
return isApiPermissionAllowed(permissionsByProject.value[row.id]?.[role]?.[operationKey]);
};
const goMembers = (row: Study) => {
@@ -66,7 +66,6 @@ describe("route view overlays are mounted only when visible", () => {
'<el-drawer v-if="projectMilestoneEditorVisible"',
'<el-drawer v-if="siteMilestoneEditorVisible"',
'<el-drawer v-if="siteEnrollmentEditorVisible"',
'<el-drawer v-if="monitoringStrategyEditorVisible"',
'const getRollbackAxisLeftStyle = (axisX?: number | null) => ({',
':style="getRollbackAxisLeftStyle(lane.x)"',
':style="getRollbackAxisLeftStyle(row.axisX)"',
+1 -1
View File
@@ -164,7 +164,7 @@ const roleLabel = computed(() => displayEnum(TEXT.enums.userRole, projectRole.va
const emptyText = TEXT.modules.workbench.quickEmpty;
const isCraWorkbench = computed(() => projectRole.value === "CRA");
const canAccessProjectPath = (path: string) =>
hasProjectPermission(study.currentPermissions?.roles, projectRole.value, getProjectRoutePermission(path), isSystemAdmin(auth.user));
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isSystemAdmin(auth.user));
const quickActionCandidates = [
{ label: TEXT.modules.workbench.feasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer },
{ label: TEXT.modules.workbench.aeList, path: "/subjects", icon: Warning },
+5 -1
View File
@@ -12,7 +12,11 @@
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["ESNext", "DOM"],
"types": ["node", "vite/client"]
"types": ["node", "vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]