13 Commits

Author SHA1 Message Date
lzh
a1a2d86b0b build(ci): 新增 Jenkinsfile + docker-compose 部署模板
- Jenkinsfile:master→PROD(172.17.16.14)、release/next→STAGING(172.17.16.7)
  镜像推 172.17.16.7:5000/aiot-platform-ui,宿主机暴露 80
- docker-compose.frontend.yml:nginx 容器加入 1panel-network 走后端 aiot-gateway 反代
2026-04-28 14:40:17 +08:00
lzh
ba8509dc66 style(@vben/docs): markdownlint 自动格式化设计稿
lint-md 对上一条设计稿做了表格管道对齐和多空格归一化,
收进单独提交保持每次提交可 diff。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:27:56 +08:00
lzh
6b8a05cc4d docs: 补充用户-项目绑定功能设计稿
记录本轮 feat/multi-tenant-project 分支的需求背景、双入口绑定
方案与前后端联动约定,供后续回溯决策。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:27:09 +08:00
lzh
6b8626907e feat(@vben/web-antd): Lottie 品牌加载动画(主题色自适应)
- 新增全局 loading:自定义 apps/web-antd/loading.html 覆盖 inject-app-loading
  的默认模板,Vue 挂载前就能播放品牌动画
- 新增 LottieLoading 组件,用于 SSO 回调等"白屏时间偏长"的运行时场景
- 换色方案:Lottie JSON 里原本 5 档硬编码绿色,按当前主题 colorPrimary
  生成 5 档 HSL 亮度做指纹替换。挂载前从 localStorage preferences 读色,
  挂载后读 CSS 变量 --primary,两条路径共用 public/lottie-theme-patch.js
  一份 classic-JS 源,window.__LottieThemePatch__ 上暴露
- vite 插件:启动/构建时从 node_modules 把 lottie_light.min.js 拷到
  public/ 供 loading.html <script> 加载;.gitignore 排除该产物
- LottieLoading 复用 loading.html 已经挂好的 window.lottie,不再把
  ~170KB 播放器再打进 Vue 产物

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:26:48 +08:00
lzh
348e40e9c2 style(@vben/web-antd): 项目成员管理模块格式微调
lint 自动格式化:长签名/标题换行、import 顺序、空元素展开;
顺手移除 new Set(undefined) 无意义的 `?? []` 兜底。无行为变更。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:25:19 +08:00
lzh
86a3c1f97b fix(@vben/web-antd): SSO callback 改走 body 传递 code/state
授权 code 原先以 query 形式发给 /system/sso/callback,会被 nginx access
log、浏览器历史和 Referer 捕获。改走 POST body,与后端 @RequestBody
SsoCallbackReqVO 对齐,避免一次性码泄露给中间层。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:24:52 +08:00
lzh
09538b03cb feat(@vben/web-antd): 项目成员管理改 Drawer + 分页 + 增量
从 Modal 多选改为 Drawer 分页表,更接近"成员管理"语义:
- 原 assign-user-form.vue 重写为 Drawer + Vxe 分页表
- 新增 add-user-modal.vue 子弹窗用于添加用户(过滤已是成员)
- 每行一个"移除"popConfirm 按钮,调 removeProjectUser 单删
- 顶部 keyword 搜索,按 username/nickname/mobile 模糊
- 底部提示:超管不在此列表(后端已过滤)
- data.ts 新增 useProjectMemberGridColumns
- api 新增 getProjectUserPage / addProjectUsers / removeProjectUser
- project/index.vue 接入点改 useVbenDrawer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:48:43 +08:00
lzh
b15b6b4f4d feat(@vben/web-antd): 用户-项目绑定管理双入口
- 用户管理页:下拉操作新增 "分配项目" 按钮 + assign-project-form.vue 弹窗
  沿用现有 assign-role-form 的交互(多选 + 覆盖写入)
- 项目管理页:行操作新增 "管理成员" 按钮 + assign-user-form.vue 弹窗
  下拉支持搜索用户 nickname/username
- 新建 api/system/user-project/ 封装 4 个接口
- api/system/project 新增 getAllProjectSimpleList:
  顶栏 simple-list 已改为用户授权过滤,管理员分配场景需要全量下拉
- 空集保存二次确认:清空所有分配/成员时弹 AntModal.confirm,防误操作
- 权限点:system:user:assign-project / system:project:assign-user

