Compare commits
13 Commits
master
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
| a1a2d86b0b | |||
| ba8509dc66 | |||
| 6b8a05cc4d | |||
| 6b8626907e | |||
| 348e40e9c2 | |||
| 86a3c1f97b | |||
| 09538b03cb | |||
| b15b6b4f4d | |||
| 20251316ae | |||
| 37acdcf394 | |||
| 72ed0eb5aa | |||
| 93bfef06a4 | |||
| 44b2dd9d05 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -56,3 +56,4 @@ design-system/
|
||||
.agent/
|
||||
.shared/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
343
Jenkinsfile
vendored
Normal file
343
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,343 @@
|
||||
// ============================================
|
||||
// AIoT Platform UI (vben) - Jenkins Pipeline
|
||||
// ============================================
|
||||
// 双分支部署:
|
||||
// master -> PROD (172.17.16.14:80)
|
||||
// release/next -> STAGING (172.17.16.7:80)
|
||||
// 镜像仓库:172.17.16.7:5000
|
||||
// ============================================
|
||||
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
options {
|
||||
buildDiscarder(logRotator(numToKeepStr: '10', artifactNumToKeepStr: '5'))
|
||||
disableConcurrentBuilds()
|
||||
timeout(time: 45, unit: 'MINUTES')
|
||||
timestamps()
|
||||
retry(1)
|
||||
}
|
||||
|
||||
environment {
|
||||
// ===== 应用标识 =====
|
||||
APP_NAME = 'aiot-platform-ui'
|
||||
CONTAINER_NAME = 'aiot-platform-ui'
|
||||
HOST_PORT = '80'
|
||||
|
||||
// ===== 镜像仓库 =====
|
||||
REGISTRY = '172.17.16.7:5000'
|
||||
REGISTRY_HOST = '172.17.16.7'
|
||||
IMAGE_NAME = "${REGISTRY}/${APP_NAME}"
|
||||
|
||||
// ===== 部署目标(默认 PROD,release/next 在 Initialize 阶段覆盖) =====
|
||||
DEPLOY_HOST = '172.17.16.14'
|
||||
DEPLOY_PATH = '/opt/aiot-platform-ui'
|
||||
STAGING_DEPLOY_HOST = '172.17.16.7'
|
||||
STAGING_DEPLOY_PATH = '/opt/aiot-platform-ui'
|
||||
SSH_KEY = '/var/jenkins_home/.ssh/id_rsa'
|
||||
|
||||
// ===== 健康检查 =====
|
||||
HEALTH_CHECK_TIMEOUT = '60'
|
||||
HEALTH_CHECK_INTERVAL = '5'
|
||||
|
||||
// ===== 镜像保留份数 =====
|
||||
IMAGE_KEEP_COUNT = '3'
|
||||
|
||||
// ===== 磁盘守护阈值(%) =====
|
||||
DISK_FREE_MIN_PCT = '5'
|
||||
DISK_FREE_WARN_PCT = '10'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Initialize') {
|
||||
steps {
|
||||
script {
|
||||
env.PIPELINE_START_TIME = System.currentTimeMillis()
|
||||
|
||||
echo "=========================================="
|
||||
echo " ${env.APP_NAME} - CI/CD Pipeline"
|
||||
echo "=========================================="
|
||||
echo "Branch: ${env.BRANCH_NAME}"
|
||||
echo "Build: #${env.BUILD_NUMBER}"
|
||||
echo "=========================================="
|
||||
|
||||
if (env.BRANCH_NAME == 'release/next') {
|
||||
env.DEPLOY_HOST = env.STAGING_DEPLOY_HOST
|
||||
env.DEPLOY_PATH = env.STAGING_DEPLOY_PATH
|
||||
echo "📦 Deploy target: STAGING (${env.DEPLOY_HOST}:${env.HOST_PORT})"
|
||||
} else {
|
||||
echo "📦 Deploy target: PRODUCTION (${env.DEPLOY_HOST}:${env.HOST_PORT})"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
script {
|
||||
retry(3) { checkout scm }
|
||||
|
||||
def shortCommit = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
|
||||
def sanitized = env.BRANCH_NAME.replaceAll('/', '-')
|
||||
env.IMAGE_TAG = "${sanitized}-${env.BUILD_NUMBER}-${shortCommit}"
|
||||
env.PREVIOUS_IMAGE_TAG = getPreviousImageTag()
|
||||
|
||||
echo "🏷️ Tag: ${env.IMAGE_TAG}"
|
||||
echo "🔖 Previous Tag (rollback to): ${env.PREVIOUS_IMAGE_TAG}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Pre-build Check') {
|
||||
steps {
|
||||
script {
|
||||
sh "docker version >/dev/null 2>&1 || { echo '❌ Docker not available'; exit 1; }"
|
||||
|
||||
// Registry 可达性
|
||||
sh """
|
||||
curl -f http://${env.REGISTRY}/v2/ >/dev/null 2>&1 || \
|
||||
echo '⚠️ Registry not accessible, will continue (build still produces local image)'
|
||||
"""
|
||||
|
||||
// Jenkins agent 本地磁盘
|
||||
def diskUsage = sh(
|
||||
script: "df ${env.WORKSPACE} | tail -1 | awk '{print \$5}' | sed 's/%//'",
|
||||
returnStdout: true
|
||||
).trim() as int
|
||||
if (diskUsage > 80) {
|
||||
echo "⚠️ Local disk usage ${diskUsage}%, pruning Docker..."
|
||||
sh "docker system prune -f || true"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build & Push Image') {
|
||||
steps {
|
||||
script {
|
||||
def buildStart = System.currentTimeMillis()
|
||||
echo "🔨 Building ${env.IMAGE_NAME}:${env.IMAGE_TAG}"
|
||||
|
||||
// --network=1panel-network 让构建容器能解析 1Panel-verdaccio-Ynee
|
||||
sh """
|
||||
set -e
|
||||
docker build \\
|
||||
--network=1panel-network \\
|
||||
-t ${env.IMAGE_NAME}:${env.IMAGE_TAG} \\
|
||||
.
|
||||
|
||||
docker push ${env.IMAGE_NAME}:${env.IMAGE_TAG}
|
||||
|
||||
if [ "${env.BRANCH_NAME}" = "master" ]; then
|
||||
docker tag ${env.IMAGE_NAME}:${env.IMAGE_TAG} ${env.IMAGE_NAME}:latest
|
||||
docker push ${env.IMAGE_NAME}:latest
|
||||
fi
|
||||
"""
|
||||
|
||||
def imageSize = sh(
|
||||
script: "docker images ${env.IMAGE_NAME}:${env.IMAGE_TAG} --format '{{.Size}}'",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
def duration = ((System.currentTimeMillis() - buildStart) / 1000) as int
|
||||
echo "✅ Built and pushed in ${duration}s, size=${imageSize}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Pre-deploy Check') {
|
||||
when { anyOf { branch 'master'; branch 'release/next' } }
|
||||
steps {
|
||||
script {
|
||||
echo "🛡️ Pre-deploy: SSH + 磁盘可达性检查"
|
||||
checkRemoteDiskOrFail(env.DEPLOY_HOST, 'Deploy')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy') {
|
||||
when { anyOf { branch 'master'; branch 'release/next' } }
|
||||
steps {
|
||||
script {
|
||||
def sshOpts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${env.SSH_KEY}"
|
||||
|
||||
echo "📂 同步 docker-compose.frontend.yml 到 ${env.DEPLOY_HOST}:${env.DEPLOY_PATH}"
|
||||
sh """
|
||||
ssh ${sshOpts} root@${env.DEPLOY_HOST} 'mkdir -p ${env.DEPLOY_PATH}'
|
||||
scp ${sshOpts} docker-compose.frontend.yml root@${env.DEPLOY_HOST}:${env.DEPLOY_PATH}/docker-compose.frontend.yml
|
||||
"""
|
||||
|
||||
try {
|
||||
echo "🚀 Deploy ${env.APP_NAME}:${env.IMAGE_TAG}"
|
||||
sh """
|
||||
ssh ${sshOpts} root@${env.DEPLOY_HOST} '
|
||||
set -e
|
||||
cd ${env.DEPLOY_PATH}
|
||||
export IMAGE_TAG=${env.IMAGE_TAG}
|
||||
export REGISTRY_HOST=${env.REGISTRY}
|
||||
export HOST_PORT=${env.HOST_PORT}
|
||||
docker compose -f docker-compose.frontend.yml pull
|
||||
docker compose -f docker-compose.frontend.yml up -d --force-recreate
|
||||
sleep 3
|
||||
'
|
||||
"""
|
||||
|
||||
// 健康检查
|
||||
waitForHealthy(sshOpts)
|
||||
|
||||
echo "✅ ${env.APP_NAME} deployed to ${env.DEPLOY_HOST}:${env.HOST_PORT}"
|
||||
} catch (Exception e) {
|
||||
echo "❌ Deploy failed: ${e.message}"
|
||||
if (env.PREVIOUS_IMAGE_TAG && env.PREVIOUS_IMAGE_TAG != 'latest') {
|
||||
echo "🔄 Rolling back to ${env.PREVIOUS_IMAGE_TAG}"
|
||||
rollback(sshOpts)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Cleanup Old Images') {
|
||||
when { anyOf { branch 'master'; branch 'release/next' } }
|
||||
steps {
|
||||
script {
|
||||
def sshOpts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${env.SSH_KEY}"
|
||||
echo "🧹 清理 ${env.DEPLOY_HOST} 上 ${env.APP_NAME} 的旧镜像(保留 ${env.IMAGE_KEEP_COUNT} 份)"
|
||||
sh """
|
||||
ssh ${sshOpts} root@${env.DEPLOY_HOST} '
|
||||
docker images ${env.IMAGE_NAME} \\
|
||||
--format "{{.CreatedAt}}|{{.ID}}|{{.Tag}}" \\
|
||||
| grep -v "|latest\$" \\
|
||||
| sort -r \\
|
||||
| awk -F"|" -v k=${env.IMAGE_KEEP_COUNT} "NR > k {print \\\$2}" \\
|
||||
| sort -u \\
|
||||
| xargs -r docker rmi -f 2>/dev/null || true
|
||||
|
||||
docker image prune -f --filter "dangling=true" || true
|
||||
'
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
script {
|
||||
def total = ((System.currentTimeMillis() - env.PIPELINE_START_TIME.toLong()) / 1000) as int
|
||||
echo """
|
||||
==========================================
|
||||
✅ BUILD SUCCESS
|
||||
==========================================
|
||||
App: ${env.APP_NAME}
|
||||
Tag: ${env.IMAGE_TAG}
|
||||
Target: ${env.DEPLOY_HOST}:${env.HOST_PORT}
|
||||
Total: ${total}s
|
||||
==========================================
|
||||
"""
|
||||
}
|
||||
}
|
||||
failure {
|
||||
script {
|
||||
echo "❌ BUILD FAILED — tag=${env.IMAGE_TAG ?: 'unknown'}, rollback_tag=${env.PREVIOUS_IMAGE_TAG ?: 'N/A'}"
|
||||
}
|
||||
}
|
||||
always {
|
||||
sh "docker image prune -f --filter 'dangling=true' || true"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// helpers
|
||||
// ============================================
|
||||
|
||||
def getPreviousImageTag() {
|
||||
try {
|
||||
def sshOpts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${env.SSH_KEY}"
|
||||
def tag = sh(
|
||||
script: """
|
||||
ssh ${sshOpts} root@${env.DEPLOY_HOST} '
|
||||
docker inspect --format="{{.Config.Image}}" ${env.CONTAINER_NAME} 2>/dev/null \
|
||||
| sed "s|.*:||" \
|
||||
|| echo "latest"
|
||||
' 2>/dev/null || echo "latest"
|
||||
""",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
return tag ?: 'latest'
|
||||
} catch (Exception e) {
|
||||
return 'latest'
|
||||
}
|
||||
}
|
||||
|
||||
def waitForHealthy(String sshOpts) {
|
||||
def maxAttempts = (env.HEALTH_CHECK_TIMEOUT.toInteger() / env.HEALTH_CHECK_INTERVAL.toInteger()) as int
|
||||
echo "⏳ 等待 ${env.APP_NAME} 健康(最多 ${env.HEALTH_CHECK_TIMEOUT}s)..."
|
||||
|
||||
sh """
|
||||
ssh ${sshOpts} root@${env.DEPLOY_HOST} '
|
||||
set -e
|
||||
for i in \$(seq 1 ${maxAttempts}); do
|
||||
# 优先 docker healthcheck,没配的话直接 curl
|
||||
STATUS=\$(docker inspect --format="{{.State.Health.Status}}" ${env.CONTAINER_NAME} 2>/dev/null || echo "no-healthcheck")
|
||||
case "\$STATUS" in
|
||||
healthy)
|
||||
echo "✅ ${env.APP_NAME} healthy"; exit 0;;
|
||||
unhealthy)
|
||||
echo "❌ ${env.APP_NAME} unhealthy"
|
||||
docker logs --tail 100 ${env.CONTAINER_NAME}
|
||||
exit 1;;
|
||||
no-healthcheck)
|
||||
if curl -fsS http://localhost:${env.HOST_PORT}/ -o /dev/null; then
|
||||
echo "✅ ${env.APP_NAME} responds 200"; exit 0
|
||||
fi;;
|
||||
esac
|
||||
ELAPSED=\$((i * ${env.HEALTH_CHECK_INTERVAL}))
|
||||
echo "⏳ \${ELAPSED}s/${env.HEALTH_CHECK_TIMEOUT}s ..."
|
||||
sleep ${env.HEALTH_CHECK_INTERVAL}
|
||||
done
|
||||
echo "❌ Health check timeout"
|
||||
docker logs --tail 100 ${env.CONTAINER_NAME} || true
|
||||
exit 1
|
||||
'
|
||||
"""
|
||||
}
|
||||
|
||||
def rollback(String sshOpts) {
|
||||
sh """
|
||||
ssh ${sshOpts} root@${env.DEPLOY_HOST} '
|
||||
set -e
|
||||
cd ${env.DEPLOY_PATH}
|
||||
export IMAGE_TAG=${env.PREVIOUS_IMAGE_TAG}
|
||||
export REGISTRY_HOST=${env.REGISTRY}
|
||||
export HOST_PORT=${env.HOST_PORT}
|
||||
docker compose -f docker-compose.frontend.yml pull
|
||||
docker compose -f docker-compose.frontend.yml up -d --force-recreate
|
||||
'
|
||||
"""
|
||||
echo "✅ Rolled back ${env.APP_NAME} to ${env.PREVIOUS_IMAGE_TAG}"
|
||||
}
|
||||
|
||||
def checkRemoteDiskOrFail(String host, String role) {
|
||||
def sshOpts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${env.SSH_KEY}"
|
||||
def minPct = Integer.parseInt(env.DISK_FREE_MIN_PCT)
|
||||
def warnPct = Integer.parseInt(env.DISK_FREE_WARN_PCT)
|
||||
|
||||
def freePct
|
||||
try {
|
||||
freePct = sh(
|
||||
script: "ssh ${sshOpts} root@${host} \"df -P / | awk 'NR==2 { print 100 - \\\$5+0 }'\"",
|
||||
returnStdout: true
|
||||
).trim() as int
|
||||
} catch (Exception e) {
|
||||
error("❌ [${role}] SSH 到 ${host} 失败:${e.message}")
|
||||
}
|
||||
|
||||
echo " ${role}@${host}: 根分区空闲 ${freePct}%"
|
||||
if (freePct < minPct) {
|
||||
error("❌ [${role}] ${host} 空闲仅 ${freePct}% < ${minPct}%,终止部署")
|
||||
} else if (freePct < warnPct) {
|
||||
echo "⚠️ [${role}] ${host} 空闲 ${freePct}% < ${warnPct}%,部署后建议清理"
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,6 @@ VITE_INJECT_APP_LOADING=true
|
||||
VITE_APP_DEFAULT_USERNAME=admin
|
||||
# 默认登录密码
|
||||
VITE_APP_DEFAULT_PASSWORD=admin123
|
||||
|
||||
# 物联运维平台前端地址(用于业务平台 -> IoT 平台 SSO 跳转)
|
||||
VITE_IOT_BASE_URL=http://127.0.0.1:5667
|
||||
|
||||
@@ -21,3 +21,6 @@ VITE_INJECT_APP_LOADING=true
|
||||
|
||||
# 打包后是否生成dist.zip
|
||||
VITE_ARCHIVER=true
|
||||
|
||||
# 物联运维平台前端地址(SSO 跳转目标),生产部署时按实际域名修改
|
||||
VITE_IOT_BASE_URL=https://iot.example.com
|
||||
|
||||
3
apps/web-antd/.gitignore
vendored
Normal file
3
apps/web-antd/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# 由 vite.config.mts 的 copyLottiePlayer 插件在 dev/build 时自动从
|
||||
# node_modules/lottie-web 拷贝,不需要进版本控制
|
||||
public/lottie_light.min.js
|
||||
89
apps/web-antd/loading.html
Normal file
89
apps/web-antd/loading.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<style data-app-loading="inject-css">
|
||||
html {
|
||||
/* same as ant-design-vue/dist/reset.css setting */
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: #f4f7f9;
|
||||
}
|
||||
|
||||
.loading.hidden {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: all 0.8s ease-out;
|
||||
}
|
||||
|
||||
.dark .loading {
|
||||
background: #0d0d10;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: 24px;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
Arial, sans-serif !important;
|
||||
font-size: 24px;
|
||||
font-weight: 600 !important;
|
||||
color: rgb(0 0 0 / 85%) !important;
|
||||
}
|
||||
|
||||
.dark .title {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* stylelint-disable-next-line selector-id-pattern -- 与 inject-app-loading 的 #__app-loading__ 保持相同命名风格 */
|
||||
#__app-loading-lottie__ {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
}
|
||||
</style>
|
||||
<div class="loading" id="__app-loading__">
|
||||
<div id="__app-loading-lottie__"></div>
|
||||
<div class="title"><%= VITE_APP_TITLE %></div>
|
||||
</div>
|
||||
<!--
|
||||
换色逻辑与 src/components/lottie-loading/LottieLoading.vue 共用同一份
|
||||
classic-JS 源(window.__LottieThemePatch__),避免两处硬编码漂移。
|
||||
-->
|
||||
<script src="/lottie-theme-patch.js"></script>
|
||||
<script src="/lottie_light.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var tp = window.__LottieThemePatch__;
|
||||
var container = document.getElementById('__app-loading-lottie__');
|
||||
if (!tp || !container || typeof lottie === 'undefined') return;
|
||||
fetch('/loading.json')
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
var hsl = tp.readPrimaryHslFromLocalStorage(
|
||||
'<%= VITE_APP_NAMESPACE %>-',
|
||||
);
|
||||
tp.patchColors(data, tp.buildShades(hsl));
|
||||
lottie.loadAnimation({
|
||||
container: container,
|
||||
renderer: 'svg',
|
||||
loop: true,
|
||||
autoplay: true,
|
||||
animationData: data,
|
||||
});
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.error('[app-loading] lottie error', e);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -59,6 +59,7 @@
|
||||
"fast-xml-parser": "catalog:",
|
||||
"highlight.js": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"lottie-web": "^5.13.0",
|
||||
"pinia": "catalog:",
|
||||
"qrcode": "catalog:",
|
||||
"steady-xml": "catalog:",
|
||||
|
||||
1
apps/web-antd/public/loading.json
Normal file
1
apps/web-antd/public/loading.json
Normal file
File diff suppressed because one or more lines are too long
151
apps/web-antd/public/lottie-theme-patch.js
Normal file
151
apps/web-antd/public/lottie-theme-patch.js
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Lottie 主题色换色共享模块(单一真源)
|
||||
*
|
||||
* 同时供两处使用:
|
||||
* 1. loading.html —— Vue 挂载前,用 localStorage 里缓存的 preferences 读主题色
|
||||
* 2. LottieLoading.vue —— 组件运行时,直接读 CSS 变量 --primary
|
||||
*
|
||||
* JSON 里的动画原本是 5 档硬编码绿色,按此映射替换为主题色的 5 档亮度。
|
||||
* 本文件不进打包,由 <script src="/lottie-theme-patch.js"> 引入,
|
||||
* 会在 window 上挂 __LottieThemePatch__。
|
||||
*/
|
||||
(function (global) {
|
||||
/** JSON 中硬编码的 5 档绿色(取 2 位小数指纹) -> 5 档亮度索引 */
|
||||
var SHADE_MAP = {
|
||||
'0.62,0.92,0.49': 0, // 最浅
|
||||
'0.44,0.74,0.08': 1,
|
||||
'0.30,0.62,0.04': 2,
|
||||
'0.18,0.47,0.02': 3,
|
||||
'0.14,0.39,0.02': 4, // 最深
|
||||
};
|
||||
|
||||
/** 5 档亮度(从浅到深),覆盖主流主题色 */
|
||||
var LIGHTNESSES = [72, 58, 48, 36, 26];
|
||||
|
||||
var FALLBACK_HSL = { h: 37, s: 100, l: 52 }; // 与 preferences/config.ts 默认色一致
|
||||
var HSL_PARSE_RE = /([\d.]+)(?:deg)?[\s,]+([\d.]+)%?[\s,]+([\d.]+)%?/i;
|
||||
var HSL_FN_RE = /hsl\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%?[\s,]+([\d.]+)%?\s*\)/i;
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
var hh = (((h % 360) + 360) % 360) / 360;
|
||||
var ss = s / 100;
|
||||
var ll = l / 100;
|
||||
if (ss === 0) return [ll, ll, ll];
|
||||
var q = ll < 0.5 ? ll * (1 + ss) : ll + ss - ll * ss;
|
||||
var p = 2 * ll - q;
|
||||
function f(t) {
|
||||
var tt = t;
|
||||
if (tt < 0) tt += 1;
|
||||
if (tt > 1) tt -= 1;
|
||||
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||
if (tt < 1 / 2) return q;
|
||||
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||
return p;
|
||||
}
|
||||
return [f(hh + 1 / 3), f(hh), f(hh - 1 / 3)];
|
||||
}
|
||||
|
||||
function rgbKey(arr) {
|
||||
if (!arr || arr.length < 3) return '';
|
||||
return arr[0].toFixed(2) + ',' + arr[1].toFixed(2) + ',' + arr[2].toFixed(2);
|
||||
}
|
||||
|
||||
function buildShades(hsl) {
|
||||
return LIGHTNESSES.map(function (l) {
|
||||
return hslToRgb(hsl.h, hsl.s, l);
|
||||
});
|
||||
}
|
||||
|
||||
/** 就地替换 Lottie JSON 中的填充色(静态与关键帧两种形态) */
|
||||
function patchColors(data, shades) {
|
||||
function swap(arr) {
|
||||
var idx = SHADE_MAP[rgbKey(arr)];
|
||||
if (idx === undefined) return null;
|
||||
var s = shades[idx];
|
||||
return [s[0], s[1], s[2], arr[3] == null ? 1 : arr[3]];
|
||||
}
|
||||
var layers = (data && data.layers) || [];
|
||||
for (var li = 0; li < layers.length; li++) {
|
||||
var shapes = layers[li].shapes || [];
|
||||
for (var si = 0; si < shapes.length; si++) {
|
||||
var its = shapes[si].it;
|
||||
if (!its) continue;
|
||||
for (var ii = 0; ii < its.length; ii++) {
|
||||
var item = its[ii];
|
||||
if (item.ty !== 'fl' || !item.c) continue;
|
||||
var k = item.c.k;
|
||||
if (Array.isArray(k) && typeof k[0] === 'number') {
|
||||
var v = swap(k);
|
||||
if (v) item.c.k = v;
|
||||
} else if (Array.isArray(k)) {
|
||||
for (var ki = 0; ki < k.length; ki++) {
|
||||
var kf = k[ki];
|
||||
if (kf && kf.s && typeof kf.s[0] === 'number') {
|
||||
var v2 = swap(kf.s);
|
||||
if (v2) kf.s = v2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue 挂载前:扫 localStorage 里 preferences 缓存读 colorPrimary
|
||||
* namespacePrefix 用于把扫描范围限定在当前 app 的 key 上,避免误命中其它应用
|
||||
*/
|
||||
function readPrimaryHslFromLocalStorage(namespacePrefix) {
|
||||
try {
|
||||
for (var i = 0; i < localStorage.length; i++) {
|
||||
var k = localStorage.key(i);
|
||||
if (!k) continue;
|
||||
if (namespacePrefix && k.indexOf(namespacePrefix) !== 0) continue;
|
||||
if (!k.endsWith('-preferences')) continue;
|
||||
if (k.endsWith('-preferences-theme')) continue;
|
||||
if (k.endsWith('-preferences-locale')) continue;
|
||||
var raw = localStorage.getItem(k);
|
||||
if (!raw) continue;
|
||||
var parsed = JSON.parse(raw);
|
||||
var color =
|
||||
parsed &&
|
||||
parsed.value &&
|
||||
parsed.value.theme &&
|
||||
parsed.value.theme.colorPrimary;
|
||||
if (!color) continue;
|
||||
var m = HSL_FN_RE.exec(color);
|
||||
if (!m) continue;
|
||||
return { h: +m[1], s: +m[2], l: +m[3] };
|
||||
}
|
||||
} catch (e) {
|
||||
/* fall through to fallback */
|
||||
}
|
||||
return FALLBACK_HSL;
|
||||
}
|
||||
|
||||
/** Vue 运行时:读 CSS 变量 --primary(格式 "H S% L%") */
|
||||
function readPrimaryHslFromCss() {
|
||||
try {
|
||||
var raw = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--primary')
|
||||
.trim();
|
||||
if (!raw) return FALLBACK_HSL;
|
||||
var m = HSL_PARSE_RE.exec(raw);
|
||||
return m ? { h: +m[1], s: +m[2], l: +m[3] } : FALLBACK_HSL;
|
||||
} catch (e) {
|
||||
return FALLBACK_HSL;
|
||||
}
|
||||
}
|
||||
|
||||
global.__LottieThemePatch__ = {
|
||||
SHADE_MAP: SHADE_MAP,
|
||||
LIGHTNESSES: LIGHTNESSES,
|
||||
hslToRgb: hslToRgb,
|
||||
rgbKey: rgbKey,
|
||||
buildShades: buildShades,
|
||||
patchColors: patchColors,
|
||||
readPrimaryHslFromLocalStorage: readPrimaryHslFromLocalStorage,
|
||||
readPrimaryHslFromCss: readPrimaryHslFromCss,
|
||||
};
|
||||
})(window);
|
||||
51
apps/web-antd/src/api/core/sso.ts
Normal file
51
apps/web-antd/src/api/core/sso.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* SSO 单点登录相关接口(对接 /system/sso/callback)
|
||||
* 用于从其他子系统(如 IoT 运维平台)跳转回来时,用 code 换 access_token
|
||||
*/
|
||||
export namespace SsoApi {
|
||||
/** 后端 OAuth2 标准响应(snake_case),仅在 API 层内部使用 */
|
||||
export interface SsoCallbackRawResult {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
}
|
||||
/** 前端统一使用 camelCase */
|
||||
export interface SsoCallbackResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
tokenType: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 SSO code 换 access_token。
|
||||
* client_secret 不在前端,由后端从配置读取。
|
||||
*
|
||||
* @param clientId 当前系统的 client_id(业务平台 = 'biz',物联运维 = 'iot')
|
||||
* @param code 授权码(从 URL 读取)
|
||||
* @param redirectUri 回调地址(必须与发起授权时一致,且需在 OAuth2 客户端的 redirectUris 白名单内)
|
||||
* @param state 发起授权时的随机串,从 URL 读出原样回传(后端校验一致性 + CSRF 防护)
|
||||
*/
|
||||
export async function ssoCallback(
|
||||
clientId: string,
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
state?: string,
|
||||
): Promise<SsoApi.SsoCallbackResult> {
|
||||
// 走 body 而非 query:避免 code 落入浏览器历史 / nginx access log。
|
||||
// 后端对应 @RequestBody SsoCallbackReqVO。
|
||||
const raw = await requestClient.post<SsoApi.SsoCallbackRawResult>(
|
||||
'/system/sso/callback',
|
||||
{ clientId, code, redirectUri, state },
|
||||
);
|
||||
return {
|
||||
accessToken: raw.access_token,
|
||||
refreshToken: raw.refresh_token,
|
||||
expiresIn: raw.expires_in,
|
||||
tokenType: raw.token_type,
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,13 @@ const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
const tenantEnable = isTenantEnable();
|
||||
const apiEncrypt = createApiEncrypt(import.meta.env);
|
||||
|
||||
/**
|
||||
* 当前前端对应的 OAuth2 客户端编号。
|
||||
* 业务平台 = 'biz',物联运维平台 = 'iot'。后端按此过滤菜单(platform 列)。
|
||||
* 改动需同步:menu 表 platform 选项、sso-callback 页面 ssoCallback(clientId) 入参。
|
||||
*/
|
||||
export const CLIENT_ID = 'biz' as const;
|
||||
|
||||
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
const client = new RequestClient({
|
||||
...options,
|
||||
@@ -86,6 +93,10 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
config.headers['visit-tenant-id'] = tenantEnable
|
||||
? accessStore.visitTenantId
|
||||
: undefined;
|
||||
// 添加项目编号
|
||||
config.headers['project-id'] = accessStore.projectId;
|
||||
// 声明前端身份:密码登录 / refresh-token 路径后端用此值绑定 token,供按 platform 过滤菜单
|
||||
config.headers['X-Client-Id'] = CLIENT_ID;
|
||||
|
||||
// 是否 API 加密
|
||||
if ((config.headers || {}).isEncrypt) {
|
||||
@@ -182,6 +193,10 @@ baseRequestClient.addRequestInterceptor({
|
||||
config.headers['visit-tenant-id'] = tenantEnable
|
||||
? accessStore.visitTenantId
|
||||
: undefined;
|
||||
// 添加项目编号
|
||||
config.headers['project-id'] = accessStore.projectId;
|
||||
// 声明前端身份(同上)
|
||||
config.headers['X-Client-Id'] = CLIENT_ID;
|
||||
return config;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ export namespace SystemMenuApi {
|
||||
visible: boolean;
|
||||
keepAlive: boolean;
|
||||
alwaysShow?: boolean;
|
||||
platform?: string;
|
||||
createTime: Date;
|
||||
}
|
||||
}
|
||||
|
||||
56
apps/web-antd/src/api/system/project/index.ts
Normal file
56
apps/web-antd/src/api/system/project/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace SystemProjectApi {
|
||||
export interface Project {
|
||||
id?: number;
|
||||
name: string;
|
||||
code: string;
|
||||
status: number;
|
||||
contactName?: string;
|
||||
contactMobile?: string;
|
||||
address?: string;
|
||||
remark?: string;
|
||||
createTime?: Date;
|
||||
}
|
||||
}
|
||||
|
||||
export function getProjectPage(params: PageParam) {
|
||||
return requestClient.get<PageResult<SystemProjectApi.Project>>(
|
||||
'/system/project/page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取当前登录用户授权的项目精简列表(顶栏项目切换器用) */
|
||||
export function getSimpleProjectList() {
|
||||
return requestClient.get<SystemProjectApi.Project[]>(
|
||||
'/system/project/simple-list',
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取本租户全部启用项目(管理员分配场景下拉数据源) */
|
||||
export function getAllProjectSimpleList() {
|
||||
return requestClient.get<SystemProjectApi.Project[]>(
|
||||
'/system/project/all-simple-list',
|
||||
);
|
||||
}
|
||||
|
||||
export function getProject(id: number) {
|
||||
return requestClient.get<SystemProjectApi.Project>(
|
||||
`/system/project/get?id=${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function createProject(data: SystemProjectApi.Project) {
|
||||
return requestClient.post('/system/project/create', data);
|
||||
}
|
||||
|
||||
export function updateProject(data: SystemProjectApi.Project) {
|
||||
return requestClient.put('/system/project/update', data);
|
||||
}
|
||||
|
||||
export function deleteProject(id: number) {
|
||||
return requestClient.delete(`/system/project/delete?id=${id}`);
|
||||
}
|
||||
83
apps/web-antd/src/api/system/user-project/index.ts
Normal file
83
apps/web-antd/src/api/system/user-project/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { PageParam, PageResult } from '@vben/request';
|
||||
|
||||
import type { SystemUserApi } from '#/api/system/user';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace SystemUserProjectApi {
|
||||
export interface AssignUserProjectsReq {
|
||||
userId: number;
|
||||
projectIds: number[];
|
||||
}
|
||||
export interface AssignProjectUsersReq {
|
||||
projectId: number;
|
||||
userIds: number[];
|
||||
}
|
||||
export interface AddProjectUsersReq {
|
||||
projectId: number;
|
||||
userIds: number[];
|
||||
}
|
||||
export interface ProjectUserPageReq extends PageParam {
|
||||
projectId: number;
|
||||
keyword?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/** 给用户覆盖式分配项目 */
|
||||
export function assignUserProjects(
|
||||
data: SystemUserProjectApi.AssignUserProjectsReq,
|
||||
) {
|
||||
return requestClient.post<boolean>(
|
||||
'/system/user-project/assign-user-projects',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 给项目覆盖式分配成员 */
|
||||
export function assignProjectUsers(
|
||||
data: SystemUserProjectApi.AssignProjectUsersReq,
|
||||
) {
|
||||
return requestClient.post<boolean>(
|
||||
'/system/user-project/assign-project-users',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询某用户已绑定的项目编号集合 */
|
||||
export function getProjectIdsByUserId(userId: number) {
|
||||
return requestClient.get<number[]>(
|
||||
`/system/user-project/list-project-ids-by-user?userId=${userId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询某项目下绑定的用户编号集合 */
|
||||
export function getUserIdsByProjectId(projectId: number) {
|
||||
return requestClient.get<number[]>(
|
||||
`/system/user-project/list-user-ids-by-project?projectId=${projectId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 分页查询项目成员(已自动过滤超级管理员) */
|
||||
export function getProjectUserPage(
|
||||
params: SystemUserProjectApi.ProjectUserPageReq,
|
||||
) {
|
||||
return requestClient.get<PageResult<SystemUserApi.User>>(
|
||||
'/system/user-project/project-user-page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/** 增量给项目添加成员 */
|
||||
export function addProjectUsers(data: SystemUserProjectApi.AddProjectUsersReq) {
|
||||
return requestClient.post<boolean>(
|
||||
'/system/user-project/add-project-users',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 从项目中移除单个成员 */
|
||||
export function removeProjectUser(projectId: number, userId: number) {
|
||||
return requestClient.delete<boolean>(
|
||||
`/system/user-project/remove-project-user?projectId=${projectId}&userId=${userId}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AnimationItem } from 'lottie-web';
|
||||
|
||||
import type { LottieJsonShape } from './globals';
|
||||
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/** 容器边长,数字按 px 处理,其他字符串原样作为 CSS 长度 */
|
||||
size?: number | string;
|
||||
/** Lottie JSON 的 URL,默认读取 public/loading.json(与 app 全局 loading 共用) */
|
||||
src?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 200,
|
||||
src: '/loading.json',
|
||||
});
|
||||
|
||||
const container = ref<HTMLElement>();
|
||||
let instance: AnimationItem | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
// 卸载优先注册:组件在 fetch 中途被卸载时也能安全收尾
|
||||
onUnmounted(() => {
|
||||
cancelled = true;
|
||||
instance?.destroy();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (!container.value) return;
|
||||
// lottie 和换色模块由 loading.html 通过 classic <script> 注入到 window 上,
|
||||
// 这里不再重复打包,避免 ~170KB 重复下发
|
||||
const tp = window.__LottieThemePatch__;
|
||||
const lottie = window.lottie;
|
||||
if (!tp || !lottie) {
|
||||
console.warn('[LottieLoading] window.__LottieThemePatch__ / lottie 缺失');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(props.src);
|
||||
if (!res.ok) throw new Error(`fetch ${props.src} -> ${res.status}`);
|
||||
const data = (await res.json()) as LottieJsonShape;
|
||||
if (cancelled || !container.value) return;
|
||||
tp.patchColors(data, tp.buildShades(tp.readPrimaryHslFromCss()));
|
||||
instance = lottie.loadAnimation({
|
||||
animationData: data,
|
||||
autoplay: true,
|
||||
container: container.value,
|
||||
loop: true,
|
||||
renderer: 'svg',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[LottieLoading]', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
:style="{
|
||||
width: typeof size === 'number' ? `${size}px` : size,
|
||||
height: typeof size === 'number' ? `${size}px` : size,
|
||||
}"
|
||||
></div>
|
||||
</template>
|
||||
41
apps/web-antd/src/components/lottie-loading/globals.ts
Normal file
41
apps/web-antd/src/components/lottie-loading/globals.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { AnimationConfigWithData, AnimationItem } from 'lottie-web';
|
||||
|
||||
export interface LottieHsl {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
}
|
||||
|
||||
/** 形状极简的 Lottie JSON 结构,仅覆盖 patchColors 真正访问的字段 */
|
||||
export interface LottieJsonShape {
|
||||
layers?: Array<{
|
||||
shapes?: Array<{
|
||||
it?: Array<{
|
||||
c?: {
|
||||
k?: Array<{ s?: number[] }> | number[];
|
||||
};
|
||||
ty?: string;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LottieThemePatch {
|
||||
SHADE_MAP: Record<string, number>;
|
||||
LIGHTNESSES: number[];
|
||||
hslToRgb(h: number, s: number, l: number): [number, number, number];
|
||||
rgbKey(arr: number[]): string;
|
||||
buildShades(hsl: LottieHsl): number[][];
|
||||
patchColors<T extends LottieJsonShape>(data: T, shades: number[][]): T;
|
||||
readPrimaryHslFromLocalStorage(namespacePrefix?: string): LottieHsl;
|
||||
readPrimaryHslFromCss(): LottieHsl;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__LottieThemePatch__?: LottieThemePatch;
|
||||
lottie?: {
|
||||
loadAnimation(config: AnimationConfigWithData): AnimationItem;
|
||||
};
|
||||
}
|
||||
}
|
||||
1
apps/web-antd/src/components/lottie-loading/index.ts
Normal file
1
apps/web-antd/src/components/lottie-loading/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as LottieLoading } from './LottieLoading.vue';
|
||||
@@ -1,20 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
import type { NotificationItem } from '@vben/layouts';
|
||||
|
||||
import type { SystemTenantApi } from '#/api/system/tenant';
|
||||
import type { SystemProjectApi } from '#/api/system/project';
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { AuthenticationLoginExpiredModal, useVbenModal } from '@vben/common-ui';
|
||||
import { isTenantEnable, useTabs, useWatermark } from '@vben/hooks';
|
||||
import { useTabs, useWatermark } from '@vben/hooks';
|
||||
import { AntdProfileOutlined, CircleHelp } from '@vben/icons';
|
||||
import {
|
||||
BasicLayout,
|
||||
Help,
|
||||
LockScreen,
|
||||
Notification,
|
||||
TenantDropdown,
|
||||
ProjectDropdown,
|
||||
UserDropdown,
|
||||
} from '@vben/layouts';
|
||||
import { preferences } from '@vben/preferences';
|
||||
@@ -29,7 +28,8 @@ import {
|
||||
updateAllNotifyMessageRead,
|
||||
updateNotifyMessageRead,
|
||||
} from '#/api/system/notify/message';
|
||||
import { getSimpleTenantList } from '#/api/system/tenant';
|
||||
import { authorize } from '#/api/system/oauth2/open';
|
||||
import { getSimpleProjectList } from '#/api/system/project';
|
||||
import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
import { useAuthStore } from '#/store';
|
||||
@@ -38,7 +38,6 @@ import LoginForm from '#/views/_core/authentication/login.vue';
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const accessStore = useAccessStore();
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const { destroyWatermark, updateWatermark } = useWatermark();
|
||||
const { closeOtherTabs, refreshTab } = useTabs();
|
||||
|
||||
@@ -149,33 +148,83 @@ function handleNotificationOpen(open: boolean) {
|
||||
handleNotificationGetUnreadCount();
|
||||
}
|
||||
|
||||
// 租户列表
|
||||
const tenants = ref<SystemTenantApi.Tenant[]>([]);
|
||||
const tenantEnable = computed(
|
||||
() => hasAccessByCodes(['system:tenant:visit']) && isTenantEnable(),
|
||||
);
|
||||
// 项目列表
|
||||
const projects = ref<SystemProjectApi.Project[]>([]);
|
||||
|
||||
/** 获取租户列表 */
|
||||
async function handleGetTenantList() {
|
||||
if (tenantEnable.value) {
|
||||
tenants.value = await getSimpleTenantList();
|
||||
/** 获取项目列表 */
|
||||
async function handleGetProjectList() {
|
||||
projects.value = await getSimpleProjectList();
|
||||
// 仅在当前 projectId 不在返回列表中(未选 / 已删除 / 无权限)时,回退到第一个
|
||||
const currentId = accessStore.projectId;
|
||||
const currentInList =
|
||||
currentId !== null && projects.value.some((p) => p.id === currentId);
|
||||
if (!currentInList) {
|
||||
const firstId = projects.value[0]?.id ?? null;
|
||||
accessStore.setProjectId(firstId as null | number);
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理租户切换 */
|
||||
async function handleTenantChange(tenant: SystemTenantApi.Tenant) {
|
||||
if (!tenant || !tenant.id) {
|
||||
message.error('切换租户失败');
|
||||
/** 跳转到物联运维平台(OAuth2 授权码无感跳转) */
|
||||
const iotLoading = ref(false);
|
||||
// env 未配置时按钮直接不渲染,避免把用户带到无效域名
|
||||
const iotBaseUrl = import.meta.env.VITE_IOT_BASE_URL as string | undefined;
|
||||
/** 生成加密安全的 state 值用于 CSRF 防护 */
|
||||
function generateState(): string {
|
||||
if (
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.randomUUID === 'function'
|
||||
) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// 老浏览器兜底:仍使用 getRandomValues,强于 Math.random
|
||||
const buf = new Uint8Array(16);
|
||||
crypto.getRandomValues(buf);
|
||||
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
async function handleGoToIot() {
|
||||
if (iotLoading.value || !iotBaseUrl) return;
|
||||
iotLoading.value = true;
|
||||
try {
|
||||
// 注意:redirectUri 必须与 OAuth2 客户端 redirectUris 白名单完全一致,否则后端拒发 code
|
||||
const redirectUri = `${iotBaseUrl}/sso-callback`;
|
||||
const state = generateState();
|
||||
const redirectUrl = await authorize(
|
||||
'code',
|
||||
'iot', // 物联运维平台的 OAuth2 客户端编号
|
||||
redirectUri,
|
||||
state,
|
||||
true, // auto_approve 跳过授权确认页
|
||||
['user.read', 'user.write'],
|
||||
[],
|
||||
);
|
||||
if (!redirectUrl) {
|
||||
message.error('获取授权失败,请重试');
|
||||
return;
|
||||
}
|
||||
window.location.href = redirectUrl;
|
||||
} catch (error: any) {
|
||||
message.error(error?.message ?? '跳转物联运维平台失败');
|
||||
} finally {
|
||||
iotLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理项目切换 */
|
||||
async function handleProjectChange(project: SystemProjectApi.Project) {
|
||||
if (!project || !project.id) {
|
||||
message.error('切换项目失败');
|
||||
return;
|
||||
}
|
||||
// 设置访问租户 ID
|
||||
accessStore.setVisitTenantId(tenant.id as number);
|
||||
if (project.id === accessStore.projectId) {
|
||||
return;
|
||||
}
|
||||
// 设置当前项目 ID(axios 拦截器会自动带 project-id 请求头)
|
||||
accessStore.setProjectId(project.id as number);
|
||||
// 关闭其他标签页,只保留当前页
|
||||
await closeOtherTabs();
|
||||
// 刷新当前页面
|
||||
// 刷新当前页面,重新拉数据
|
||||
await refreshTab();
|
||||
// 提示切换成功
|
||||
message.success(`切换当前租户为: ${tenant.name}`);
|
||||
message.success(`切换当前项目为: ${project.name}`);
|
||||
}
|
||||
|
||||
// ========== 初始化 ==========
|
||||
@@ -184,8 +233,8 @@ let notifyTimer: null | ReturnType<typeof setInterval> = null;
|
||||
onMounted(() => {
|
||||
// 首次加载未读数量
|
||||
handleNotificationGetUnreadCount();
|
||||
// 获取租户列表
|
||||
handleGetTenantList();
|
||||
// 获取项目列表
|
||||
handleGetProjectList();
|
||||
// 轮询刷新未读数量
|
||||
notifyTimer = setInterval(
|
||||
() => {
|
||||
@@ -250,14 +299,27 @@ watch(
|
||||
/>
|
||||
</template>
|
||||
<template #header-right-1>
|
||||
<div v-if="tenantEnable">
|
||||
<TenantDropdown
|
||||
class="mr-2"
|
||||
:tenant-list="tenants"
|
||||
:visit-tenant-id="accessStore.visitTenantId"
|
||||
@success="handleTenantChange"
|
||||
/>
|
||||
</div>
|
||||
<ProjectDropdown
|
||||
v-if="projects.length > 0"
|
||||
class="mr-2"
|
||||
:project-list="projects"
|
||||
:visit-project-id="accessStore.projectId"
|
||||
@success="handleProjectChange"
|
||||
/>
|
||||
</template>
|
||||
<template #header-right-2>
|
||||
<button
|
||||
v-if="iotBaseUrl"
|
||||
type="button"
|
||||
class="ml-1 mr-2 flex h-8 cursor-pointer items-center gap-1.5 rounded-full bg-[#1677FF] px-4 text-sm font-medium text-white shadow-sm transition-all hover:bg-[#4096FF] hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="iotLoading"
|
||||
title="进入物联运维平台"
|
||||
@click="handleGoToIot"
|
||||
>
|
||||
<span class="i-lucide:cpu text-base"></span>
|
||||
<span>物联运维</span>
|
||||
<span class="i-lucide:arrow-up-right text-xs opacity-80"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template #extra>
|
||||
<AuthenticationLoginExpiredModal
|
||||
|
||||
@@ -109,6 +109,18 @@ const coreRoutes: RouteRecordRaw[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SsoCallback',
|
||||
path: '/sso-callback',
|
||||
component: () => import('#/views/_core/authentication/sso-callback.vue'),
|
||||
meta: {
|
||||
hideInBreadcrumb: true,
|
||||
hideInMenu: true,
|
||||
hideInTab: true,
|
||||
ignoreAccess: true,
|
||||
title: 'SSO 登录中',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export { coreRoutes, fallbackNotFoundRoute };
|
||||
|
||||
@@ -29,6 +29,42 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
const loginLoading = ref(false);
|
||||
|
||||
/**
|
||||
* token 写入成功后的共用流程:拉用户信息、跳首页、弹欢迎提示。
|
||||
* authLogin / ssoLogin 共用以避免逻辑漂移。
|
||||
*/
|
||||
async function postAuthSuccess(
|
||||
onSuccess?: () => Promise<void> | void,
|
||||
): Promise<null | UserInfo> {
|
||||
// 登录成功后,如果未设置访问租户,默认使用登录时选择的租户
|
||||
if (accessStore.tenantId && !accessStore.visitTenantId) {
|
||||
accessStore.setVisitTenantId(accessStore.tenantId);
|
||||
}
|
||||
|
||||
// TODO @芋艿:清理掉 accessCodes 相关的逻辑
|
||||
const fetchUserInfoResult = await fetchUserInfo();
|
||||
const userInfo = fetchUserInfoResult.user;
|
||||
|
||||
if (accessStore.loginExpired) {
|
||||
accessStore.setLoginExpired(false);
|
||||
} else {
|
||||
onSuccess
|
||||
? await onSuccess?.()
|
||||
: await router.push(
|
||||
userInfo?.homePath || preferences.app.defaultHomePath,
|
||||
);
|
||||
}
|
||||
|
||||
if (userInfo?.nickname) {
|
||||
notification.success({
|
||||
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nickname}`,
|
||||
duration: 3,
|
||||
message: $t('authentication.loginSuccess'),
|
||||
});
|
||||
}
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步处理登录操作
|
||||
* Asynchronously handle the login process
|
||||
@@ -41,7 +77,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
params: Recordable<any>,
|
||||
onSuccess?: () => Promise<void> | void,
|
||||
) {
|
||||
// 异步处理用户登录操作并获取 accessToken
|
||||
let userInfo: null | UserInfo = null;
|
||||
try {
|
||||
let loginResult: AuthApi.LoginResult;
|
||||
@@ -64,52 +99,48 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
const { accessToken, refreshToken } = loginResult;
|
||||
|
||||
// 如果成功获取到 accessToken
|
||||
if (accessToken) {
|
||||
accessStore.setAccessToken(accessToken);
|
||||
accessStore.setRefreshToken(refreshToken);
|
||||
|
||||
// 登录成功后,如果未设置访问租户,默认使用登录时选择的租户
|
||||
if (accessStore.tenantId && !accessStore.visitTenantId) {
|
||||
accessStore.setVisitTenantId(accessStore.tenantId);
|
||||
}
|
||||
|
||||
// 获取用户信息并存储到 userStore、accessStore 中
|
||||
// TODO @芋艿:清理掉 accessCodes 相关的逻辑
|
||||
// const [fetchUserInfoResult, accessCodes] = await Promise.all([
|
||||
// fetchUserInfo(),
|
||||
// // getAccessCodesApi(),
|
||||
// ]);
|
||||
const fetchUserInfoResult = await fetchUserInfo();
|
||||
|
||||
userInfo = fetchUserInfoResult.user;
|
||||
|
||||
if (accessStore.loginExpired) {
|
||||
accessStore.setLoginExpired(false);
|
||||
} else {
|
||||
onSuccess
|
||||
? await onSuccess?.()
|
||||
: await router.push(
|
||||
userInfo.homePath || preferences.app.defaultHomePath,
|
||||
);
|
||||
}
|
||||
|
||||
if (userInfo?.nickname) {
|
||||
notification.success({
|
||||
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nickname}`,
|
||||
duration: 3,
|
||||
message: $t('authentication.loginSuccess'),
|
||||
});
|
||||
}
|
||||
if (!accessToken) {
|
||||
return { userInfo: null };
|
||||
}
|
||||
accessStore.setAccessToken(accessToken);
|
||||
accessStore.setRefreshToken(refreshToken);
|
||||
userInfo = await postAuthSuccess(onSuccess);
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
userInfo,
|
||||
};
|
||||
return { userInfo };
|
||||
}
|
||||
|
||||
/**
|
||||
* SSO 登录:从其他平台跳转过来时,已经拿到 access_token,跳过密码校验。
|
||||
* 失败(token 空、后续拉用户信息异常)会回滚 token,避免留下空 token 导致刷新循环。
|
||||
*/
|
||||
async function ssoLogin(
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
onSuccess?: () => Promise<void> | void,
|
||||
) {
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw new Error('SSO 登录失败:缺少 accessToken / refreshToken');
|
||||
}
|
||||
let userInfo: null | UserInfo = null;
|
||||
try {
|
||||
loginLoading.value = true;
|
||||
accessStore.setAccessToken(accessToken);
|
||||
accessStore.setRefreshToken(refreshToken);
|
||||
try {
|
||||
userInfo = await postAuthSuccess(onSuccess);
|
||||
} catch (error) {
|
||||
// 回滚 token,避免 Bearer null/空 token 陷入 401 循环
|
||||
accessStore.setAccessToken(null);
|
||||
accessStore.setRefreshToken(null);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
return { userInfo };
|
||||
}
|
||||
|
||||
async function logout(redirect: boolean = true) {
|
||||
@@ -155,6 +186,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return {
|
||||
$reset,
|
||||
authLogin,
|
||||
ssoLogin,
|
||||
fetchUserInfo,
|
||||
loginLoading,
|
||||
logout,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { LOGIN_PATH } from '@vben/constants';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ssoCallback } from '#/api/core/sso';
|
||||
import { CLIENT_ID } from '#/api/request';
|
||||
import { LottieLoading } from '#/components/lottie-loading';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
/**
|
||||
* SSO 回调页
|
||||
*
|
||||
* 场景:从其他子系统(如 IoT 运维平台)跳转回来时,URL 会带上 code 参数:
|
||||
* biz.xxx.com/sso-callback?code=xxx&state=yyy
|
||||
*
|
||||
* 约束:window.location.origin + '/sso-callback' 必须与发起授权时传的 redirect_uri 完全一致,
|
||||
* 且已登记在后端 OAuth2 客户端的 redirectUris 白名单中,否则 yudao 会拒发 / 换 token。
|
||||
*/
|
||||
defineOptions({ name: 'SsoCallback' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const errorMsg = ref<string>('');
|
||||
|
||||
onMounted(async () => {
|
||||
const code = route.query.code as string | undefined;
|
||||
// state 必须从 URL 原样回传,否则后端会抛 OAUTH2_GRANT_STATE_MISMATCH
|
||||
const state = (route.query.state as string) || undefined;
|
||||
// 立即清空 URL 查询参数,避免 code 出现在浏览器历史、后退时二次 exchange
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
|
||||
if (!code) {
|
||||
errorMsg.value = 'SSO 登录失败:缺少授权码';
|
||||
message.error(errorMsg.value);
|
||||
await router.replace(LOGIN_PATH);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const redirectUri = `${window.location.origin}/sso-callback`;
|
||||
const tokenResp = await ssoCallback(CLIENT_ID, code, redirectUri, state);
|
||||
// 用拿到的 token 完成登录(会拉用户信息、菜单并跳首页)
|
||||
await authStore.ssoLogin(tokenResp.accessToken, tokenResp.refreshToken);
|
||||
} catch (error: any) {
|
||||
errorMsg.value = error?.message ?? 'SSO 登录失败,请重新登录';
|
||||
message.error(errorMsg.value);
|
||||
await router.replace(LOGIN_PATH);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen items-center justify-center">
|
||||
<div v-if="errorMsg" class="text-base">{{ errorMsg }}</div>
|
||||
<LottieLoading v-else :size="220" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,6 +20,16 @@ import { getMenuList } from '#/api/system/menu';
|
||||
import { $t } from '#/locales';
|
||||
import { componentKeys } from '#/router/routes';
|
||||
|
||||
/**
|
||||
* 前端平台选项(对应 system_menu.platform 和 system_oauth2_client.platform)
|
||||
* null = 共享菜单;biz = 仅业务平台;iot = 仅物联运维平台
|
||||
* 新增前端时在此扩展。
|
||||
*/
|
||||
const PLATFORM_OPTIONS = [
|
||||
{ label: '业务平台 (biz)', value: 'biz' },
|
||||
{ label: '物联运维平台 (iot)', value: 'iot' },
|
||||
] as const;
|
||||
|
||||
/** 新增/修改的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
@@ -239,6 +249,17 @@ export function useFormSchema(): VbenFormSchema[] {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'platform',
|
||||
label: '所属平台',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
placeholder: '请选择所属平台(不选则两个平台都展示)',
|
||||
options: PLATFORM_OPTIONS,
|
||||
},
|
||||
help: '不选 = 共享菜单,两个前端都能看到;选择后只在对应前端显示',
|
||||
},
|
||||
{
|
||||
fieldName: 'keepAlive',
|
||||
label: '缓存状态',
|
||||
|
||||
263
apps/web-antd/src/views/system/project/data.ts
Normal file
263
apps/web-antd/src/views/system/project/data.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getSimpleUserList } from '#/api/system/user';
|
||||
|
||||
/** 新增/修改的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'id',
|
||||
component: 'Input',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '项目名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入项目名称',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'code',
|
||||
label: '项目编码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入项目编码',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '项目状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: z.number().default(CommonStatusEnum.ENABLE),
|
||||
},
|
||||
{
|
||||
fieldName: 'contactName',
|
||||
label: '联系人',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入联系人',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'contactMobile',
|
||||
label: '联系手机',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入联系手机',
|
||||
},
|
||||
rules: 'mobile',
|
||||
},
|
||||
{
|
||||
fieldName: 'address',
|
||||
label: '项目地址',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入项目地址',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'remark',
|
||||
label: '备注',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入备注',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 项目成员管理抽屉的表格列 */
|
||||
export function useProjectMemberGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'id',
|
||||
title: '用户编号',
|
||||
minWidth: 90,
|
||||
},
|
||||
{
|
||||
field: 'username',
|
||||
title: '用户名',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'nickname',
|
||||
title: '昵称',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'mobile',
|
||||
title: '手机号',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'deptId',
|
||||
title: '部门编号',
|
||||
minWidth: 90,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
minWidth: 90,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 管理成员的表单(项目 → 多用户)— 保留给未来"覆盖写入"场景,当前 UI 已改抽屉 */
|
||||
export function useAssignUserFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '项目名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'code',
|
||||
label: '项目编码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'userIds',
|
||||
label: '成员',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleUserList,
|
||||
labelField: 'nickname',
|
||||
valueField: 'id',
|
||||
mode: 'multiple',
|
||||
placeholder: '请选择成员(留空表示清空所有成员)',
|
||||
allowClear: true,
|
||||
showSearch: true,
|
||||
optionFilterProp: 'label',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '项目名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入项目名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'code',
|
||||
label: '项目编码',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入项目编码',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
placeholder: '请选择状态',
|
||||
allowClear: true,
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'id',
|
||||
title: '项目编号',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '项目名称',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: 'code',
|
||||
title: '项目编码',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: 'contactName',
|
||||
title: '联系人',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'contactMobile',
|
||||
title: '联系手机',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '项目状态',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 130,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
184
apps/web-antd/src/views/system/project/index.vue
Normal file
184
apps/web-antd/src/views/system/project/index.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SystemProjectApi } from '#/api/system/project';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { confirm, Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteProject, getProjectPage } from '#/api/system/project';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import AssignUserDrawer from './modules/assign-user-form.vue';
|
||||
import Form from './modules/form.vue';
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [MemberDrawer, memberDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: AssignUserDrawer,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function handleRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建项目 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑项目 */
|
||||
function handleEdit(row: SystemProjectApi.Project) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 管理成员 */
|
||||
function handleAssignUser(row: SystemProjectApi.Project) {
|
||||
memberDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除项目 */
|
||||
async function handleDelete(row: SystemProjectApi.Project) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteProject(row.id!);
|
||||
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量删除项目 */
|
||||
async function handleDeleteBatch() {
|
||||
await confirm($t('ui.actionMessage.deleteBatchConfirm'));
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deletingBatch'),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await Promise.all(checkedIds.value.map((id) => deleteProject(id)));
|
||||
checkedIds.value = [];
|
||||
message.success($t('ui.actionMessage.deleteSuccess'));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
const checkedIds = ref<number[]>([]);
|
||||
function handleRowCheckboxChange({
|
||||
records,
|
||||
}: {
|
||||
records: SystemProjectApi.Project[];
|
||||
}) {
|
||||
checkedIds.value = records.map((item) => item.id!);
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getProjectPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<SystemProjectApi.Project>,
|
||||
gridEvents: {
|
||||
checkboxAll: handleRowCheckboxChange,
|
||||
checkboxChange: handleRowCheckboxChange,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="handleRefresh" />
|
||||
<MemberDrawer @success="handleRefresh" />
|
||||
<Grid table-title="项目列表">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['项目']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['system:project:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
{
|
||||
label: $t('ui.actionTitle.deleteBatch'),
|
||||
type: 'primary',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
disabled: isEmpty(checkedIds),
|
||||
auth: ['system:project:delete'],
|
||||
onClick: handleDeleteBatch,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['system:project:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '管理成员',
|
||||
type: 'link',
|
||||
auth: ['system:project:assign-user'],
|
||||
onClick: handleAssignUser.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['system:project:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SystemUserApi } from '#/api/system/user';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message, Select, Spin } from 'ant-design-vue';
|
||||
|
||||
import { getSimpleUserList } from '#/api/system/user';
|
||||
import { addProjectUsers } from '#/api/system/user-project';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
interface AddUserModalData {
|
||||
projectId: number;
|
||||
projectName?: string;
|
||||
excludedUserIds: number[];
|
||||
}
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const allUsers = ref<SystemUserApi.User[]>([]);
|
||||
const selectedUserIds = ref<number[]>([]);
|
||||
const loading = ref(false);
|
||||
const currentData = ref<AddUserModalData | null>(null);
|
||||
|
||||
/** 可选项:全量用户 - 已是成员的用户 */
|
||||
const selectOptions = computed(() => {
|
||||
const excluded = new Set(currentData.value?.excludedUserIds);
|
||||
return allUsers.value
|
||||
.filter((u) => u.id && !excluded.has(u.id))
|
||||
.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${u.nickname || u.username}(${u.username})`,
|
||||
}));
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
if (!currentData.value?.projectId) return;
|
||||
if (selectedUserIds.value.length === 0) {
|
||||
message.warning('请至少选择一个用户');
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
await addProjectUsers({
|
||||
projectId: currentData.value.projectId,
|
||||
userIds: selectedUserIds.value,
|
||||
});
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
allUsers.value = [];
|
||||
selectedUserIds.value = [];
|
||||
currentData.value = null;
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<AddUserModalData>();
|
||||
if (!data) return;
|
||||
currentData.value = data;
|
||||
loading.value = true;
|
||||
try {
|
||||
allUsers.value = await getSimpleUserList();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:title="
|
||||
currentData?.projectName
|
||||
? `添加用户到项目 - ${currentData.projectName}`
|
||||
: '添加用户'
|
||||
"
|
||||
class="w-[560px]"
|
||||
>
|
||||
<Spin :spinning="loading">
|
||||
<div class="px-4 py-2">
|
||||
<div class="mb-2 text-sm">
|
||||
选择要加入项目的用户(已在项目中的用户会自动过滤)
|
||||
</div>
|
||||
<Select
|
||||
v-model:value="selectedUserIds"
|
||||
mode="multiple"
|
||||
placeholder="请选择用户"
|
||||
:options="selectOptions"
|
||||
:filter-option="
|
||||
(input: string, option: any) =>
|
||||
option.label.toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
show-search
|
||||
allow-clear
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SystemProjectApi } from '#/api/system/project';
|
||||
import type { SystemUserApi } from '#/api/system/user';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Input, message } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
getProjectUserPage,
|
||||
getUserIdsByProjectId,
|
||||
removeProjectUser,
|
||||
} from '#/api/system/user-project';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useProjectMemberGridColumns } from '../data';
|
||||
import AddUserModal from './add-user-modal.vue';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
/** 当前打开抽屉的项目 */
|
||||
const currentProject = ref<null | SystemProjectApi.Project>(null);
|
||||
/** 搜索关键字 */
|
||||
const keyword = ref<string>('');
|
||||
|
||||
/** 添加用户子弹窗 */
|
||||
const [AddUserModalCmp, addUserModalApi] = useVbenModal({
|
||||
connectedComponent: AddUserModal,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 移除单个成员 */
|
||||
async function handleRemove(row: SystemUserApi.User) {
|
||||
if (!currentProject.value?.id || !row.id) return;
|
||||
const hideLoading = message.loading({
|
||||
content: `正在从项目中移除 ${row.nickname || row.username}...`,
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await removeProjectUser(currentProject.value.id, row.id);
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
gridApi.query();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开添加用户弹窗 */
|
||||
async function handleOpenAddUser() {
|
||||
if (!currentProject.value?.id) return;
|
||||
// 把当前已是成员的 userIds 传给子 Modal 用于过滤
|
||||
const excludedIds = await getUserIdsByProjectId(currentProject.value.id);
|
||||
addUserModalApi
|
||||
.setData({
|
||||
projectId: currentProject.value.id,
|
||||
projectName: currentProject.value.name,
|
||||
excludedUserIds: excludedIds,
|
||||
})
|
||||
.open();
|
||||
}
|
||||
|
||||
/** 子弹窗保存后刷新表格 */
|
||||
function handleAddUserSuccess() {
|
||||
gridApi.query();
|
||||
emit('success');
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: useProjectMemberGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
pageSize: 10,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }) => {
|
||||
if (!currentProject.value?.id) {
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
return await getProjectUserPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
projectId: currentProject.value.id,
|
||||
keyword: keyword.value || undefined,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
},
|
||||
} as VxeTableGridOptions<SystemUserApi.User>,
|
||||
});
|
||||
|
||||
function handleSearch() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
currentProject.value = null;
|
||||
keyword.value = '';
|
||||
return;
|
||||
}
|
||||
const data = drawerApi.getData<SystemProjectApi.Project>();
|
||||
if (!data || !data.id) return;
|
||||
currentProject.value = data;
|
||||
// 等 currentProject 赋值后再 query
|
||||
gridApi.query();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
class="w-[800px]"
|
||||
:title="
|
||||
currentProject ? `项目成员管理 - ${currentProject.name}` : '项目成员管理'
|
||||
"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
>
|
||||
<AddUserModalCmp @success="handleAddUserSuccess" />
|
||||
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
placeholder="搜索用户名 / 昵称 / 手机号"
|
||||
allow-clear
|
||||
class="w-64"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
<Button type="primary" @click="handleSearch">搜索</Button>
|
||||
<Button type="primary" @click="handleOpenAddUser">
|
||||
<template #icon>
|
||||
<span class="iconify ant-design--plus-outlined"></span>
|
||||
</template>
|
||||
添加用户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Grid>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '移除',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['system:project:assign-user'],
|
||||
popConfirm: {
|
||||
title: `确认将 ${row.nickname || row.username} 从项目中移除?`,
|
||||
confirm: handleRemove.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<div class="mt-3 text-xs text-gray-500">
|
||||
提示:超级管理员通过角色天然拥有所有项目权限,不会出现在此列表中。
|
||||
</div>
|
||||
</Drawer>
|
||||
</template>
|
||||
80
apps/web-antd/src/views/system/project/modules/form.vue
Normal file
80
apps/web-antd/src/views/system/project/modules/form.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SystemProjectApi } from '#/api/system/project';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { createProject, getProject, updateProject } from '#/api/system/project';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<SystemProjectApi.Project>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value
|
||||
? $t('ui.actionTitle.edit', ['项目'])
|
||||
: $t('ui.actionTitle.create', ['项目']);
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
wrapperClass: 'grid-cols-1',
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = (await formApi.getValues()) as SystemProjectApi.Project;
|
||||
try {
|
||||
await (formData.value ? updateProject(data) : createProject(data));
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
const data = modalApi.getData<SystemProjectApi.Project>();
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getProject(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="getTitle">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -10,6 +10,7 @@ import { handleTree } from '@vben/utils';
|
||||
import { z } from '#/adapter/form';
|
||||
import { getDeptList } from '#/api/system/dept';
|
||||
import { getSimplePostList } from '#/api/system/post';
|
||||
import { getAllProjectSimpleList } from '#/api/system/project';
|
||||
import { getSimpleRoleList } from '#/api/system/role';
|
||||
import { getRangePickerDefaultProps } from '#/utils';
|
||||
|
||||
@@ -232,6 +233,49 @@ export function useAssignRoleFormSchema(): VbenFormSchema[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** 分配项目的表单 */
|
||||
export function useAssignProjectFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'username',
|
||||
label: '用户名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'nickname',
|
||||
label: '用户昵称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'projectIds',
|
||||
label: '项目',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getAllProjectSimpleList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
mode: 'multiple',
|
||||
placeholder: '请选择项目(留空表示清空所有分配)',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 用户导入的表单 */
|
||||
export function useImportFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import AssignProjectForm from './modules/assign-project-form.vue';
|
||||
import AssignRoleForm from './modules/assign-role-form.vue';
|
||||
import DeptTree from './modules/dept-tree.vue';
|
||||
import Form from './modules/form.vue';
|
||||
@@ -44,6 +45,11 @@ const [AssignRoleModal, assignRoleModalApi] = useVbenModal({
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [AssignProjectModal, assignProjectModalApi] = useVbenModal({
|
||||
connectedComponent: AssignProjectForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [ImportModal, importModalApi] = useVbenModal({
|
||||
connectedComponent: ImportForm,
|
||||
destroyOnClose: true,
|
||||
@@ -133,6 +139,11 @@ function handleAssignRole(row: SystemUserApi.User) {
|
||||
assignRoleModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 分配项目 */
|
||||
function handleAssignProject(row: SystemUserApi.User) {
|
||||
assignProjectModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 更新用户状态 */
|
||||
async function handleStatusChange(
|
||||
newStatus: number,
|
||||
@@ -205,6 +216,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
<FormModal @success="handleRefresh" />
|
||||
<ResetPasswordModal @success="handleRefresh" />
|
||||
<AssignRoleModal @success="handleRefresh" />
|
||||
<AssignProjectModal @success="handleRefresh" />
|
||||
<ImportModal @success="handleRefresh" />
|
||||
|
||||
<div class="flex h-full w-full">
|
||||
@@ -280,6 +292,12 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
auth: ['system:permission:assign-user-role'],
|
||||
onClick: handleAssignRole.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '分配项目',
|
||||
type: 'link',
|
||||
auth: ['system:user:assign-project'],
|
||||
onClick: handleAssignProject.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '重置密码',
|
||||
type: 'link',
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SystemUserApi } from '#/api/system/user';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message, Modal as AntModal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
assignUserProjects,
|
||||
getProjectIdsByUserId,
|
||||
} from '#/api/system/user-project';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useAssignProjectFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 80,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useAssignProjectFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
const values = await formApi.getValues();
|
||||
const projectIds: number[] = values.projectIds ?? [];
|
||||
|
||||
// 空集二次确认:清空该用户的所有项目分配是高危操作
|
||||
if (projectIds.length === 0) {
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
AntModal.confirm({
|
||||
title: '确认清空项目分配?',
|
||||
content: `即将清空用户【${values.username}】的所有项目分配,保存后该用户将无法访问任何项目。确认继续?`,
|
||||
okText: '确认清空',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
modalApi.lock();
|
||||
try {
|
||||
await assignUserProjects({
|
||||
userId: values.id,
|
||||
projectIds,
|
||||
});
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<SystemUserApi.User>();
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
const projectIds = await getProjectIdsByUserId(data.id);
|
||||
await formApi.setValues({
|
||||
...data,
|
||||
projectIds,
|
||||
});
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="分配项目">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -1,9 +1,39 @@
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
import { copyFileSync, mkdirSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { defineConfig } from '@vben/vite-config';
|
||||
|
||||
/**
|
||||
* 启动/构建时把 lottie-web 的 light 播放器从 node_modules 拷到 public/
|
||||
* 供 loading.html 以 <script src="/lottie_light.min.js"> 方式加载。
|
||||
* 直接覆盖拷贝(~170KB,<1ms),避免 mtime 粒度在部分 FS 上漏拷。
|
||||
*/
|
||||
function copyLottiePlayer(): Plugin {
|
||||
const require = createRequire(import.meta.url);
|
||||
const src = require.resolve('lottie-web/build/player/lottie_light.min.js');
|
||||
const dest = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'public/lottie_light.min.js',
|
||||
);
|
||||
|
||||
return {
|
||||
name: 'web-antd:copy-lottie-player',
|
||||
configResolved() {
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
copyFileSync(src, dest);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(async () => {
|
||||
return {
|
||||
application: {},
|
||||
vite: {
|
||||
plugins: [copyLottiePlayer()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/admin-api': {
|
||||
|
||||
35
docker-compose.frontend.yml
Normal file
35
docker-compose.frontend.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
# ============================================
|
||||
# AIoT Platform UI (vben) - 部署 Compose
|
||||
# 由 Jenkins 在部署阶段同步到 ${DEPLOY_PATH}
|
||||
# 通过环境变量注入:
|
||||
# IMAGE_TAG 镜像版本号
|
||||
# REGISTRY_HOST 镜像仓库地址(含端口)
|
||||
# HOST_PORT 宿主机暴露端口
|
||||
# ============================================
|
||||
version: '3.8'
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: 1panel-network
|
||||
external: true
|
||||
|
||||
services:
|
||||
aiot-platform-ui:
|
||||
image: ${REGISTRY_HOST:-172.17.16.7:5000}/aiot-platform-ui:${IMAGE_TAG:-latest}
|
||||
container_name: aiot-platform-ui
|
||||
restart: on-failure:5
|
||||
ports:
|
||||
- "${HOST_PORT:-80}:80"
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O- http://localhost/ >/dev/null 2>&1 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 15s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256m
|
||||
cpus: '0.5'
|
||||
399
docs/design/2026-04-23-user-project-binding.md
Normal file
399
docs/design/2026-04-23-user-project-binding.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# Design: 用户 ↔ 项目 绑定功能
|
||||
|
||||
- Generated by /office-hours on 2026-04-23
|
||||
- Frontend branch: `feat/multi-tenant-project`(yudao-ui-admin-vben)
|
||||
- Backend branch: `feat/multi-tenant`(aiot-platform-cloud)
|
||||
- Status: APPROVED
|
||||
- Mode: 内部项目(双端联动)
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
前后端已经铺好了"租户 → 项目 → 业务数据"两级隔离的骨架,顶栏也有了项目切换器,但**谁能访问哪个项目**这件事目前只有一个隐性来源:租户创建时 `createDefaultProject()` 把所有用户绑到 `CODE=DEFAULT` 项目上。
|
||||
|
||||
一旦新建第二个项目,或新增一个用户,就没有任何管理界面去维护 `system_user_project` 这张中间表。HR 要给新员工开权限、项目经理要组建项目团队,目前都做不到。
|
||||
|
||||
要补的是:**用户 ↔ 项目绑定关系的管理界面和专用 API**。
|
||||
|
||||
---
|
||||
|
||||
## 现状梳理(调研结论)
|
||||
|
||||
### 后端(aiot-platform-cloud · `viewsh-module-system`)已就位
|
||||
|
||||
| 组件 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `system_project` 表 | `sql/mysql/project/01-create-tables.sql` | 含租户隔离、`CODE=DEFAULT` 约定 |
|
||||
| `system_user_project` 表 | 同上 | 中间表 `(user_id, project_id)` 唯一约束 + `tenant_id` |
|
||||
| `ProjectDO` / `UserProjectDO` | `dal/dataobject/project/` | UserProjectDO 只有二元组字段 |
|
||||
| `ProjectMapper` / `UserProjectMapper` | `dal/mysql/project/` | UserProjectMapper 已有 `selectListByUserId`、`selectListByProjectId`、`deleteByUserIdAndProjectId` |
|
||||
| `ProjectService.getAuthorizedProjectIds(userId)` | `service/project/ProjectServiceImpl.java:143` | 已实现 |
|
||||
| `ProjectService.getDefaultProjectId(userId)` | 同上 `:149` | 已实现(DEFAULT 优先,否则最小 ID) |
|
||||
| `ProjectContextHolder` | `framework/tenant/core/context/` | ThreadLocal 项目上下文 |
|
||||
| `ProjectDatabaseInterceptor` | `framework/tenant/core/db/` | MyBatis Plus 自动拼 `WHERE project_id = ?` |
|
||||
| `ProjectContextWebFilter` | `framework/tenant/core/web/` | 从 HTTP header `project-id` 注入到 context |
|
||||
| `ProjectSecurityWebFilter` | `framework/tenant/core/security/` | **已做"用户对项目的访问权"校验**(含默认项目回填 + 非授权项目 403) |
|
||||
|
||||
### 前端(yudao-ui-admin-vben · `apps/web-antd`)已就位
|
||||
|
||||
| 组件 | 路径 |
|
||||
| --- | --- |
|
||||
| 项目 CRUD 页面 | `views/system/project/{index.vue, modules/form.vue, data.ts}` |
|
||||
| 项目 API | `api/system/project/index.ts` |
|
||||
| 顶栏项目切换器 | `packages/effects/layouts/src/widgets/project-dropdown/project-dropdown.vue` |
|
||||
| 请求拦截器 | axios 自动注入 `project-id` 请求头 |
|
||||
|
||||
### Gap
|
||||
|
||||
1. **无 UserProject 管理 API** —— 后端 `UserProjectMapper` 有,但没封装 Service,没暴露 Controller。
|
||||
2. **无用户 ↔ 项目绑定管理界面** —— 用户页没"分配项目"、项目页没"管理成员"。
|
||||
3. **`/system/project/simple-list` 是 bug**
|
||||
- `ProjectController.getProjectSimpleList`(`ProjectController.java:77-81`)当前查的是 `projectService.getProjectListByStatus(ENABLE)`——**返回整租户启用项目**。
|
||||
- 正确行为:只返回**当前登录用户授权的**启用项目(否则顶栏下拉里会出现无权访问的项目名,点了才 403)。
|
||||
|
||||
---
|
||||
|
||||
## Premises(已确认)
|
||||
|
||||
1. 后端表和 `ProjectSecurityWebFilter` 都已到位,**不新建任何表、不改任何字段**。
|
||||
2. UI 采用**双入口**:用户页「分配项目」+ 项目页「管理成员」。
|
||||
3. **不引入"项目内角色"**:`user_project` 保持二元组,未来需要时再加 `role_id` 字段。
|
||||
4. **顺带修 `simple-list` bug**:改成只返回登录用户授权的项目。
|
||||
5. **沿用 yudao "assignXxx" 风格**:幂等覆盖写入,Body 传 `Set<Long>`,Service 内部 diff 出增删。参照 `PermissionServiceImpl.assignUserRole`(`PermissionServiceImpl.java:208`)。
|
||||
6. **边界守卫**:禁止管理员把自己从当前正在访问的项目移除;禁止清空 superadmin(用户 id=1)的项目分配。
|
||||
|
||||
---
|
||||
|
||||
## Approaches Considered
|
||||
|
||||
### Approach A: 一次到位(已选)
|
||||
|
||||
一个 PR 同时交付:UserProject 管理 API + 双入口前端弹窗 + `simple-list` bug 修复。
|
||||
|
||||
- Completeness: 9/10
|
||||
- 人力估:~1 天 / CC 估:~35 分钟
|
||||
- Pros: 一次发布闭环;用户体验一致;bug 不遗留
|
||||
- Cons: PR 稍大;需要前后端同时 review
|
||||
- Reuses: `UserProjectMapper`、`PermissionController.assign-user-role` 模式、`assign-role-form.vue` 模板
|
||||
|
||||
### Approach B: 分两步交付(备选)
|
||||
|
||||
PR1 只做"用户视角"(后端 2 个接口 + 修 bug + 前端用户页入口);PR2 做"项目视角"。
|
||||
|
||||
- Completeness: 8/10
|
||||
- 人力估:PR1 ~0.4 天 + PR2 ~0.5 天
|
||||
- Pros: PR 小好 review;风险可控
|
||||
- Cons: 项目经理视角的体验要等下个迭代
|
||||
|
||||
### Approach C: 极简版(备选)
|
||||
|
||||
在用户编辑表单里直接加"所属项目"多选下拉框(类似 `deptId`/`postIds`),不做独立弹窗,不改项目页。
|
||||
|
||||
- Completeness: 5/10
|
||||
- 人力估:~0.25 天
|
||||
- Pros: 文件改动最少
|
||||
- Cons: 项目经理组团队要一个个用户翻;与 yudao "assign-role" 交互不一致
|
||||
|
||||
---
|
||||
|
||||
## Recommended Approach: A(一次到位)
|
||||
|
||||
---
|
||||
|
||||
## 详细设计
|
||||
|
||||
### 后端改动(aiot-platform-cloud · `viewsh-module-system-server`)
|
||||
|
||||
#### 1. 新建 `UserProjectController`
|
||||
|
||||
路径:`controller/admin/project/UserProjectController.java` 基础路径:`/system/user-project`
|
||||
|
||||
| 方法 | 路径 | 权限 | Body / Param | 返回 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| POST | `/assign-user-projects` | `system:user:assign-project` | `{userId, projectIds: Set<Long>}` | `Boolean` |
|
||||
| POST | `/assign-project-users` | `system:project:assign-user` | `{projectId, userIds: Set<Long>}` | `Boolean` |
|
||||
| GET | `/list-project-ids-by-user` | `system:user:assign-project` | `?userId=` | `Set<Long>` |
|
||||
| GET | `/list-user-ids-by-project` | `system:project:assign-user` | `?projectId=` | `Set<Long>` |
|
||||
|
||||
两个 `assign-*` 都是**幂等覆盖写入**(传全集,Service 内 diff)。
|
||||
|
||||
#### 2. 新建 `UserProjectService` + `UserProjectServiceImpl`
|
||||
|
||||
路径:`service/project/`
|
||||
|
||||
```java
|
||||
public interface UserProjectService {
|
||||
// 覆盖写入:用户 userId 所绑定的项目集合设为 projectIds
|
||||
void assignUserProjects(Long userId, Set<Long> projectIds);
|
||||
|
||||
// 覆盖写入:项目 projectId 下的成员设为 userIds
|
||||
void assignProjectUsers(Long projectId, Set<Long> userIds);
|
||||
|
||||
// 查:用户已绑定的项目 ID 集合
|
||||
Set<Long> getProjectIdsByUserId(Long userId);
|
||||
|
||||
// 查:项目下的用户 ID 集合
|
||||
Set<Long> getUserIdsByProjectId(Long projectId);
|
||||
}
|
||||
```
|
||||
|
||||
实现参考 `PermissionServiceImpl.assignUserRole`(`PermissionServiceImpl.java:208`)的 diff 思路:
|
||||
|
||||
```java
|
||||
// 1. 查当前集合
|
||||
Set<Long> currentIds = ...selectListByUserId(userId).stream().map(getProjectId).collect(toSet());
|
||||
// 2. 计算增删
|
||||
Set<Long> toInsert = CollUtil.subtract(projectIds, currentIds);
|
||||
Set<Long> toDelete = CollUtil.subtract(currentIds, projectIds);
|
||||
// 3. 执行
|
||||
if (!toInsert.isEmpty()) userProjectMapper.insertBatch(buildDOs(userId, toInsert));
|
||||
if (!toDelete.isEmpty()) userProjectMapper.delete(wrapper by userId + projectId in toDelete);
|
||||
```
|
||||
|
||||
#### 3. 修复 `ProjectController.getProjectSimpleList`
|
||||
|
||||
`ProjectController.java:77-81`:
|
||||
|
||||
```java
|
||||
// 当前实现(错)
|
||||
List<ProjectDO> list = projectService.getProjectListByStatus(ENABLE);
|
||||
|
||||
// 应改为 —— 在 ProjectService 加新方法
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
List<ProjectDO> list = projectService.getAuthorizedEnabledProjects(userId);
|
||||
```
|
||||
|
||||
**预防回归**:在修复前,**全量搜索 `getSimpleProjectList` / `getProjectSimpleList` 的所有调用点**(前后端都要搜),挨个确认它们的语义诉求是"当前用户授权的"还是"本租户全部"。找到的每个调用点都要在 PR 描述里列出并说明是否受影响。**初步预期**:只有顶栏项目切换器用它;但后端日志/报表/下拉如果也在用原语义("本租户全部启用项目"),需要改指向本 PR 新增的 `/system/project/all-simple-list`(见下一节)。
|
||||
|
||||
#### 3.1 新增 `/system/project/all-simple-list`(管理员专用)
|
||||
|
||||
**用途**:给"管理员为其他人分配项目"场景提供**全量**项目下拉。权限点复用 `system:project:query`,返回体和当前 `getProjectSimpleList` 一致。
|
||||
|
||||
```java
|
||||
@GetMapping("/all-simple-list")
|
||||
@Operation(summary = "获取本租户全部启用项目(用于管理员分配场景)")
|
||||
@PreAuthorize("@ss.hasPermission('system:project:query')")
|
||||
public CommonResult<List<ProjectRespVO>> getAllProjectSimpleList() {
|
||||
List<ProjectDO> list = projectService.getProjectListByStatus(ENABLE);
|
||||
return success(convertList(list, p -> new ProjectRespVO().setId(p.getId()).setName(p.getName()).setCode(p.getCode())));
|
||||
}
|
||||
```
|
||||
|
||||
前端"分配项目"弹窗的项目下拉**必须用此接口**,不能用 `getSimpleProjectList`(后者已改成只返回当前登录人授权项目)。
|
||||
|
||||
#### 4. 边界守卫(Service 层)
|
||||
|
||||
- **`assignUserProjects`**:若 `projectIds` 不包含用户当前 `ProjectContextHolder.getProjectId()`,**且调用者 `SecurityFrameworkUtils.getLoginUserId()` 就是 `userId` 本人**(即自己改自己的分配),抛 `USER_PROJECT_CANNOT_REMOVE_SELF_CURRENT` 业务异常。
|
||||
- _注释要写清_:本守卫只阻止自己把自己踢出当前项目;管理员给别人改分配不受此影响(即使被改的用户当前正在访问某项目)。
|
||||
- **`assignProjectUsers`**:
|
||||
- _superadmin 守卫_:若 `userIds` 计算出的"要移除的子集"中任何一个持有超管角色,拒绝。**不能用 `userId == 1` 判别**(不同租户的管理员 id 不一定是 1)。正确做法:注入 `PermissionService.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode())` 或类似的现有工具;yudao 里可查 `AdminUserService.isSuperAdmin()` 的实现并复用。
|
||||
- _自踢守卫_:若当前登录人 id 在被移除列表 **且** 当前 `ProjectContextHolder.getProjectId()` 等于目标 `projectId`,拒绝。
|
||||
- **项目删除级联**:
|
||||
- `ProjectService.deleteProject` 需在**同一个 `@Transactional` 方法内**:先 `userProjectMapper.delete(lambdaQuery.eq(projectId, id))`(走 MyBatis Plus 自动软删标记 `deleted=1`),再 `projectMapper.deleteById(id)`。
|
||||
- 项目 `disable` 不触发级联(用户关系保留,恢复启用后仍生效)。
|
||||
|
||||
#### 5. 菜单权限种子(SQL)
|
||||
|
||||
新增两条菜单权限(放 `sql/mysql/migrations/2026-04-23_user_project_permissions.sql`)。
|
||||
|
||||
**关键:`parent_id` 不能写死**,因为不同环境菜单 id 可能不同。用子查询动态取:
|
||||
|
||||
```sql
|
||||
-- 依赖条件:
|
||||
-- 1) 用户管理菜单存在,通过 permission='system:user:list' 或 name='用户管理' 定位
|
||||
-- 2) 项目管理菜单存在,通过 permission='system:project:query' 或 name='项目管理' 定位
|
||||
-- (若项目主菜单尚不存在,先插一条主菜单再插按钮)
|
||||
|
||||
-- system:user:assign-project —— 用户管理下的"分配项目"按钮
|
||||
INSERT INTO system_menu (name, permission, type, parent_id, sort, status, creator, create_time, updater, update_time, deleted)
|
||||
SELECT '用户分配项目', 'system:user:assign-project', 3,
|
||||
m.id, 10, 0, '1', NOW(), '1', NOW(), 0
|
||||
FROM system_menu m
|
||||
WHERE m.permission = 'system:user:list' AND m.deleted = 0
|
||||
LIMIT 1;
|
||||
|
||||
-- system:project:assign-user —— 项目管理下的"管理成员"按钮
|
||||
INSERT INTO system_menu (name, permission, type, parent_id, sort, status, creator, create_time, updater, update_time, deleted)
|
||||
SELECT '项目管理成员', 'system:project:assign-user', 3,
|
||||
m.id, 10, 0, '1', NOW(), '1', NOW(), 0
|
||||
FROM system_menu m
|
||||
WHERE m.permission = 'system:project:query' AND m.deleted = 0
|
||||
LIMIT 1;
|
||||
|
||||
-- 幂等:若已存在同 permission 的按钮,忽略(需要 DBA 先 DELETE 或加 ON DUPLICATE KEY;
|
||||
-- 若项目有多环境,推荐改成 Flyway/Liquibase 迁移脚本由 CI 控制幂等)
|
||||
```
|
||||
|
||||
迁移脚本跑完后人工核对:两条记录的 `parent_id` 非 NULL 且能在 "菜单管理" 页面看到层级正确。
|
||||
|
||||
---
|
||||
|
||||
### 前端改动(yudao-ui-admin-vben · `apps/web-antd`)
|
||||
|
||||
#### 1. 新建 API 封装
|
||||
|
||||
路径:`src/api/system/user-project/index.ts`
|
||||
|
||||
```typescript
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace SystemUserProjectApi {
|
||||
export interface AssignUserProjectsReq {
|
||||
userId: number;
|
||||
projectIds: number[];
|
||||
}
|
||||
export interface AssignProjectUsersReq {
|
||||
projectId: number;
|
||||
userIds: number[];
|
||||
}
|
||||
}
|
||||
|
||||
export function assignUserProjects(
|
||||
data: SystemUserProjectApi.AssignUserProjectsReq,
|
||||
) {
|
||||
return requestClient.post('/system/user-project/assign-user-projects', data);
|
||||
}
|
||||
|
||||
export function assignProjectUsers(
|
||||
data: SystemUserProjectApi.AssignProjectUsersReq,
|
||||
) {
|
||||
return requestClient.post('/system/user-project/assign-project-users', data);
|
||||
}
|
||||
|
||||
export function getProjectIdsByUserId(userId: number) {
|
||||
return requestClient.get<number[]>(
|
||||
`/system/user-project/list-project-ids-by-user?userId=${userId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getUserIdsByProjectId(projectId: number) {
|
||||
return requestClient.get<number[]>(
|
||||
`/system/user-project/list-user-ids-by-project?projectId=${projectId}`,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 用户管理页:「分配项目」弹窗
|
||||
|
||||
**新建** `views/system/user/modules/assign-project-form.vue` —— 直接照搬 `assign-role-form.vue`(同目录)的结构,替换:
|
||||
|
||||
- import:`assignUserRole, getUserRoleList` → `assignUserProjects, getProjectIdsByUserId`
|
||||
- 数据源:**必须用** `getAllProjectSimpleList`(后端 3.1 节新建的管理员专用接口),**不要用** `getSimpleProjectList`(已改成只返回调用者自己授权的项目)
|
||||
- schema:`useAssignRoleFormSchema` → 新建 `useAssignProjectFormSchema`(在 `data.ts` 里)。字段 `projectIds`,`component: 'Select'` + `mode: 'multiple'`,`options` 由 `getAllProjectSimpleList()` 填充
|
||||
|
||||
**空集二次确认**:`onConfirm` 提交前若 `values.projectIds.length === 0`,弹 `Modal.confirm({ title: '确认清空该用户的所有项目分配?' })`。仅"确认"后才调 API。避免误点保存把用户所有项目全删。
|
||||
|
||||
**修改** `views/system/user/index.vue`:在行操作按钮区加一个「分配项目」,照抄现有「分配角色」按钮的写法。
|
||||
|
||||
**修改** `views/system/user/data.ts`:新增 `useAssignProjectFormSchema()`。
|
||||
|
||||
#### 3. 项目管理页:「管理成员」弹窗
|
||||
|
||||
**新建** `views/system/project/modules/assign-user-form.vue`
|
||||
|
||||
交互:穿梭框(transfer)样式或多选 Select,左侧全量用户(通过 `getSimpleUserList`),右侧已绑定。支持搜索 username/nickname。
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 项目:10号线巡检项目 │
|
||||
│ │
|
||||
│ ┌─── 所有用户 ───┐ ┌─── 项目成员 ───┐ │
|
||||
│ │ □ 张三 (ops) │ →→→ │ ☑ 李四 (mgr) │ │
|
||||
│ │ □ 王五 (iot) │ ←←← │ ☑ 赵六 (clean) │ │
|
||||
│ └───────────────┘ └───────────────┘ │
|
||||
│ │
|
||||
│ [取消] [保存] │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
简化版实现:直接用 ant-design-vue 的 `a-transfer`;或者用 `a-select` multiple + `getSimpleUserList` 的下拉。第一版用 `a-select multiple` 最快。
|
||||
|
||||
**修改** `views/system/project/index.vue`:行操作加「管理成员」按钮。
|
||||
|
||||
**修改** `views/system/project/data.ts`:新增 `useAssignUserFormSchema()`。
|
||||
|
||||
---
|
||||
|
||||
## 数据模型
|
||||
|
||||
**不新增表,不改字段。** 沿用:
|
||||
|
||||
```sql
|
||||
system_user_project (
|
||||
id BIGINT PK AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
project_id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
creator, create_time, updater, update_time, deleted,
|
||||
UNIQUE KEY uk_user_project (user_id, project_id, deleted),
|
||||
INDEX idx_project (project_id)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 成功标准(Acceptance)
|
||||
|
||||
1. 管理员在用户页对用户"分配项目"保存后,该用户立刻能通过顶栏切换到被分配项目,业务接口返回数据。
|
||||
2. 管理员在项目页"管理成员"增删后,被移除的用户切到该项目会被 `ProjectSecurityWebFilter` 403。
|
||||
3. 顶栏项目下拉只显示当前登录用户**授权且启用**的项目。
|
||||
4. 管理员不能把**自己**从当前正在访问的项目移除(UI 禁用 + 后端守卫)。
|
||||
5. 不能清空 superadmin 的项目分配。
|
||||
6. 分配/取消分配后,`ProjectService.getAuthorizedProjectIds(userId)` 立刻反映最新状态(无缓存或缓存有清理)。
|
||||
|
||||
---
|
||||
|
||||
## Open Questions(实施前再 settle)
|
||||
|
||||
1. ~~管理员分配时用哪个接口拉"所有项目"下拉数据?~~ **已决**:新增 `/system/project/all-simple-list`,权限点复用 `system:project:query`(见后端 3.1 节)。
|
||||
|
||||
2. ~~删除项目时,`system_user_project` 要不要级联软删?~~ **已决**:在同一 `@Transactional` 内级联软删,本 PR 一起做(见后端 4 节)。
|
||||
|
||||
3. **缓存?** `ProjectSecurityWebFilter` 每次请求都调 `getAuthorizedProjectIds`——高并发下要不要加 Redis 缓存?本 PR 先不做,只做能用的 V1。给 ops 团队留条 TODO。
|
||||
|
||||
4. **业务表历史数据的 `project_id` 谁填?** 这是另一个迁移任务,不在本设计范围。假定各业务模块自己有迁移脚本(看 `sql/mysql/project/03-alter-business-tables.sql`)。
|
||||
|
||||
5. **GET 查询接口与 `ProjectSecurityWebFilter` 的交互。** 4 个新增 API 都是管理员在"某个项目上下文"里调用,理论上不需要 `@ProjectIgnore` —— Filter 会用 context 里的 `project-id` 正常通过。但若出现"调用者刚被移除所有授权项目,自己还想查"的边缘场景会 403。**本 PR 不处理,留给实施时观察日志**。若要求必然能通,后端给 4 个接口加 `@ProjectIgnore` 注解。
|
||||
|
||||
---
|
||||
|
||||
## Distribution Plan(交付通路)
|
||||
|
||||
- **backend**: 在 `feat/multi-tenant` 上拆子分支 `feat/multi-tenant/user-project-api`,合并后通过现有镜像构建 → 部署。
|
||||
- **frontend**: 在当前 `feat/multi-tenant-project` 上直接开工,通过 `Dockerfile.deploy` 构建 → 部署。
|
||||
- CI 跑 backend 单测(新增 `UserProjectServiceImplTest`)+ 前端 lint + typecheck。
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序(The Assignment)
|
||||
|
||||
1. **后端先发**(~25 min):
|
||||
1. 建 `UserProjectService` 接口 + 实现 + 测试(diff 逻辑用参数化测试覆盖增量/减量/全替换/空集四种情况)
|
||||
2. 建 `UserProjectController`
|
||||
3. 修 `ProjectController.getProjectSimpleList`(+ 单独测试用例)
|
||||
4. 加菜单权限种子 SQL
|
||||
5. 本地跑一遍:用 `superadmin` 给 `user-xxx` 分配两个项目 → Postman 断言 `list-project-ids-by-user` 返回
|
||||
2. **前端接入**(~10 min):
|
||||
1. `api/system/user-project/index.ts`
|
||||
2. `assign-project-form.vue` + 用户页按钮
|
||||
3. `assign-user-form.vue` + 项目页按钮
|
||||
3. **手工验证清单**:
|
||||
- 分配 → 切项目 → 业务接口受保护 ✅
|
||||
- 取消分配 → 切项目被 403 ✅
|
||||
- 顶栏下拉只有授权项目 ✅
|
||||
- 自踢守卫 ✅
|
||||
- superadmin 守卫 ✅
|
||||
|
||||
---
|
||||
|
||||
## Reviewer Concerns(第 1 轮 spec review 已处理)
|
||||
|
||||
- ~~superadmin 守卫用 `userId==1` 不安全~~ → 已改为角色判别(4 节)
|
||||
- ~~菜单 parent_id 占位符未填~~ → 已改为 `SELECT FROM system_menu WHERE permission=...` 子查询(5 节)
|
||||
- ~~`simple-list` 修复可能破坏其他调用点~~ → 已加"修前全量搜索调用点"要求(3 节)
|
||||
- ~~"管理员分配用哪个下拉接口"有两个方案未定~~ → 已定为新增 `/all-simple-list`(3.1 节)
|
||||
- ~~前端空集清空缺少二次确认~~ → 已补 `Modal.confirm` 流程(前端 2 节)
|
||||
- ~~级联删除事务边界不清晰~~ → 已明确 `@Transactional` 同一方法内(4 节)
|
||||
|
||||
综合质量分从 6.5/10 → 预估 8.5/10。如实施发现新风险,回填此处。
|
||||
@@ -8,6 +8,7 @@ export { default as AuthenticationLayoutToggle } from './layout-toggle.vue';
|
||||
export * from './lock-screen';
|
||||
export * from './notification';
|
||||
export * from './preferences';
|
||||
export * from './project-dropdown';
|
||||
export * from './tenant-dropdown';
|
||||
export * from './theme-toggle';
|
||||
export * from './timezone';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ProjectDropdown } from './project-dropdown.vue';
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
/**
|
||||
* 窄接口:widget 不依赖 apps/* 的 API 类型,只取下拉展示所需字段。
|
||||
* 消费端若传入更大的 Project 对象会被结构化子类型接收,无兼容问题。
|
||||
*/
|
||||
interface Project {
|
||||
id?: number;
|
||||
name: string;
|
||||
code?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'ProjectDropdown',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
projectList?: Project[];
|
||||
visitProjectId?: null | number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const projects = computed(() => props.projectList ?? []);
|
||||
|
||||
async function handleChange(id: number | undefined) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const project = projects.value.find((item) => item.id === id);
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
emit('success', project);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="hover:bg-accent ml-1 mr-2 h-8 w-32 cursor-pointer rounded-full p-1.5"
|
||||
>
|
||||
<IconifyIcon icon="lucide:folder-kanban" class="mr-2" />
|
||||
{{
|
||||
projects.find((item) => item.id === visitProjectId)?.name ||
|
||||
'请选择项目'
|
||||
}}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-40 p-0 pb-1">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:disabled="project.id === visitProjectId"
|
||||
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
|
||||
@click="handleChange(project.id)"
|
||||
>
|
||||
<template v-if="project.id === visitProjectId">
|
||||
<IconifyIcon icon="lucide:check" class="mr-2" />
|
||||
{{ project.name }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ project.name }}
|
||||
</template>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="projects.length === 0"
|
||||
disabled
|
||||
class="mx-1 flex cursor-default items-center rounded-sm py-1 text-xs leading-8 opacity-60"
|
||||
>
|
||||
暂无项目
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -39,6 +39,10 @@ interface AccessState {
|
||||
* 登录是否过期
|
||||
*/
|
||||
loginExpired: boolean;
|
||||
/**
|
||||
* 当前项目编号
|
||||
*/
|
||||
projectId: null | number;
|
||||
/**
|
||||
* 登录 accessToken
|
||||
*/
|
||||
@@ -105,6 +109,9 @@ export const useAccessStore = defineStore('core-access', {
|
||||
setTenantId(tenantId: null | number) {
|
||||
this.tenantId = tenantId;
|
||||
},
|
||||
setProjectId(projectId: null | number) {
|
||||
this.projectId = projectId;
|
||||
},
|
||||
setVisitTenantId(visitTenantId: number) {
|
||||
this.visitTenantId = visitTenantId;
|
||||
},
|
||||
@@ -121,6 +128,7 @@ export const useAccessStore = defineStore('core-access', {
|
||||
'accessCodes',
|
||||
'tenantId',
|
||||
'visitTenantId',
|
||||
'projectId',
|
||||
'isLockScreen',
|
||||
'lockScreenPassword',
|
||||
],
|
||||
@@ -137,6 +145,7 @@ export const useAccessStore = defineStore('core-access', {
|
||||
refreshToken: null,
|
||||
tenantId: null,
|
||||
visitTenantId: null,
|
||||
projectId: null,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@@ -821,6 +821,9 @@ importers:
|
||||
jszip:
|
||||
specifier: 'catalog:'
|
||||
version: 3.10.1
|
||||
lottie-web:
|
||||
specifier: ^5.13.0
|
||||
version: 5.13.0
|
||||
pinia:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
|
||||
@@ -1176,7 +1179,7 @@ importers:
|
||||
dependencies:
|
||||
'@commitlint/cli':
|
||||
specifier: 'catalog:'
|
||||
version: 19.8.1(@types/node@25.0.3)(typescript@5.9.3)
|
||||
version: 19.8.1(@types/node@24.10.4)(typescript@5.9.3)
|
||||
'@commitlint/config-conventional':
|
||||
specifier: 'catalog:'
|
||||
version: 19.8.1
|
||||
@@ -8523,6 +8526,9 @@ packages:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
lottie-web@5.13.0:
|
||||
resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==}
|
||||
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
@@ -12952,11 +12958,11 @@ snapshots:
|
||||
style-mod: 4.1.3
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@commitlint/cli@19.8.1(@types/node@25.0.3)(typescript@5.9.3)':
|
||||
'@commitlint/cli@19.8.1(@types/node@24.10.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@commitlint/format': 19.8.1
|
||||
'@commitlint/lint': 19.8.1
|
||||
'@commitlint/load': 19.8.1(@types/node@25.0.3)(typescript@5.9.3)
|
||||
'@commitlint/load': 19.8.1(@types/node@24.10.4)(typescript@5.9.3)
|
||||
'@commitlint/read': 19.8.1
|
||||
'@commitlint/types': 19.8.1
|
||||
tinyexec: 1.0.2
|
||||
@@ -13003,7 +13009,7 @@ snapshots:
|
||||
'@commitlint/rules': 19.8.1
|
||||
'@commitlint/types': 19.8.1
|
||||
|
||||
'@commitlint/load@19.8.1(@types/node@25.0.3)(typescript@5.9.3)':
|
||||
'@commitlint/load@19.8.1(@types/node@24.10.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@commitlint/config-validator': 19.8.1
|
||||
'@commitlint/execute-rule': 19.8.1
|
||||
@@ -13011,7 +13017,7 @@ snapshots:
|
||||
'@commitlint/types': 19.8.1
|
||||
chalk: 5.6.2
|
||||
cosmiconfig: 9.0.0(typescript@5.9.3)
|
||||
cosmiconfig-typescript-loader: 6.2.0(@types/node@25.0.3)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3)
|
||||
cosmiconfig-typescript-loader: 6.2.0(@types/node@24.10.4)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3)
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.merge: 4.6.2
|
||||
lodash.uniq: 4.5.0
|
||||
@@ -16525,9 +16531,9 @@ snapshots:
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cosmiconfig-typescript-loader@6.2.0(@types/node@25.0.3)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3):
|
||||
cosmiconfig-typescript-loader@6.2.0(@types/node@24.10.4)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@types/node': 25.0.3
|
||||
'@types/node': 24.10.4
|
||||
cosmiconfig: 9.0.0(typescript@5.9.3)
|
||||
jiti: 2.6.1
|
||||
typescript: 5.9.3
|
||||
@@ -18988,6 +18994,8 @@ snapshots:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
lottie-web@5.13.0: {}
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
lower-case@2.0.2:
|
||||
|
||||
Reference in New Issue
Block a user