设计文档:docs/design/2026-04-23-user-project-binding.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:51:09 +08:00
lzh
20251316ae chore: 忽略根目录 CLAUDE.md(本地 AI 上下文文件)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:46:08 +08:00
lzh
37acdcf394 feat(@vben/web-antd): 顶栏项目切换器 + 物联运维平台 SSO 无感跳转
- 顶栏 TenantDropdown 替换为 ProjectDropdown(新建 widget)
  - 进入时拉 /system/project/simple-list;仅当本地 projectId 不在列表时
    才回退到首项,避免静默改写用户选择
  - 空列表不渲染,避免出现永远空下拉
- 新增"物联运维"按钮,走 OAuth2 authorization code 流程跳 IoT 前端
  - state 使用 crypto.randomUUID() / getRandomValues() 生成(CSRF 防护)
  - VITE_IOT_BASE_URL 未配置时按钮隐藏,不再硬编码兜底 URL
  - 使用原生 <button disabled> 替代 <a role="button">,修复可访问性
- 新增 /sso-callback 回调页 + /system/sso/callback API
  - 挂载后立即 history.replaceState 清 code/state,避免二次 exchange
  - API 层做 snake_case → camelCase 映射,统一前端风格
  - 文档化 redirectUri 必须与 OAuth2 客户端 redirectUris 白名单一致
- authStore 新增 ssoLogin,与 authLogin 抽取共用 postAuthSuccess
  - token 为空直接抛错,fetchUserInfo 失败回滚 token 避免 401 循环

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:43:44 +08:00
lzh
72ed0eb5aa feat(@vben/web-antd): 支持按 platform 过滤多前端菜单
业务平台 (biz) 与物联运维平台 (iot) 共享同一后端,需按前端来源过滤菜单,
避免同一角色在两端看到相同菜单。

- 新增 CLIENT_ID 常量,请求拦截器 / 基础 client 统一注入 X-Client-Id 头,
  后端密码登录 & refresh-token 据此绑定 token 的 client/platform
- SystemMenuApi.Menu 增加 platform 字段
- 菜单表单新增"所属平台"选择项(PLATFORM_OPTIONS),为 null 则两端共享

配合后端迁移 sql/mysql/migrations/2026-04-20_oauth2_client_platform.sql。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:40:19 +08:00
lzh
93bfef06a4 style(@vben/web-antd): lint 自动格式化项目管理页面
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 23:11:06 +08:00
lzh
44b2dd9d05 feat(@vben/web-antd): 项目管理前端页面和请求拦截器适配
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 23:08:25 +08:00
38 changed files with 2729 additions and 83 deletions

1
.gitignore vendored
View File

@@ -56,3 +56,4 @@ design-system/
.agent/
.shared/
.claude/
CLAUDE.md

343
Jenkinsfile vendored Normal file
View 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}"
// ===== 部署目标(默认 PRODrelease/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}%,部署后建议清理"
}
}

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1,3 @@
# 由 vite.config.mts 的 copyLottiePlayer 插件在 dev/build 时自动从
# node_modules/lottie-web 拷贝,不需要进版本控制
public/lottie_light.min.js

View 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>

View File

@@ -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:",

File diff suppressed because one or more lines are too long

View 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);

View 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,
};
}

View File

@@ -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;
},
});

View File

@@ -17,6 +17,7 @@ export namespace SystemMenuApi {
visible: boolean;
keepAlive: boolean;
alwaysShow?: boolean;
platform?: string;
createTime: Date;
}
}

View 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}`);
}

View 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}`,
);
}

View File

@@ -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>

View 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;
};
}
}

View File

@@ -0,0 +1 @@
export { default as LottieLoading } from './LottieLoading.vue';

View File

@@ -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;
}
// 设置当前项目 IDaxios 拦截器会自动带 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

View File

@@ -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 };

View File

@@ -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,

View File

@@ -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>

View File

@@ -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: '缓存状态',

View 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' },
},
];
}

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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 [

View File

@@ -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',

View File

@@ -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>

View File

@@ -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': {

View 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'

View 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。如实施发现新风险回填此处。

View File

@@ -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';

View File

@@ -0,0 +1 @@
export { default as ProjectDropdown } from './project-dropdown.vue';

View File

@@ -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>

View File

@@ -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
View File

@@ -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: