Compare commits
66
Commits
049965f51b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73297e4b52 | ||
|
|
4f94dbdbda | ||
|
|
51c95dd7b9 | ||
|
|
c39c0f83d7 | ||
|
|
83a73dfcc8 | ||
|
|
b8c55a0654 | ||
|
|
7cb8b7b974 | ||
|
|
16d49375f1 | ||
|
|
b2a4eeb2f3 | ||
|
|
8bbe3c654b | ||
|
|
e83983b41c | ||
|
|
3605a91b78 | ||
|
|
24ae7b13fe | ||
|
|
1a011656ec | ||
|
|
32b73ba407 | ||
|
|
e8ab9280bf | ||
|
|
d48153ced9 | ||
|
|
d034320406 | ||
|
|
3b0f29b0d1 | ||
|
|
5c7d5d68e4 | ||
|
|
a2aa4f925c | ||
|
|
2dec060b91 | ||
|
|
c12628f051 | ||
|
|
0a93441f64 | ||
|
|
b1dba84c49 | ||
|
|
efd55a3a75 | ||
|
|
5296b5999c | ||
|
|
ec53c34b9c | ||
|
|
e034b947e5 | ||
|
|
e1aee91a98 | ||
|
|
14dcdb7fe1 | ||
|
|
6a2c82c0ef | ||
|
|
0080861482 | ||
|
|
02a203a7f1 | ||
|
|
71c08d6e9a | ||
|
|
7784492522 | ||
|
|
d012a99e28 | ||
|
|
628a914e19 | ||
|
|
a082e59c3e | ||
|
|
af94e1d2cb | ||
|
|
4237615238 | ||
|
|
711765234f | ||
|
|
6750a735c2 | ||
|
|
af367ddd19 | ||
|
|
e1cdc26697 | ||
|
|
e0252eb997 | ||
|
|
df5f5d2cff | ||
|
|
659a569054 | ||
|
|
c1e4603fa4 | ||
|
|
194e76184a | ||
|
|
4bf8efedbb | ||
|
|
12f9771ed5 | ||
|
|
3697121c03 | ||
|
|
34c2496704 | ||
|
|
efff0eba39 | ||
|
|
47a5184ddc | ||
|
|
b1b16332e4 | ||
|
|
c7404b9d5e | ||
|
|
7e37f2ada6 | ||
|
|
9200fa2135 | ||
|
|
790f0e3db5 | ||
|
|
428ee56bd3 | ||
|
|
886cce1b9e | ||
|
|
73066b5ab3 | ||
|
|
9c04b5e9fc | ||
|
|
f5c1289681 |
@@ -1,7 +0,0 @@
|
|||||||
# 正式环境 - base
|
|
||||||
VITE_BASE_HOST=https://base.bestsch.com
|
|
||||||
VITE_SSO_HOST=https://sso.bestsch.com
|
|
||||||
VITE_SERVICE_HOST=https://base.bestsch.com/LearnCenter
|
|
||||||
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
|
|
||||||
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
|
|
||||||
VITE_IS_MOCK=false
|
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
# 开发环境 (dev)
|
# 开发环境 (dev):仅 vite dev 加载(--mode dev)。值可自由指本地或线上地址。
|
||||||
|
# build 产物不读此文件(build 默认 production mode,只认 .env / .env.production,两者均不存在),
|
||||||
|
# 线上环境差异全部由 projman 的 env-config-{目标}.json 承担。
|
||||||
|
#
|
||||||
|
# 本地起服务的三种模式(详见 vite.config.ts 的 server.proxy 注释):
|
||||||
|
# 纯前端 mock: VITE_IS_MOCK=true npm run dev → 用户/菜单走 src/assets/mock,其余接口透传给后端
|
||||||
|
# 本地后端联调: npm run dev → 跨源直连本文件 VITE_SERVICE_HOST
|
||||||
|
# 线上后端联调: VITE_SERVICE_HOST= YH_API_PROXY_TARGET=http://base.yunhaixingtong.com/userbase npm run dev
|
||||||
|
# (留空 VITE_SERVICE_HOST 让请求走同源 /api,由 dev 代理转发;CAS 登录 cookie 跨域带不过去时改用本地后端)
|
||||||
VITE_BASE_HOST=http://base.bestsch.com
|
VITE_BASE_HOST=http://base.bestsch.com
|
||||||
VITE_SSO_HOST=http://sso.bestsch.com
|
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
||||||
VITE_SERVICE_HOST=http://localhost:8060
|
VITE_SERVICE_HOST=http://localhost:8060
|
||||||
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
|
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
|
||||||
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
|
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
|
||||||
VITE_IS_MOCK=false
|
VITE_IS_MOCK=false
|
||||||
|
# 主题通道 dev 跨源直连(可选,需通道已开 CORS);留空则同源相对 → 走 vite 的 /static 代理
|
||||||
|
VITE_THEME_HOST=
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# 正式环境 - ld
|
|
||||||
VITE_BASE_HOST=https://ld.bestsch.com
|
|
||||||
VITE_SSO_HOST=https://sso.bestsch.com
|
|
||||||
VITE_SERVICE_HOST=https://ld.bestsch.com/LearnCenter
|
|
||||||
VITE_ZIYUANKU_HOST=https://ld.bestsch.com/BschZyk
|
|
||||||
VITE_ZHUANXIANG_HOST=https://ld.bestsch.com/BschZncpd
|
|
||||||
VITE_IS_MOCK=false
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# 正式环境 - 云海星通(部署在 base.yunhaixingtong.com/userbase)
|
|
||||||
VITE_BASE_HOST=http://base.yunhaixingtong.com
|
|
||||||
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
|
||||||
VITE_SERVICE_HOST=http://base.yunhaixingtong.com/userbase
|
|
||||||
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
|
|
||||||
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
|
|
||||||
VITE_IS_MOCK=false
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
name: build
|
||||||
|
|
||||||
|
# 前端应用 CI:构建 → 打包 zip → 推 Nexus raw → 通知 projman 建档
|
||||||
|
# 触发:push 到 main / 手动 Run workflow
|
||||||
|
# 链路:clone 源码 -> node 容器 npm ci + npm run build -> 打包 dist 为 zip -> 推 Nexus raw -> 通知 projman 建档
|
||||||
|
# 依赖仓库 Secrets:DEPLOY_TOKEN(webhook 鉴权)、NEXUS_USER / NEXUS_PASSWORD(上传 zip 到 Nexus raw)
|
||||||
|
# 注意:CI 构建后自动建档(status=pending),前端发布仍由用户在「前端发布」Tab 点部署触发
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: docker:24
|
||||||
|
options: --network infra-infra-net
|
||||||
|
steps:
|
||||||
|
- name: 安装 git / curl / zip(alpine)
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# runner 容器偶发拉不到公网 apk 源(dl-cdn.alpinelinux.org 报 Permission denied 并卡满 60s×2),
|
||||||
|
# 这里先按可达性挑一个能通的镜像源再装包,避免整条流水线卡死在第一步、后续步骤全被 skip
|
||||||
|
chosen=""
|
||||||
|
for host in mirrors.aliyun.com mirrors.huaweicloud.com mirrors.cloud.tencent.com dl-cdn.alpinelinux.org; do
|
||||||
|
if wget -q -T 6 -O /dev/null "https://${host}/alpine/v3.20/main/x86_64/APKINDEX.tar.gz"; then
|
||||||
|
chosen="${host}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "apk 源不可达: ${host}"
|
||||||
|
done
|
||||||
|
if [ -z "${chosen}" ]; then echo "所有 apk 源均不可达,请检查 runner 容器网络/代理"; exit 1; fi
|
||||||
|
echo "使用 apk 源: ${chosen}"
|
||||||
|
sed -i "s#dl-cdn.alpinelinux.org#${chosen}#g" /etc/apk/repositories
|
||||||
|
apk add --no-cache git curl zip
|
||||||
|
|
||||||
|
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||||
|
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src
|
||||||
|
cp -a /tmp/src/. .
|
||||||
|
rm -rf /tmp/src
|
||||||
|
# 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符
|
||||||
|
COMMIT_MSG=$(git log -1 --pretty=%s | tr '\n' ' ' | sed 's/["\\]/ /g' | cut -c1-200)
|
||||||
|
echo "COMMIT_MSG=${COMMIT_MSG}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
# 前端构建:起独立 node 容器(通过 docker.sock 调宿主 dockerd)
|
||||||
|
# node:20 镜像预装 npm,npm ci 按 lockfile 安装依赖
|
||||||
|
- name: 构建前端(node 容器)
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||||
|
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||||
|
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||||
|
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||||
|
BUILD_CONTAINER="frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||||
|
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||||
|
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||||
|
docker run -d --name "$BUILD_CONTAINER" -w /build node:20 sleep 3600
|
||||||
|
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||||
|
docker exec "$BUILD_CONTAINER" sh -c '
|
||||||
|
set -e
|
||||||
|
cd /build
|
||||||
|
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
|
||||||
|
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
|
||||||
|
npm config set registry https://registry.npmmirror.com
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
'
|
||||||
|
echo "=== dist 产物 ==="
|
||||||
|
docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
|
||||||
|
# 从容器 cp dist 到宿主工作目录
|
||||||
|
docker cp "$BUILD_CONTAINER":/build/dist ./dist
|
||||||
|
docker rm -f "$BUILD_CONTAINER"
|
||||||
|
echo "BUILD CHECK OK"
|
||||||
|
|
||||||
|
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
|
||||||
|
- name: 打包产物
|
||||||
|
run: |
|
||||||
|
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
|
ZIP_FILE="userbase-front-${TAG}.zip"
|
||||||
|
cd dist && zip -r "../${ZIP_FILE}" . && cd ..
|
||||||
|
echo "ZIP_FILE=${ZIP_FILE}" >> "$GITHUB_ENV"
|
||||||
|
echo "TAG=${TAG}" >> "$GITHUB_ENV"
|
||||||
|
ls -la "${ZIP_FILE}"
|
||||||
|
|
||||||
|
# 上传到 Nexus raw 仓库
|
||||||
|
- name: 上传产物到 Nexus
|
||||||
|
env:
|
||||||
|
NEXUS_USER: ${{ secrets.NEXUS_USER }}
|
||||||
|
NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
NEXUS_URL="http://nexus.yunhaixingtong.com:8081/repository/front-hosted/${ZIP_FILE}"
|
||||||
|
# -f:HTTP >=400 时 curl 以非零退出,仓库不存在/认证失败必须让 job 变红,否则建档的是不存在的 URL
|
||||||
|
curl -sSf -X PUT \
|
||||||
|
-u "${NEXUS_USER}:${NEXUS_PASSWORD}" \
|
||||||
|
--upload-file "${ZIP_FILE}" \
|
||||||
|
"${NEXUS_URL}"
|
||||||
|
echo "NEXUS_URL=${NEXUS_URL}" >> "$GITHUB_ENV"
|
||||||
|
echo "uploaded to ${NEXUS_URL}"
|
||||||
|
|
||||||
|
# 通知 projman:webhook 收到后建档(type=front, status=pending),部署由用户在「前端发布」Tab 手动触发
|
||||||
|
- name: 通知 projman 建档
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
curl -sSf -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Deploy-Token: ${{ secrets.DEPLOY_TOKEN }}" \
|
||||||
|
-d "{\"app\":\"userbase\",\"front\":\"front\",\"tag\":\"${TAG}\",\"sha\":\"${GITHUB_SHA}\",\"message\":\"${COMMIT_MSG}\",\"nexusUrl\":\"${NEXUS_URL}\"}"
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
name: deploy-userbase-front
|
|
||||||
|
|
||||||
# 前端部署(方案 B:不打包镜像,直接部署静态文件 + 软链接版本控制)
|
|
||||||
# 触发:push 到 main(已启用仓库 Actions,push 自动部署)/ 手动 Run workflow
|
|
||||||
# 核心:vite build --mode prod -> dist/prod
|
|
||||||
# -> 拷贝到 /data/www/userbase/releases/<version>/
|
|
||||||
# -> 原子切换 /data/www/userbase/front 软链接指向新版本
|
|
||||||
# -> 保留最近 5 个版本,更早自动清理(秒级回滚)
|
|
||||||
# 不再构建/推送 nginx 镜像;Caddy 已改为 file_server(root=/srv/userbase=/data/www/userbase)。
|
|
||||||
# 依赖:160 宿主的 docker daemon(runner 已挂 docker.sock),且 runner valid_volumes 已放行 /data/www/**。无需 NEXUS secret。
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# docker:24 自带 docker CLI,复用宿主 docker daemon(act_runner v0.6.1 已挂载 docker.sock)。
|
|
||||||
# 切勿再声明 volumes: docker.sock(会报 Duplicate mount point)。
|
|
||||||
container:
|
|
||||||
image: docker:24
|
|
||||||
steps:
|
|
||||||
- name: Install git (alpine)
|
|
||||||
run: apk add --no-cache git
|
|
||||||
|
|
||||||
# 内网 Gitea 无法访问 github.com 拉取 actions/checkout,改为直接 clone 本仓库
|
|
||||||
- name: Checkout (manual, internal Gitea)
|
|
||||||
run: |
|
|
||||||
git clone --depth 1 --branch main http://192.168.10.160:3000/${GITHUB_REPOSITORY}.git /tmp/src
|
|
||||||
cp -a /tmp/src/. .
|
|
||||||
rm -rf /tmp/src
|
|
||||||
|
|
||||||
# 构建 + 部署:在 node:20 容器里完成(源码经 docker cp 传入,并挂载宿主 /data/www/userbase)
|
|
||||||
- name: Build & Deploy (symlink versioning)
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
# 版本号:short sha + 时间戳(可改为 git tag 优先,见下)
|
|
||||||
VERSION="$(echo ${GITHUB_SHA} | cut -c1-7)-$(date +%Y%m%d-%H%M)"
|
|
||||||
echo "Deploying version: $VERSION"
|
|
||||||
|
|
||||||
docker rm -f frontbuild 2>/dev/null || true
|
|
||||||
docker run -d --name frontbuild \
|
|
||||||
-e VERSION="$VERSION" \
|
|
||||||
-v /data/www/userbase:/data/www/userbase \
|
|
||||||
-w /build node:20 sleep 3600
|
|
||||||
|
|
||||||
# 源码拷入构建容器(绕过 act 工作区非宿主路径的坑,与 service 的 mavenbuild 一致)
|
|
||||||
docker cp "${PWD}/." frontbuild:/build/
|
|
||||||
|
|
||||||
docker exec frontbuild sh -c '
|
|
||||||
set -e
|
|
||||||
cd /build
|
|
||||||
# 去掉 Windows 专用 sass 二进制路径(Linux 容器内该路径无效,否则 build 报错)
|
|
||||||
# .npmrc 被 gitignore、CI 克隆下来没有它,故仅在存在时才删(缺失不报错)
|
|
||||||
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
|
|
||||||
npm ci
|
|
||||||
npm run build:prod
|
|
||||||
'
|
|
||||||
|
|
||||||
# 部署:首次把真实目录 front 迁入 releases/initial 并建软链;之后均为软链替换
|
|
||||||
docker exec frontbuild sh -c '
|
|
||||||
set -e
|
|
||||||
cd /build
|
|
||||||
# ---- 首次初始化:front 当前是真实目录,无法直接 ln -sfn 覆盖 ----
|
|
||||||
if [ ! -L /data/www/userbase/front ]; then
|
|
||||||
mkdir -p /data/www/userbase/releases
|
|
||||||
mv /data/www/userbase/front /data/www/userbase/releases/initial
|
|
||||||
# 相对软链:Caddy 容器内 /data/www/userbase 挂在 /srv/userbase,
|
|
||||||
# 绝对路径在容器内解析不到,必须用相对路径(相对 front 所在目录)
|
|
||||||
ln -sfn releases/initial /data/www/userbase/front
|
|
||||||
echo "INIT: front -> releases/initial (保留上线前状态用于回滚)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p /data/www/userbase/releases/$VERSION
|
|
||||||
cp -r /build/dist/prod/. /data/www/userbase/releases/$VERSION/
|
|
||||||
# 原子切换:front 此时已是软链接,ln -sfn 直接替换指向(必须用相对路径)
|
|
||||||
ln -sfn releases/$VERSION /data/www/userbase/front
|
|
||||||
echo "current symlink ->"; ls -la /data/www/userbase/front
|
|
||||||
# 清理旧版本,保留最近 5 个(initial 也会被计入,最多保留 5 个含 initial)
|
|
||||||
cd /data/www/userbase/releases
|
|
||||||
ls -1t | tail -n +6 | while read d; do echo "rm old: $d"; rm -rf "$d"; done
|
|
||||||
echo "remaining versions:"; ls -1t
|
|
||||||
'
|
|
||||||
|
|
||||||
docker rm -f frontbuild
|
|
||||||
echo "DEPLOY OK: $VERSION"
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# UserbaseFront
|
||||||
|
|
||||||
|
> 本仓库由 projman 自动创建并注入 CI(`.gitea/workflows/build.yml`)与 Dockerfile(`docker/Dockerfile`)。
|
||||||
|
> 推送代码到 main 分支即自动构建镜像并推送到 Nexus,projman 收到通知后建档待部署,由运维在 projman「后端发布」手动点部署。
|
||||||
|
|
||||||
|
## 首次推送本地代码到本仓库
|
||||||
|
|
||||||
|
> 目标:把本地已有代码推上去,同时**保留** projman 注入的文件(`build.yml`、`Dockerfile`)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 进入本地代码目录
|
||||||
|
cd /your/local/code/dir
|
||||||
|
|
||||||
|
# 2. 初始化 git(目录已有 .git 也安全,git init 幂等无副作用)
|
||||||
|
git init
|
||||||
|
git branch -M main
|
||||||
|
|
||||||
|
# 3. 先把本地代码提交成一个 commit(关键!不提交直接 merge 会丢失本地代码)
|
||||||
|
git add -A
|
||||||
|
git commit -m "init local code"
|
||||||
|
|
||||||
|
# 4. 关联远程仓库(幂等:已存在 origin 则改地址,不报错)
|
||||||
|
git remote add origin http://gitea.yunhaixingtong.com/userbase/UserbaseFront.git 2>/dev/null \
|
||||||
|
|| git remote set-url origin http://gitea.yunhaixingtong.com/userbase/UserbaseFront.git
|
||||||
|
|
||||||
|
# 5. 拉取远程信息(只下载,不合并)
|
||||||
|
git fetch origin
|
||||||
|
|
||||||
|
# 6. 合并远程 main(关键步骤)
|
||||||
|
# 本地与远程是两条独立历史,需允许合并不相关历史。
|
||||||
|
# 此步会把 projman 注入的 build.yml 与 Dockerfile 合并进你的本地工作区。
|
||||||
|
git merge origin/main --allow-unrelated-histories -m "merge projman injected files"
|
||||||
|
|
||||||
|
# 7. 查看状态(确认 .gitea/workflows/build.yml、docker/Dockerfile 已在本地)
|
||||||
|
git status
|
||||||
|
|
||||||
|
# 8. 提交并推送
|
||||||
|
git add -A
|
||||||
|
git commit -m "sync local code" 2>/dev/null || true
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## 推送后验证
|
||||||
|
|
||||||
|
push 触发 CI 构建,前往 Gitea 仓库 → **Actions** 查看日志;构建成功后镜像推送到 Nexus,projman 建档待部署,在 projman 应用详情「后端发布」手动点部署。
|
||||||
|
|
||||||
|
## 禁止写法(会导致注入文件被删)
|
||||||
|
|
||||||
|
`git reset origin/main` + `git add -A` 会把远程已注入、但你本地没有的文件(`build.yml`、`Dockerfile`)当作"删除"提交并推送,导致这些文件被删、CI 失败。务必用 `git merge` 而非 `git reset`,且 merge 前先提交本地代码。
|
||||||
|
|
||||||
|
## 关于 Dockerfile
|
||||||
|
|
||||||
|
projman 注入的 Dockerfile 仅在仓库不存在时写入;你自备的 Dockerfile 优先,不会被覆盖。
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
//铃铛debug
|
|
||||||
window.conf = {
|
|
||||||
baseHost: "http://basedebug.bestsch.com",
|
|
||||||
ssoHost: "http://ssodebug.bestsch.com",
|
|
||||||
serviceHost: "http://basedebug.bestsch.com/QualityEvaluate",
|
|
||||||
};
|
|
||||||
+42
@@ -5,6 +5,48 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="../../favicon.ico" />
|
<link rel="icon" type="image/svg+xml" href="../../favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- 运行时配置引导:在任何应用模块执行前同步拉取 /<应用>/<前端目标>/api/env-config
|
||||||
|
(projman 配置管理在线维护、双写 Nacos),替代 config/index.js 与生产构建环境变量。
|
||||||
|
必须同步:host.config.ts 在模块求值期就读 window.__APP_CONFIG__;失败/本地开发
|
||||||
|
(路径不足两段)则不设置:本地走 .env.dev(import.meta.env.VITE_*),线上走同域推导兜底。
|
||||||
|
注意:同步 XHR 不支持 timeout 属性(赋值会抛 InvalidAccessError),依赖内网快速失败 -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var seg = location.pathname.split("/").filter(Boolean);
|
||||||
|
if (seg.length < 2) return; // 本地开发(根路径):无应用上下文,走 .env.dev
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("GET", "/" + seg[0] + "/api/env-config?target=" + encodeURIComponent(seg[1]), false);
|
||||||
|
xhr.send(null);
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
var conf = JSON.parse(xhr.responseText);
|
||||||
|
if (conf && typeof conf === "object") window.__APP_CONFIG__ = conf;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* 拉取失败保持未设置,走同域推导兜底 */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<!-- 主题通道引导(A 类免插件接入,见 YschTheme《管理端主题远程化与公共静态通道方案》§11):
|
||||||
|
唯一接入点。不装 npm 依赖、不改构建配置;FOUC 门、manifest 预取、主题 <link> 追加与
|
||||||
|
排序(晚于 viewuiplus.css)全由通道 yh-theme-init.js 承担,主题升级走 /static 通道,
|
||||||
|
本应用无需重新构建。
|
||||||
|
origin 零写死,优先级四档:__APP_CONFIG__.staticHost(专用通道覆盖)
|
||||||
|
> __APP_CONFIG__.baseHost(env-config 在线维护,换域名改配置即生效)
|
||||||
|
> %VITE_THEME_HOST%(仅 dev 注入的跨源直连,需通道已开 CORS)
|
||||||
|
> 同源相对 "/static/…"(生产常态:本应用部署在 base 域 /userbase/front/,与通道同源;
|
||||||
|
本地 dev 由 vite.config 的 /static 代理兜到通道)。
|
||||||
|
document.write 是有意为之:保证 parser-blocking,让档位类与 FOUC 门先于首帧;
|
||||||
|
build 不读 .env,占位符留字面量 → 判首字符 % 置空 → 产物零环境值烧入。 -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var c = window.__APP_CONFIG__ || {};
|
||||||
|
var themeHost = "%VITE_THEME_HOST%";
|
||||||
|
if (themeHost.charAt(0) === "%") themeHost = "";
|
||||||
|
var host = String(c.staticHost || c.baseHost || themeHost).replace(/\/+$/, "");
|
||||||
|
document.write('<script src="' + host + '/static/js/yh-theme-init.js"><\/script>');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<title><%- title %></title>
|
<title><%- title %></title>
|
||||||
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
|
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
|
||||||
<script src="./libs/jquery.min.js"></script>
|
<script src="./libs/jquery.min.js"></script>
|
||||||
|
|||||||
Generated
+4
-2988
File diff suppressed because it is too large
Load Diff
+4
-5
@@ -6,10 +6,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --mode dev",
|
"dev": "vite --mode dev",
|
||||||
"build": "vite build --mode base",
|
"build": "vite build",
|
||||||
"build:base": "vite build --mode base",
|
|
||||||
"build:ld": "vite build --mode ld",
|
|
||||||
"build:prod": "vite build --mode prod",
|
"build:prod": "vite build --mode prod",
|
||||||
|
"build:ld": "vite build --mode ld",
|
||||||
|
"build:base": "vite build --mode base",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"api:gen": "alova gen -f"
|
"api:gen": "alova gen -f"
|
||||||
},
|
},
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"mockjs": "^1.1.0",
|
"mockjs": "^1.1.0",
|
||||||
"pinia": "^2.1.4",
|
"pinia": "^2.1.4",
|
||||||
"sass": "^1.64.1",
|
"sass": "^1.64.1",
|
||||||
"view-ui-plus": "^1.3.16",
|
"view-ui-plus": "^1.3.24",
|
||||||
"vite-plugin-mock": "2.9.6",
|
"vite-plugin-mock": "2.9.6",
|
||||||
"vue": "^3.4.19",
|
"vue": "^3.4.19",
|
||||||
"vue-router": "4",
|
"vue-router": "4",
|
||||||
@@ -35,7 +35,6 @@
|
|||||||
"@unocss/preset-uno": "^66.6.0",
|
"@unocss/preset-uno": "^66.6.0",
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
"sass-loader": "^10.0.1",
|
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"unocss": "^66.6.0",
|
"unocss": "^66.6.0",
|
||||||
"unplugin-auto-import": "0.18.3",
|
"unplugin-auto-import": "0.18.3",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
+40
-9
@@ -10,6 +10,7 @@ import Apis from '@/api/index'
|
|||||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||||
import { UserRoleEnum } from "./types";
|
import { UserRoleEnum } from "./types";
|
||||||
|
import { isRouteAllowed } from "@/router";
|
||||||
const store = useDefaultStore();
|
const store = useDefaultStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { $bus } = useCurrentInstance();
|
const { $bus } = useCurrentInstance();
|
||||||
@@ -53,10 +54,9 @@ const layoutInit = (info: any, modList: any[], appList: Array<any>, baseHost: st
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 角色判定
|
* 角色判定(降级兜底):GetUserScope 失败时按 GetUserInfo 的 user.type 推断——
|
||||||
* 现基于 GetUserInfo 返回的 user.type 判定角色:
|
* type=1 → SuperAdmin,其余 → School。同时写入 currentRole 供下游
|
||||||
* type=1 → SuperAdmin,type=2 → School(不再有 Teacher 角色)
|
* Utils.getCurrentRoleInfo 与请求头注入读取(保持兼容;正常路径以 GetUserScope 为准)。
|
||||||
* 同时写入 currentRole 供下游 Utils.getCurrentRoleInfo 读取(保持兼容)
|
|
||||||
*/
|
*/
|
||||||
const setCurrentRole = (user: any) => {
|
const setCurrentRole = (user: any) => {
|
||||||
const userId = user?.id ?? null;
|
const userId = user?.id ?? null;
|
||||||
@@ -70,7 +70,7 @@ const setCurrentRole = (user: any) => {
|
|||||||
};
|
};
|
||||||
localStorage.setItem("currentRole", encodeURI(JSON.stringify(currentRole)));
|
localStorage.setItem("currentRole", encodeURI(JSON.stringify(currentRole)));
|
||||||
|
|
||||||
// 主题样式已本地化到 src/assets/styles/layout.css(含 default-theme / base.layout.beta / _iview_override),不再请求远程样式
|
// 主题样式由中央静态通道下发(index.html 的 yh-theme-init.js 挂 /static/*),本仓不再持有 layout.css
|
||||||
|
|
||||||
if (user?.type === 1) {
|
if (user?.type === 1) {
|
||||||
store.setUserRole(UserRoleEnum.SuperAdmin);
|
store.setUserRole(UserRoleEnum.SuperAdmin);
|
||||||
@@ -87,12 +87,43 @@ onMounted(async () => {
|
|||||||
try {
|
try {
|
||||||
var user: any = await alovaInstance.Request(Apis.Base.getUserInfo());
|
var user: any = await alovaInstance.Request(Apis.Base.getUserInfo());
|
||||||
|
|
||||||
// 系统字典 & 模块列表(模块当前写死,后续接入接口)
|
|
||||||
await store.fetchSysDict();
|
|
||||||
await store.fetchModules();
|
|
||||||
|
|
||||||
currentUser.value = user;
|
currentUser.value = user;
|
||||||
|
|
||||||
|
// 系统字典
|
||||||
|
await store.fetchSysDict();
|
||||||
|
|
||||||
|
// 角色判定:优先 GetUserScope(后端单点判定四级访问级别,前端不重复实现规则),
|
||||||
|
// 失败时降级走 setCurrentRole 的 type 推断
|
||||||
setCurrentRole(user);
|
setCurrentRole(user);
|
||||||
|
try {
|
||||||
|
const scope: any = await alovaInstance.Request(Apis.Base.getUserScope());
|
||||||
|
if (scope && scope.level != null) {
|
||||||
|
store.setUserRole(scope.level);
|
||||||
|
store.setUserScope({
|
||||||
|
bureauGroupId: scope.bureauGroupId ?? null,
|
||||||
|
schoolGroupIds: scope.schoolGroupIds ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("获取访问范围失败,按用户类型降级判定角色", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NONE 级:整体「暂无权限」,不加载菜单(接口层已被后端 NONE 拦截器 403 双挡)
|
||||||
|
store.setRoleLoaded(true);
|
||||||
|
if (store.userRole === UserRoleEnum.None) {
|
||||||
|
isNoAuthority.value = true;
|
||||||
|
layoutInit(user, [], [], baseHost, ssoHost);
|
||||||
|
isLogin.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次导航早于角色落定被守卫放行:此处复核当前路由(直接敲 URL 场景),越权重定向 /no-auth
|
||||||
|
if (!isRouteAllowed(router.currentRoute.value, store.userRole)) {
|
||||||
|
router.replace("/no-auth");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模块列表(按当前级别过滤菜单权限矩阵)
|
||||||
|
await store.fetchModules();
|
||||||
layoutInit(user, store.modules, store.modules, baseHost, ssoHost);
|
layoutInit(user, store.modules, store.modules, baseHost, ssoHost);
|
||||||
|
|
||||||
// 登录后直接跳转到左侧菜单第一个可用入口
|
// 登录后直接跳转到左侧菜单第一个可用入口
|
||||||
|
|||||||
@@ -30,11 +30,30 @@ export default {
|
|||||||
'SysGroup.move': ['POST', '/api/sysgroup/Move'],
|
'SysGroup.move': ['POST', '/api/sysgroup/Move'],
|
||||||
'SysGroup.sysGroupEdit': ['POST', '/api/sysgroup/Edit/{id}'],
|
'SysGroup.sysGroupEdit': ['POST', '/api/sysgroup/Edit/{id}'],
|
||||||
'SysGroup.sysGroupRemove': ['POST', '/api/sysgroup/Delete/{id}'],
|
'SysGroup.sysGroupRemove': ['POST', '/api/sysgroup/Delete/{id}'],
|
||||||
|
'SysGroup.disable': ['POST', '/api/sysgroup/Disable/{id}'],
|
||||||
|
'SysGroup.enable': ['POST', '/api/sysgroup/Enable/{id}'],
|
||||||
|
'SysGroup.setIncludeChildren': ['POST', '/api/sysgroup/SetIncludeChildren/{id}'],
|
||||||
'SysGroup.batchAdd': ['POST', '/api/sysgroup/BatchAdd'],
|
'SysGroup.batchAdd': ['POST', '/api/sysgroup/BatchAdd'],
|
||||||
'SysGroup.sysGroupAdd': ['POST', '/api/sysgroup/Add'],
|
'SysGroup.sysGroupAdd': ['POST', '/api/sysgroup/Add'],
|
||||||
|
'CustomDeptCategory.customDeptCategoryList': ['GET', '/api/deptcategory/List'],
|
||||||
|
'CustomDeptCategory.customDeptCategoryAdd': ['POST', '/api/deptcategory/Add'],
|
||||||
|
'CustomDeptCategory.customDeptCategoryEdit': ['POST', '/api/deptcategory/Edit/{id}'],
|
||||||
|
'CustomDeptCategory.customDeptCategorySort': ['POST', '/api/deptcategory/Sort'],
|
||||||
|
'CustomDeptCategory.customDeptCategoryDelete': ['POST', '/api/deptcategory/Delete/{id}'],
|
||||||
|
'CustomDept.customDeptList': ['GET', '/api/customdept/List'],
|
||||||
|
'CustomDept.customDeptAdd': ['POST', '/api/customdept/Add'],
|
||||||
|
'CustomDept.customDeptEdit': ['POST', '/api/customdept/Edit/{id}'],
|
||||||
|
'CustomDept.customDeptMove': ['POST', '/api/customdept/Move/{id}'],
|
||||||
|
'CustomDept.customDeptSort': ['POST', '/api/customdept/Sort'],
|
||||||
|
'CustomDept.customDeptDisable': ['POST', '/api/customdept/Disable/{id}'],
|
||||||
|
'CustomDept.customDeptEnable': ['POST', '/api/customdept/Enable/{id}'],
|
||||||
|
'CustomDept.customDeptDelete': ['POST', '/api/customdept/Delete/{id}'],
|
||||||
|
'CustomDept.customDeptMembers': ['GET', '/api/customdept/Members/{id}'],
|
||||||
|
'CustomDept.customDeptCandidateUsers': ['GET', '/api/customdept/CandidateUsers/{id}'],
|
||||||
|
'CustomDept.customDeptAddMembers': ['POST', '/api/customdept/AddMembers/{id}'],
|
||||||
|
'CustomDept.customDeptRemoveMembers': ['POST', '/api/customdept/RemoveMembers/{id}'],
|
||||||
'PushSubscription.test': ['POST', '/api/push/Test'],
|
'PushSubscription.test': ['POST', '/api/push/Test'],
|
||||||
'PushSubscription.retry': ['POST', '/api/push/Retry/{id}'],
|
'PushSubscription.retry': ['POST', '/api/push/Retry/{id}'],
|
||||||
'Identity.switchIdentity': ['POST', '/api/identity/switch'],
|
|
||||||
'GroupAuthz.setSchoolApp': ['POST', '/api/groupauthz/SetSchoolApp'],
|
'GroupAuthz.setSchoolApp': ['POST', '/api/groupauthz/SetSchoolApp'],
|
||||||
'GroupAuthz.setGroupModule': ['POST', '/api/groupauthz/SetGroupModule'],
|
'GroupAuthz.setGroupModule': ['POST', '/api/groupauthz/SetGroupModule'],
|
||||||
'GroupAuthz.saveModulePerms': ['POST', '/api/groupauthz/SaveModulePerms'],
|
'GroupAuthz.saveModulePerms': ['POST', '/api/groupauthz/SaveModulePerms'],
|
||||||
@@ -72,6 +91,7 @@ export default {
|
|||||||
'Group.add_2': ['POST', '/oapi/group/add'],
|
'Group.add_2': ['POST', '/oapi/group/add'],
|
||||||
'SysUser.list': ['GET', '/api/sysuser/List'],
|
'SysUser.list': ['GET', '/api/sysuser/List'],
|
||||||
'SysUser.getUserGroups': ['GET', '/api/sysuser/Groups/{userId}'],
|
'SysUser.getUserGroups': ['GET', '/api/sysuser/Groups/{userId}'],
|
||||||
|
'SysUser.userIdentity': ['GET', '/api/sysuser/UserIdentity'],
|
||||||
'SysUser.export_': ['GET', '/api/sysuser/Export'],
|
'SysUser.export_': ['GET', '/api/sysuser/Export'],
|
||||||
'SysUser.downloadTemplate': ['GET', '/api/sysuser/DownloadTemplate'],
|
'SysUser.downloadTemplate': ['GET', '/api/sysuser/DownloadTemplate'],
|
||||||
'SysGroup.sysGroupList': ['GET', '/api/sysgroup/List'],
|
'SysGroup.sysGroupList': ['GET', '/api/sysgroup/List'],
|
||||||
@@ -83,10 +103,9 @@ export default {
|
|||||||
'SysOperationLog.operationLogDownload': ['GET', '/api/oplog/Download/{id}'],
|
'SysOperationLog.operationLogDownload': ['GET', '/api/oplog/Download/{id}'],
|
||||||
'SysOapiLog.oapiLogList': ['GET', '/api/oapilog/List'],
|
'SysOapiLog.oapiLogList': ['GET', '/api/oapilog/List'],
|
||||||
'SysOapiLog.oapiLogDownload': ['GET', '/api/oapilog/Download/{id}'],
|
'SysOapiLog.oapiLogDownload': ['GET', '/api/oapilog/Download/{id}'],
|
||||||
'Identity.current': ['GET', '/api/identity/current'],
|
|
||||||
'Identity.available': ['GET', '/api/identity/available'],
|
|
||||||
'GroupAuthz.userRoles': ['GET', '/api/groupauthz/UserRoles'],
|
'GroupAuthz.userRoles': ['GET', '/api/groupauthz/UserRoles'],
|
||||||
'GroupAuthz.userPermissionTree': ['GET', '/api/groupauthz/UserPermissionTree'],
|
'GroupAuthz.userPermissionTree': ['GET', '/api/groupauthz/UserPermissionTree'],
|
||||||
|
'GroupAuthz.groupPermissionTree': ['GET', '/api/groupauthz/GroupPermissionTree'],
|
||||||
'GroupAuthz.userGroupTree': ['GET', '/api/groupauthz/UserGroupTree'],
|
'GroupAuthz.userGroupTree': ['GET', '/api/groupauthz/UserGroupTree'],
|
||||||
'GroupAuthz.searchUsers': ['GET', '/api/groupauthz/SearchUsers'],
|
'GroupAuthz.searchUsers': ['GET', '/api/groupauthz/SearchUsers'],
|
||||||
'GroupAuthz.hasModulePerm': ['GET', '/api/groupauthz/HasModulePerm'],
|
'GroupAuthz.hasModulePerm': ['GET', '/api/groupauthz/HasModulePerm'],
|
||||||
@@ -98,7 +117,9 @@ export default {
|
|||||||
'Base.RedirectTo': ['GET', '/api/base/RedirectTo/{id}'],
|
'Base.RedirectTo': ['GET', '/api/base/RedirectTo/{id}'],
|
||||||
'Base.manualHashCacheDemo': ['GET', '/api/base/ManualHashCacheDemo'],
|
'Base.manualHashCacheDemo': ['GET', '/api/base/ManualHashCacheDemo'],
|
||||||
'Base.getUserInfo': ['GET', '/api/base/GetUserInfo'],
|
'Base.getUserInfo': ['GET', '/api/base/GetUserInfo'],
|
||||||
|
'Base.getUserScope': ['GET', '/api/base/GetUserScope'],
|
||||||
'Base.getSysDict': ['GET', '/api/base/GetSysDict'],
|
'Base.getSysDict': ['GET', '/api/base/GetSysDict'],
|
||||||
|
'AppSettings.schoolList': ['GET', '/api/app/SchoolList'],
|
||||||
'AppRole.roleUsers': ['GET', '/api/approle/RoleUsers'],
|
'AppRole.roleUsers': ['GET', '/api/approle/RoleUsers'],
|
||||||
'AppRole.roleModuleTree': ['GET', '/api/approle/RoleModuleTree'],
|
'AppRole.roleModuleTree': ['GET', '/api/approle/RoleModuleTree'],
|
||||||
'AppRole.roleList': ['GET', '/api/approle/RoleList'],
|
'AppRole.roleList': ['GET', '/api/approle/RoleList'],
|
||||||
|
|||||||
Vendored
+729
-106
@@ -114,10 +114,17 @@ export interface SysUserRequest {
|
|||||||
email?: string;
|
email?: string;
|
||||||
gender?: number;
|
gender?: number;
|
||||||
profilePicture?: string;
|
profilePicture?: string;
|
||||||
roles?: number[];
|
/** 身份归属学校(schoolId 解析后的真实组 id;编辑必传,新增可不传按 groupId 解析) */
|
||||||
|
schoolId?: number;
|
||||||
|
/** 用户身份角色(SysRoleEnum.key 1-7),按 schoolId 全量覆盖保存;空数组=清空该校身份 */
|
||||||
|
roleIds?: number[];
|
||||||
disabled?: number;
|
disabled?: number;
|
||||||
expired?: boolean;
|
expired?: boolean;
|
||||||
}
|
}
|
||||||
|
export interface UserIdentityVo {
|
||||||
|
schoolId?: number;
|
||||||
|
roleIds?: number[];
|
||||||
|
}
|
||||||
export interface SysGroupSortRequest {
|
export interface SysGroupSortRequest {
|
||||||
pid: number;
|
pid: number;
|
||||||
ids: number[];
|
ids: number[];
|
||||||
@@ -141,15 +148,64 @@ export interface SysGroupRequest {
|
|||||||
type: number;
|
type: number;
|
||||||
sort?: number;
|
sort?: number;
|
||||||
}
|
}
|
||||||
|
export interface SysGroupIncludeChildrenRequest {
|
||||||
|
includeChildren: boolean;
|
||||||
|
}
|
||||||
|
export interface CustomDeptCategoryRequest {
|
||||||
|
schoolId?: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
export interface CustomDeptCategorySortRequest {
|
||||||
|
schoolId?: number;
|
||||||
|
ids: number[];
|
||||||
|
}
|
||||||
|
export interface CustomDeptCategoryVo {
|
||||||
|
id?: number;
|
||||||
|
schoolId?: number;
|
||||||
|
name?: string;
|
||||||
|
sortNo?: number;
|
||||||
|
/** 该分类下有效部门数;>0 时分类不允许删除 */
|
||||||
|
departmentCount?: number;
|
||||||
|
}
|
||||||
|
export interface CustomDepartmentRequest {
|
||||||
|
schoolId?: number;
|
||||||
|
categoryId?: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
export interface CustomDepartmentMoveRequest {
|
||||||
|
schoolId?: number;
|
||||||
|
categoryId: number;
|
||||||
|
}
|
||||||
|
export interface CustomDepartmentSortRequest {
|
||||||
|
schoolId?: number;
|
||||||
|
categoryId?: number;
|
||||||
|
ids: number[];
|
||||||
|
}
|
||||||
|
export interface CustomDepartmentMemberRequest {
|
||||||
|
schoolId?: number;
|
||||||
|
userIds: number[];
|
||||||
|
}
|
||||||
|
export interface CustomDepartmentMemberVo {
|
||||||
|
userId?: number;
|
||||||
|
account?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
export interface CustomDepartmentVo {
|
||||||
|
id?: number;
|
||||||
|
schoolId?: number;
|
||||||
|
categoryId?: number;
|
||||||
|
categoryName?: string;
|
||||||
|
name?: string;
|
||||||
|
sortNo?: number;
|
||||||
|
/** true=启用 / false=停用(后端 CUSTOM 侧统一输出 boolean) */
|
||||||
|
enabled?: boolean;
|
||||||
|
memberCount?: number;
|
||||||
|
}
|
||||||
export interface PushTestRequest {
|
export interface PushTestRequest {
|
||||||
appId?: number;
|
appId?: number;
|
||||||
interfacePath?: string;
|
interfacePath?: string;
|
||||||
targetIds?: number[];
|
targetIds?: number[];
|
||||||
}
|
}
|
||||||
export interface IdentitySwitchRequest {
|
|
||||||
roleId: number;
|
|
||||||
schoolId: number;
|
|
||||||
}
|
|
||||||
export interface SchoolAppAuthzRequest {
|
export interface SchoolAppAuthzRequest {
|
||||||
schid: number;
|
schid: number;
|
||||||
appid: number;
|
appid: number;
|
||||||
@@ -176,11 +232,18 @@ export interface GroupAppAssignRequest {
|
|||||||
}
|
}
|
||||||
export interface UserAppRoleBindRequest {
|
export interface UserAppRoleBindRequest {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
/** 绑定组ID(可选):角色归属的组,空则后端兜底主组 */
|
||||||
|
groupId?: number;
|
||||||
roleIds?: number[];
|
roleIds?: number[];
|
||||||
}
|
}
|
||||||
|
export interface AppRoleUserBinding {
|
||||||
|
userId: number;
|
||||||
|
/** 绑定发生时所选的组ID */
|
||||||
|
groupId: number;
|
||||||
|
}
|
||||||
export interface AppRoleUserBindRequest {
|
export interface AppRoleUserBindRequest {
|
||||||
roleId: number;
|
roleId: number;
|
||||||
userIds?: number[];
|
bindings?: AppRoleUserBinding[];
|
||||||
}
|
}
|
||||||
export interface AppRoleModuleAssignRequest {
|
export interface AppRoleModuleAssignRequest {
|
||||||
roleId: number;
|
roleId: number;
|
||||||
@@ -213,6 +276,8 @@ export interface ApplicationInfoRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
desc?: string;
|
desc?: string;
|
||||||
|
appCode?: string;
|
||||||
|
appNature?: string;
|
||||||
clients?: number[];
|
clients?: number[];
|
||||||
pcUrl?: string;
|
pcUrl?: string;
|
||||||
pcIcon?: string;
|
pcIcon?: string;
|
||||||
@@ -220,6 +285,7 @@ export interface ApplicationInfoRequest {
|
|||||||
appIcon?: string;
|
appIcon?: string;
|
||||||
officialAccountUrl?: string;
|
officialAccountUrl?: string;
|
||||||
dingtalkUrl?: string;
|
dingtalkUrl?: string;
|
||||||
|
miniprogramUrl?: string;
|
||||||
}
|
}
|
||||||
export interface SysUserMoveRequest {
|
export interface SysUserMoveRequest {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -231,22 +297,16 @@ export interface SysGroupVo {
|
|||||||
name?: string;
|
name?: string;
|
||||||
type?: number;
|
type?: number;
|
||||||
sort?: number;
|
sort?: number;
|
||||||
|
/** 启停状态:0=启用 / 1=停用(存量 null 按启用处理) */
|
||||||
|
status?: number;
|
||||||
|
/** 包含子组:1=含下级部门 / 0=仅本级(仅学校节点有值) */
|
||||||
|
includeChildren?: number;
|
||||||
|
/** 直属成员数(部门表格「人员」列;与服务端 SysGroupVo 同步,api:gen 后一样保留) */
|
||||||
|
memberCount?: number;
|
||||||
|
/** 直属成员姓名预览(最多 50 个),完整名单走 /api/sysuser/List?groupId= */
|
||||||
|
memberNames?: string[];
|
||||||
children?: SysGroupVo[];
|
children?: SysGroupVo[];
|
||||||
}
|
}
|
||||||
export interface IdentityCurrentVo {
|
|
||||||
roleId?: number;
|
|
||||||
roleName?: string;
|
|
||||||
schoolId?: number;
|
|
||||||
schoolName?: string;
|
|
||||||
fallback?: boolean;
|
|
||||||
}
|
|
||||||
export interface IdentityOptionVo {
|
|
||||||
roleId?: number;
|
|
||||||
roleName?: string;
|
|
||||||
schoolId?: number;
|
|
||||||
schoolName?: string;
|
|
||||||
isMain?: boolean;
|
|
||||||
}
|
|
||||||
export interface AppItem {
|
export interface AppItem {
|
||||||
id?: number;
|
id?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -315,10 +375,15 @@ export interface SysUserVo {
|
|||||||
email?: string;
|
email?: string;
|
||||||
gender?: number;
|
gender?: number;
|
||||||
profilePicture?: string;
|
profilePicture?: string;
|
||||||
roles?: number[];
|
|
||||||
disabled?: number;
|
disabled?: number;
|
||||||
type?: number;
|
type?: number;
|
||||||
sortNo?: number;
|
sortNo?: number;
|
||||||
|
groupStatus?: number;
|
||||||
|
/**
|
||||||
|
* 用户身份名(如「老师、校领导」):列表「用户身份」列展示字段。
|
||||||
|
* 组上下文列表只含「当前组范围解析出的学校」下的身份(与编辑弹窗「所在组」同源);未选组才全量。
|
||||||
|
*/
|
||||||
|
identityNames?: string;
|
||||||
expired?: boolean;
|
expired?: boolean;
|
||||||
createTime?: string;
|
createTime?: string;
|
||||||
updateTime?: string;
|
updateTime?: string;
|
||||||
@@ -685,6 +750,10 @@ export interface AppRoleUserVo {
|
|||||||
id?: number;
|
id?: number;
|
||||||
account?: string;
|
account?: string;
|
||||||
realName?: string;
|
realName?: string;
|
||||||
|
/** 绑定发生时所选的组ID */
|
||||||
|
groupId?: number;
|
||||||
|
/** 组路径(查询时按 groupId 现算,scope 裁剪后) */
|
||||||
|
groupPath?: string;
|
||||||
}
|
}
|
||||||
export interface AppRoleAuthzModuleVo {
|
export interface AppRoleAuthzModuleVo {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -761,6 +830,8 @@ export interface ApplicationInfoVo {
|
|||||||
desc?: string;
|
desc?: string;
|
||||||
appKey?: number;
|
appKey?: number;
|
||||||
appSecret?: string;
|
appSecret?: string;
|
||||||
|
appCode?: string;
|
||||||
|
appNature?: string;
|
||||||
clients?: number[];
|
clients?: number[];
|
||||||
pcUrl?: string;
|
pcUrl?: string;
|
||||||
pcIcon?: string;
|
pcIcon?: string;
|
||||||
@@ -768,6 +839,7 @@ export interface ApplicationInfoVo {
|
|||||||
appIcon?: string;
|
appIcon?: string;
|
||||||
officialAccountUrl?: string;
|
officialAccountUrl?: string;
|
||||||
dingtalkUrl?: string;
|
dingtalkUrl?: string;
|
||||||
|
miniprogramUrl?: string;
|
||||||
}
|
}
|
||||||
export interface BasePageResultApplicationInfoVo {
|
export interface BasePageResultApplicationInfoVo {
|
||||||
total?: number;
|
total?: number;
|
||||||
@@ -1281,6 +1353,40 @@ declare global {
|
|||||||
>(
|
>(
|
||||||
config: Config
|
config: Config
|
||||||
): Alova2Method<number[], 'SysUser.getUserGroups', Config>;
|
): Alova2Method<number[], 'SysUser.getUserGroups', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [GET]
|
||||||
|
*
|
||||||
|
* **path:** /api/sysuser/UserIdentity
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Query Parameters**
|
||||||
|
* ```ts
|
||||||
|
* type QueryParameters = {
|
||||||
|
* userId: number
|
||||||
|
* groupId: number
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = UserIdentityVo
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
userIdentity<
|
||||||
|
Config extends Alova2MethodConfig<UserIdentityVo> & {
|
||||||
|
params: {
|
||||||
|
userId: number;
|
||||||
|
groupId: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<UserIdentityVo, 'SysUser.userIdentity', Config>;
|
||||||
/**
|
/**
|
||||||
* ---
|
* ---
|
||||||
*
|
*
|
||||||
@@ -1493,6 +1599,81 @@ declare global {
|
|||||||
>(
|
>(
|
||||||
config: Config
|
config: Config
|
||||||
): Alova2Method<null, 'SysGroup.sysGroupRemove', Config>;
|
): Alova2Method<null, 'SysGroup.sysGroupRemove', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/sysgroup/Disable/{id}
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = null
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
disable<
|
||||||
|
Config extends Alova2MethodConfig<null> & {
|
||||||
|
pathParams: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<null, 'SysGroup.disable', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/sysgroup/Enable/{id}
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = null
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
enable<
|
||||||
|
Config extends Alova2MethodConfig<null> & {
|
||||||
|
pathParams: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<null, 'SysGroup.enable', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/sysgroup/SetIncludeChildren/{id}
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **RequestBody**
|
||||||
|
* ```ts
|
||||||
|
* type RequestBody = SysGroupIncludeChildrenRequest
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = null
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
setIncludeChildren<
|
||||||
|
Config extends Alova2MethodConfig<null> & {
|
||||||
|
pathParams: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
data: SysGroupIncludeChildrenRequest;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<null, 'SysGroup.setIncludeChildren', Config>;
|
||||||
/**
|
/**
|
||||||
* ---
|
* ---
|
||||||
*
|
*
|
||||||
@@ -1660,6 +1841,493 @@ declare global {
|
|||||||
config?: Config
|
config?: Config
|
||||||
): Alova2Method<SysGroupVo[], 'SysGroup.getSchoolTree', Config>;
|
): Alova2Method<SysGroupVo[], 'SysGroup.getSchoolTree', Config>;
|
||||||
};
|
};
|
||||||
|
CustomDeptCategory: {
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [GET]
|
||||||
|
*
|
||||||
|
* **path:** /api/deptcategory/List
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Query Parameters**
|
||||||
|
* ```ts
|
||||||
|
* type QueryParameters = {
|
||||||
|
* schoolId?: number
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = Array<CustomDeptCategoryVo>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptCategoryList<
|
||||||
|
Config extends Alova2MethodConfig<CustomDeptCategoryVo[]> & {
|
||||||
|
params?: {
|
||||||
|
schoolId?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<CustomDeptCategoryVo[], 'CustomDeptCategory.customDeptCategoryList', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/deptcategory/Add
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Request Body**
|
||||||
|
* ```ts
|
||||||
|
* type RequestBody = CustomDeptCategoryRequest
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = CustomDeptCategoryVo
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptCategoryAdd<
|
||||||
|
Config extends Alova2MethodConfig<CustomDeptCategoryVo> & {
|
||||||
|
data: CustomDeptCategoryRequest;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<CustomDeptCategoryVo, 'CustomDeptCategory.customDeptCategoryAdd', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/deptcategory/Edit/{id}
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Request Body**
|
||||||
|
* ```ts
|
||||||
|
* type RequestBody = CustomDeptCategoryRequest
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = CustomDeptCategoryVo
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptCategoryEdit<
|
||||||
|
Config extends Alova2MethodConfig<CustomDeptCategoryVo> & {
|
||||||
|
pathParams: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
data: CustomDeptCategoryRequest;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<CustomDeptCategoryVo, 'CustomDeptCategory.customDeptCategoryEdit', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/deptcategory/Sort
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Request Body**
|
||||||
|
* ```ts
|
||||||
|
* type RequestBody = CustomDeptCategorySortRequest
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = null
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptCategorySort<
|
||||||
|
Config extends Alova2MethodConfig<null> & {
|
||||||
|
data: CustomDeptCategorySortRequest;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<null, 'CustomDeptCategory.customDeptCategorySort', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/deptcategory/Delete/{id}
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = null
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptCategoryDelete<
|
||||||
|
Config extends Alova2MethodConfig<null> & {
|
||||||
|
pathParams: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<null, 'CustomDeptCategory.customDeptCategoryDelete', Config>;
|
||||||
|
};
|
||||||
|
CustomDept: {
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [GET]
|
||||||
|
*
|
||||||
|
* **path:** /api/customdept/List
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Query Parameters**
|
||||||
|
* ```ts
|
||||||
|
* type QueryParameters = {
|
||||||
|
* categoryId: number
|
||||||
|
* schoolId?: number
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = Array<CustomDepartmentVo>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptList<
|
||||||
|
Config extends Alova2MethodConfig<CustomDepartmentVo[]> & {
|
||||||
|
params: {
|
||||||
|
categoryId: number;
|
||||||
|
schoolId?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<CustomDepartmentVo[], 'CustomDept.customDeptList', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/customdept/Add
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Request Body**
|
||||||
|
* ```ts
|
||||||
|
* type RequestBody = CustomDepartmentRequest
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = CustomDepartmentVo
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptAdd<
|
||||||
|
Config extends Alova2MethodConfig<CustomDepartmentVo> & {
|
||||||
|
data: CustomDepartmentRequest;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<CustomDepartmentVo, 'CustomDept.customDeptAdd', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/customdept/Edit/{id}
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Request Body**
|
||||||
|
* ```ts
|
||||||
|
* type RequestBody = CustomDepartmentRequest
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = CustomDepartmentVo
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptEdit<
|
||||||
|
Config extends Alova2MethodConfig<CustomDepartmentVo> & {
|
||||||
|
pathParams: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
data: CustomDepartmentRequest;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<CustomDepartmentVo, 'CustomDept.customDeptEdit', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/customdept/Move/{id}
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Request Body**
|
||||||
|
* ```ts
|
||||||
|
* type RequestBody = CustomDepartmentMoveRequest
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = CustomDepartmentVo
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptMove<
|
||||||
|
Config extends Alova2MethodConfig<CustomDepartmentVo> & {
|
||||||
|
pathParams: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
data: CustomDepartmentMoveRequest;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<CustomDepartmentVo, 'CustomDept.customDeptMove', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/customdept/Sort
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Request Body**
|
||||||
|
* ```ts
|
||||||
|
* type RequestBody = CustomDepartmentSortRequest
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = null
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptSort<
|
||||||
|
Config extends Alova2MethodConfig<null> & {
|
||||||
|
data: CustomDepartmentSortRequest;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<null, 'CustomDept.customDeptSort', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/customdept/Disable/{id}
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = null
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptDisable<
|
||||||
|
Config extends Alova2MethodConfig<null> & {
|
||||||
|
pathParams: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<null, 'CustomDept.customDeptDisable', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/customdept/Enable/{id}
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = null
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptEnable<
|
||||||
|
Config extends Alova2MethodConfig<null> & {
|
||||||
|
pathParams: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<null, 'CustomDept.customDeptEnable', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/customdept/Delete/{id}
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = null
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptDelete<
|
||||||
|
Config extends Alova2MethodConfig<null> & {
|
||||||
|
pathParams: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<null, 'CustomDept.customDeptDelete', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [GET]
|
||||||
|
*
|
||||||
|
* **path:** /api/customdept/Members/{id}
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = Array<CustomDepartmentMemberVo>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptMembers<
|
||||||
|
Config extends Alova2MethodConfig<CustomDepartmentMemberVo[]> & {
|
||||||
|
pathParams: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<CustomDepartmentMemberVo[], 'CustomDept.customDeptMembers', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [GET]
|
||||||
|
*
|
||||||
|
* **path:** /api/customdept/CandidateUsers/{id}
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Query Parameters**
|
||||||
|
* ```ts
|
||||||
|
* type QueryParameters = {
|
||||||
|
* keyword?: string
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = Array<CustomDepartmentMemberVo>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptCandidateUsers<
|
||||||
|
Config extends Alova2MethodConfig<CustomDepartmentMemberVo[]> & {
|
||||||
|
pathParams: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
params?: {
|
||||||
|
keyword?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<CustomDepartmentMemberVo[], 'CustomDept.customDeptCandidateUsers', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/customdept/AddMembers/{id}
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Request Body**
|
||||||
|
* ```ts
|
||||||
|
* type RequestBody = CustomDepartmentMemberRequest
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = null
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptAddMembers<
|
||||||
|
Config extends Alova2MethodConfig<null> & {
|
||||||
|
pathParams: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
data: CustomDepartmentMemberRequest;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<null, 'CustomDept.customDeptAddMembers', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [POST]
|
||||||
|
*
|
||||||
|
* **path:** /api/customdept/RemoveMembers/{id}
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Request Body**
|
||||||
|
* ```ts
|
||||||
|
* type RequestBody = CustomDepartmentMemberRequest
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = null
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
customDeptRemoveMembers<
|
||||||
|
Config extends Alova2MethodConfig<null> & {
|
||||||
|
pathParams: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
data: CustomDepartmentMemberRequest;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
config: Config
|
||||||
|
): Alova2Method<null, 'CustomDept.customDeptRemoveMembers', Config>;
|
||||||
|
};
|
||||||
PushSubscription: {
|
PushSubscription: {
|
||||||
/**
|
/**
|
||||||
* ---
|
* ---
|
||||||
@@ -1835,91 +2503,6 @@ declare global {
|
|||||||
config: Config
|
config: Config
|
||||||
): Alova2Method<Blob, 'PushSubscription.download', Config>;
|
): Alova2Method<Blob, 'PushSubscription.download', Config>;
|
||||||
};
|
};
|
||||||
Identity: {
|
|
||||||
/**
|
|
||||||
* ---
|
|
||||||
*
|
|
||||||
* [POST]
|
|
||||||
*
|
|
||||||
* **path:** /api/identity/switch
|
|
||||||
*
|
|
||||||
* ---
|
|
||||||
*
|
|
||||||
* **RequestBody**
|
|
||||||
* ```ts
|
|
||||||
* type RequestBody = {
|
|
||||||
* roleId: number
|
|
||||||
* schoolId: number
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* ---
|
|
||||||
*
|
|
||||||
* **Response**
|
|
||||||
* ```ts
|
|
||||||
* type Response = {
|
|
||||||
* roleId?: number
|
|
||||||
* roleName?: string
|
|
||||||
* schoolId?: number
|
|
||||||
* schoolName?: string
|
|
||||||
* fallback?: boolean
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
switchIdentity<
|
|
||||||
Config extends Alova2MethodConfig<IdentityCurrentVo> & {
|
|
||||||
data: IdentitySwitchRequest;
|
|
||||||
}
|
|
||||||
>(
|
|
||||||
config: Config
|
|
||||||
): Alova2Method<IdentityCurrentVo, 'Identity.switchIdentity', Config>;
|
|
||||||
/**
|
|
||||||
* ---
|
|
||||||
*
|
|
||||||
* [GET]
|
|
||||||
*
|
|
||||||
* **path:** /api/identity/current
|
|
||||||
*
|
|
||||||
* ---
|
|
||||||
*
|
|
||||||
* **Response**
|
|
||||||
* ```ts
|
|
||||||
* type Response = {
|
|
||||||
* roleId?: number
|
|
||||||
* roleName?: string
|
|
||||||
* schoolId?: number
|
|
||||||
* schoolName?: string
|
|
||||||
* fallback?: boolean
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
current<Config extends Alova2MethodConfig<IdentityCurrentVo>>(
|
|
||||||
config?: Config
|
|
||||||
): Alova2Method<IdentityCurrentVo, 'Identity.current', Config>;
|
|
||||||
/**
|
|
||||||
* ---
|
|
||||||
*
|
|
||||||
* [GET]
|
|
||||||
*
|
|
||||||
* **path:** /api/identity/available
|
|
||||||
*
|
|
||||||
* ---
|
|
||||||
*
|
|
||||||
* **Response**
|
|
||||||
* ```ts
|
|
||||||
* type Response = Array<{
|
|
||||||
* roleId?: number
|
|
||||||
* roleName?: string
|
|
||||||
* schoolId?: number
|
|
||||||
* schoolName?: string
|
|
||||||
* isMain?: boolean
|
|
||||||
* }>
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
available<Config extends Alova2MethodConfig<IdentityOptionVo[]>>(
|
|
||||||
config?: Config
|
|
||||||
): Alova2Method<IdentityOptionVo[], 'Identity.available', Config>;
|
|
||||||
};
|
|
||||||
GroupAuthz: {
|
GroupAuthz: {
|
||||||
/**
|
/**
|
||||||
* ---
|
* ---
|
||||||
@@ -2090,6 +2673,8 @@ declare global {
|
|||||||
Config extends Alova2MethodConfig<UserRoleVo[]> & {
|
Config extends Alova2MethodConfig<UserRoleVo[]> & {
|
||||||
params: {
|
params: {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
/** 组ID(可选):传入时只返回该组下的绑定(编辑弹窗按所在组回显) */
|
||||||
|
groupId?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
@@ -2188,6 +2773,8 @@ declare global {
|
|||||||
Config extends Alova2MethodConfig<UserGroupNodeVo[]> & {
|
Config extends Alova2MethodConfig<UserGroupNodeVo[]> & {
|
||||||
params: {
|
params: {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
/** 上下文组ID(可选):传入且用户确属该组时只画该组祖先链 */
|
||||||
|
groupId?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
@@ -2817,6 +3404,23 @@ declare global {
|
|||||||
getUserInfo<Config extends Alova2MethodConfig<object>>(
|
getUserInfo<Config extends Alova2MethodConfig<object>>(
|
||||||
config?: Config
|
config?: Config
|
||||||
): Alova2Method<object, 'Base.getUserInfo', Config>;
|
): Alova2Method<object, 'Base.getUserInfo', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [GET]
|
||||||
|
*
|
||||||
|
* **path:** /api/base/GetUserScope
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = { level: number, bureauGroupId?: number, schoolGroupIds?: number[] }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
getUserScope<Config extends Alova2MethodConfig<object>>(
|
||||||
|
config?: Config
|
||||||
|
): Alova2Method<object, 'Base.getUserScope', Config>;
|
||||||
/**
|
/**
|
||||||
* ---
|
* ---
|
||||||
*
|
*
|
||||||
@@ -3078,6 +3682,8 @@ declare global {
|
|||||||
params: {
|
params: {
|
||||||
roleId: number;
|
roleId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
/** 绑定组ID:按行精确删除 */
|
||||||
|
groupId: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
@@ -3979,6 +4585,23 @@ declare global {
|
|||||||
>(
|
>(
|
||||||
config: Config
|
config: Config
|
||||||
): Alova2Method<BasePageResultApplicationInfoVo, 'AppSettings.appList', Config>;
|
): Alova2Method<BasePageResultApplicationInfoVo, 'AppSettings.appList', Config>;
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* [GET]
|
||||||
|
*
|
||||||
|
* **path:** /api/app/SchoolList
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* **Response**
|
||||||
|
* ```ts
|
||||||
|
* type Response = Array<{ id: number, name: string }>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
schoolList<Config extends Alova2MethodConfig<Array<any>>>(
|
||||||
|
config?: Config
|
||||||
|
): Alova2Method<Array<any>, 'AppSettings.schoolList', Config>;
|
||||||
/**
|
/**
|
||||||
* ---
|
* ---
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"prefix": "appicon",
|
||||||
|
"info": {
|
||||||
|
"name": "AppIcon",
|
||||||
|
"total": 2,
|
||||||
|
"author": "ysch front-end team",
|
||||||
|
"license": {
|
||||||
|
"title": "Remix Icon (Apache-2.0) 等,见 meta.json source",
|
||||||
|
"spdx": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"height": 1024
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"tongzhi": {
|
||||||
|
"body": "<path d=\"M853.33 725.33H938.67V810.67H85.33V725.33H170.67V426.67C170.67 238.15 323.49 85.33 512 85.33C700.51 85.33 853.33 238.15 853.33 426.67V725.33ZM768 725.33V426.67C768 285.28 653.38 170.67 512 170.67C370.62 170.67 256 285.28 256 426.67V725.33H768ZM384 896H640V981.33H384V896Z\" ></path>",
|
||||||
|
"cn": "通知"
|
||||||
|
},
|
||||||
|
"wenjuan": {
|
||||||
|
"body": "<path d=\"M725.33 85.33V170.67H853.61C877.02 170.67 896 189.65 896 213.05V896.28C896 919.69 877.02 938.67 853.61 938.67H170.39C146.98 938.67 128 919.68 128 896.28V213.05C128 189.64 146.98 170.67 170.39 170.67H298.67V85.33H725.33ZM298.67 256H213.33V853.33H810.67V256H725.33V341.33H298.67V256ZM384 682.67V768H298.67V682.67H384ZM384 554.67V640H298.67V554.67H384ZM384 426.67V512H298.67V426.67H384ZM640 170.67H384V256H640V170.67Z\" ></path>",
|
||||||
|
"cn": "问卷"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,52 @@
|
|||||||
import { BaseConf } from "@/types/Basics";
|
import { BaseConf } from "@/types/Basics";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过 Vite 环境变量管理配置,不再依赖 window.conf / config.js
|
* 配置优先级(高 → 低):
|
||||||
* 环境变量定义在项目根目录的 .env.dev / .env.base / .env.ld 中
|
* ① window.__APP_CONFIG__:线上运行时配置,index.html 引导脚本在应用模块加载前同步拉取
|
||||||
|
* /<应用>/<前端目标>/api/env-config?target=xxx(projman 配置管理在线维护、双写 Nacos)写入
|
||||||
|
* ② import.meta.env.VITE_*:仅本地开发(vite dev --mode dev 读 .env.dev)——值可自由指本地
|
||||||
|
* 或线上地址,供联调切换;build(production mode)时 .env/.env.production 均不存在,
|
||||||
|
* VITE_* 恒为 undefined,本层自动跳过,产物不含任何环境值
|
||||||
|
* ③ 同域推导兜底:env-config 拉取失败(线上后端/Nacos 不可用)时框架至少能渲染;
|
||||||
|
* ssoHost/relayConf 为空 → 登录与跨域中转不可用——与后端不可用状态一致,可接受。
|
||||||
*/
|
*/
|
||||||
const env = import.meta.env;
|
const env = import.meta.env;
|
||||||
|
|
||||||
|
// ③ 同域推导兜底:context 段取当前路径首段(/userbase/front/ → userbase),与服务端 context-path 对齐
|
||||||
|
const ctxSeg = (window.location.pathname.split("/").filter(Boolean)[0] || "") as string;
|
||||||
|
const origin = window.location.origin;
|
||||||
|
|
||||||
let conf: BaseConf = {
|
let conf: BaseConf = {
|
||||||
|
baseHost: origin,
|
||||||
|
ssoHost: "",
|
||||||
|
serviceHost: ctxSeg ? `${origin}/${ctxSeg}` : origin,
|
||||||
|
relayConf: {
|
||||||
|
ziyuankuHost: "",
|
||||||
|
zhuanxiangHost: "",
|
||||||
|
},
|
||||||
|
isMock: false,
|
||||||
|
isDebug: env.DEV,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 逐键覆盖:只覆盖来源里实际出现且非空的键(含 relayConf 二级逐键合并),未出现的键沿用底层值
|
||||||
|
const applyConf = (target: any, source: any) => {
|
||||||
|
if (!source || typeof source !== "object") return;
|
||||||
|
const { relayConf, ...flat } = source;
|
||||||
|
Object.keys(flat).forEach(key => {
|
||||||
|
if (flat[key] !== undefined && flat[key] !== null && flat[key] !== "") {
|
||||||
|
(target as any)[key] = flat[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (relayConf && typeof relayConf === "object") {
|
||||||
|
if (!target.relayConf || typeof target.relayConf !== "object") target.relayConf = {};
|
||||||
|
Object.keys(relayConf).forEach(key => {
|
||||||
|
if (relayConf[key]) target.relayConf[key] = relayConf[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ② 本地开发(.env.dev;构建产物中恒 undefined → 自动跳过)
|
||||||
|
applyConf(conf, {
|
||||||
baseHost: env.VITE_BASE_HOST,
|
baseHost: env.VITE_BASE_HOST,
|
||||||
ssoHost: env.VITE_SSO_HOST,
|
ssoHost: env.VITE_SSO_HOST,
|
||||||
serviceHost: env.VITE_SERVICE_HOST,
|
serviceHost: env.VITE_SERVICE_HOST,
|
||||||
@@ -15,12 +55,13 @@ let conf: BaseConf = {
|
|||||||
zhuanxiangHost: env.VITE_ZHUANXIANG_HOST,
|
zhuanxiangHost: env.VITE_ZHUANXIANG_HOST,
|
||||||
},
|
},
|
||||||
isMock: env.VITE_IS_MOCK === "true",
|
isMock: env.VITE_IS_MOCK === "true",
|
||||||
isDebug: env.DEV,
|
});
|
||||||
};
|
|
||||||
|
// ① 线上运行时配置(最高优先,覆盖前面所有)
|
||||||
|
applyConf(conf, (window as any).__APP_CONFIG__);
|
||||||
|
|
||||||
// HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https://
|
// HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https://
|
||||||
var ishttps = "https:" == document.location.protocol ? true : false;
|
if ("https:" === document.location.protocol) {
|
||||||
if (ishttps) {
|
|
||||||
Object.keys(conf).forEach(element => {
|
Object.keys(conf).forEach(element => {
|
||||||
if (typeof conf[element] === "string" && conf[element].indexOf("http://") > -1) {
|
if (typeof conf[element] === "string" && conf[element].indexOf("http://") > -1) {
|
||||||
conf[element] = conf[element].replace("http://", "https://");
|
conf[element] = conf[element].replace("http://", "https://");
|
||||||
|
|||||||
+60
-22
@@ -1,26 +1,26 @@
|
|||||||
import type { IModule } from "@/store";
|
import type { IModule } from "@/store";
|
||||||
|
import { UserRoleEnum } from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 左侧菜单数据(系统管理功能分组)
|
* 左侧菜单数据(系统管理功能分组)+ 菜单权限矩阵(管理端权限分级改造 §4.2)。
|
||||||
* ----------------------------------------------------------------
|
* ----------------------------------------------------------------
|
||||||
* 当前直接 Promise.resolve 返回写死数据;后续接入真实接口时,
|
* 每个节点带 roles 元数据(可访问级别集合),getMenuList(userRole) 按级别过滤,
|
||||||
* 只需把 `return Promise.resolve(menu)` 替换为
|
* children 过滤后为空的父目录整体隐藏。当前数据写死;后续接入真实接口时,
|
||||||
* `return alovaInstance.Request(Apis.xxx.getMenu())`
|
* 只需把 `return Promise.resolve(...)` 替换为接口调用后 filterMenuByRole 即可。
|
||||||
* 即可无缝对接,调用方(store.fetchModules)无需任何改动。
|
|
||||||
*
|
*
|
||||||
* 分组结构:
|
* 分组结构:
|
||||||
* 1. 组,用户管理 —— 顶层独立选项卡(单页)
|
* 1. 组,用户管理 —— S+B+C
|
||||||
* 2. 应用管理 —— 顶层独立选项卡(= 应用列表主页 /app-manage/list);
|
* 2. 应用管理 —— S+C(局端不开放:局端的应用动作是「开通」,应用维护是平台级)
|
||||||
* 页内按钮跳转「应用类别管理」子页(/app-manage/category,不在菜单里,
|
* 3. 权限管理 —— 父目录:应用开通(S+B) / 组授权 / 角色授权 / 权限一览(S+B+C)
|
||||||
* 由 routes.ts 的 meta.parentId=1102 关联,面包屑「应用管理>应用类别管理」)
|
* 4. 日志管理 —— 仅超管(父目录)
|
||||||
* 3. 权限管理 —— 父目录:应用开通 / 组授权 / 权限一览
|
* 5. 基础设置 —— 非业务域的基础配置合集(当前:部门管理),后续字典/参数/模板等杂项归此
|
||||||
* 4. 日志管理 —— 父目录:接口日志 / 系统日志
|
*
|
||||||
|
* 菜单不渲染图标:本工程侧边栏只输出文字,节点无需 icon 字段。
|
||||||
*
|
*
|
||||||
* 每个节点带唯一 id;子节点的 pid 由 layout.vue 的 flaten() 扁平化时
|
* 每个节点带唯一 id;子节点的 pid 由 layout.vue 的 flaten() 扁平化时
|
||||||
* 依据父节点 id 自动推导,用于「高亮自身 + 面包屑展示父级」联动。
|
* 依据父节点 id 自动推导,用于「高亮自身 + 面包屑展示父级」联动。
|
||||||
* 菜单项不再渲染 icon。
|
|
||||||
*/
|
*/
|
||||||
export function getMenuList(): Promise<IModule[]> {
|
export function getMenuList(userRole?: UserRoleEnum): Promise<IModule[]> {
|
||||||
const menu: IModule[] = [
|
const menu: IModule[] = [
|
||||||
// 1. 组,用户管理(顶层独立选项卡)
|
// 1. 组,用户管理(顶层独立选项卡)
|
||||||
{
|
{
|
||||||
@@ -28,6 +28,7 @@ export function getMenuList(): Promise<IModule[]> {
|
|||||||
modId: 1101,
|
modId: 1101,
|
||||||
name: "组,用户管理",
|
name: "组,用户管理",
|
||||||
page: "/group-user",
|
page: "/group-user",
|
||||||
|
roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School],
|
||||||
},
|
},
|
||||||
// 2. 应用管理(顶层独立选项卡,= 应用列表主页;页内按钮跳转应用类别管理子页)
|
// 2. 应用管理(顶层独立选项卡,= 应用列表主页;页内按钮跳转应用类别管理子页)
|
||||||
{
|
{
|
||||||
@@ -35,6 +36,7 @@ export function getMenuList(): Promise<IModule[]> {
|
|||||||
modId: 1102,
|
modId: 1102,
|
||||||
name: "应用管理",
|
name: "应用管理",
|
||||||
page: "/app-manage/list",
|
page: "/app-manage/list",
|
||||||
|
roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.School],
|
||||||
},
|
},
|
||||||
// 3. 权限管理(父目录)
|
// 3. 权限管理(父目录)
|
||||||
{
|
{
|
||||||
@@ -43,11 +45,12 @@ export function getMenuList(): Promise<IModule[]> {
|
|||||||
name: "权限管理",
|
name: "权限管理",
|
||||||
page: "",
|
page: "",
|
||||||
childState: true,
|
childState: true,
|
||||||
|
roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School],
|
||||||
children: [
|
children: [
|
||||||
{ id: 41, modId: 1103, name: "应用开通", page: "/app-open" },
|
{ id: 41, modId: 1103, name: "应用开通", page: "/app-open", roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau] },
|
||||||
{ id: 42, modId: 1104, name: "组授权", page: "/group-auth" },
|
{ id: 42, modId: 1104, name: "组授权", page: "/group-auth", roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School] },
|
||||||
{ id: 44, modId: 1110, name: "角色授权", page: "/role-authz/list" },
|
{ id: 44, modId: 1110, name: "角色授权", page: "/role-authz/list", roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School] },
|
||||||
{ id: 43, modId: 1105, name: "权限一览", page: "/permission-view" },
|
{ id: 43, modId: 1105, name: "权限一览", page: "/permission-view", roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// 4. 日志管理(父目录)
|
// 4. 日志管理(父目录)
|
||||||
@@ -57,13 +60,48 @@ export function getMenuList(): Promise<IModule[]> {
|
|||||||
name: "日志管理",
|
name: "日志管理",
|
||||||
page: "",
|
page: "",
|
||||||
childState: true,
|
childState: true,
|
||||||
|
roles: [UserRoleEnum.SuperAdmin],
|
||||||
children: [
|
children: [
|
||||||
{ id: 53, modId: 1111, name: "操作日志", page: "/oplog" },
|
{ id: 53, modId: 1111, name: "操作日志", page: "/oplog", roles: [UserRoleEnum.SuperAdmin] },
|
||||||
{ id: 56, modId: 1114, name: "开放接口日志", page: "/oapilog" },
|
{ id: 56, modId: 1114, name: "开放接口日志", page: "/oapilog", roles: [UserRoleEnum.SuperAdmin] },
|
||||||
{ id: 54, modId: 1112, name: "推送日志", page: "/push-log" },
|
{ id: 54, modId: 1112, name: "推送日志", page: "/push-log", roles: [UserRoleEnum.SuperAdmin] },
|
||||||
{ id: 55, modId: 1113, name: "测试推送", page: "/push-test" },
|
],
|
||||||
|
},
|
||||||
|
// 5. 基础设置(父目录):非业务域的基础配置杂项合集,部门管理从「权限管理」迁入
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
modId: 1115,
|
||||||
|
name: "基础设置",
|
||||||
|
page: "",
|
||||||
|
childState: true,
|
||||||
|
roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School],
|
||||||
|
children: [
|
||||||
|
{ id: 61, modId: 1106, name: "部门管理", page: "/dept-manage", roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return Promise.resolve(menu);
|
return Promise.resolve(filterMenuByRole(menu, userRole));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按级别过滤菜单:roles 未声明的节点视为全部级别可见;children 过滤后为空的父目录整体隐藏 */
|
||||||
|
export function filterMenuByRole(menu: IModule[], userRole?: UserRoleEnum): IModule[] {
|
||||||
|
if (userRole == null) {
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
const result: IModule[] = [];
|
||||||
|
for (const item of menu) {
|
||||||
|
const roles = (item as any).roles as UserRoleEnum[] | undefined;
|
||||||
|
if (roles && !roles.includes(userRole)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const children = item.children
|
||||||
|
? filterMenuByRole(item.children, userRole)
|
||||||
|
: undefined;
|
||||||
|
if (item.childState && (!children || children.length === 0)) {
|
||||||
|
// 父目录:可见但全部子项被过滤 → 整体隐藏
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push(children ? { ...item, children } : item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 1024 1024" width="1em" height="1em"><path transform="translate(-56.6 -56.8) scale(1.11111)" d="M555 427Q555 444 542.5 456.5Q530 469 512.0 469.0Q494 469 481.5 456.5Q469 444 469 427V128Q469 117 475.0 107.0Q481 97 491.0 91.0Q501 85 512.0 85.0Q523 85 533.0 91.0Q543 97 549.0 107.0Q555 117 555 128ZM657 293Q646 287 640.0 275.5Q634 264 635.5 251.5Q637 239 645.5 229.0Q654 219 666 216Q683 211 698 219Q783 266 835.0 347.5Q887 429 894.5 525.5Q902 622 863.0 710.5Q824 799 747 858Q679 911 595.5 929.5Q512 948 428.5 929.5Q345 911 276.5 858.0Q208 805 169.5 729.0Q131 653 128.0 567.5Q125 482 159 403Q184 344 227.0 297.0Q270 250 326 219Q341 211 357.5 216.0Q374 221 382.5 236.0Q391 251 386.5 267.5Q382 284 367 293Q308 326 269.5 380.0Q231 434 218.5 499.0Q206 564 223.0 629.0Q240 694 282.5 745.0Q325 796 385.0 824.5Q445 853 512.0 853.0Q579 853 639.0 824.5Q699 796 741.5 745.0Q784 694 801.0 629.0Q818 564 805.5 499.0Q793 434 754.5 380.0Q716 326 657 293Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1017 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 1024 1024" width="1em" height="1em"><path d="M802.5 946H603.8C575 945.9 551.7 922.6 551.6 893.8V694.2H472.8V893.8C472.8 922.5 449.3 946 420.6 946H219.1C190.4 945.9 167.1 922.6 167.1 893.8V566.4H77.8C55.9 566.6 36.3 553 28.8 532.4C21.2 512.1 27 489.2 43.4 475L468.2 95.9L468.5 95.7C496.5 71.5 538.3 72.3 565.4 97.5L981.9 476.2L982 476.5C997.7 491.2 1002.8 513.7 994.7 533.6C986.9 553.5 967.7 566.6 946.3 566.5L854.6 566.5V893.8C854.6 922.5 831.2 946 802.5 946ZM615.2 882.4H791V503H916.7L522.3 144.3L522.2 144.2C518.9 141.1 513.8 140.9 510.3 143.7L107.8 502.8H230.6V882.4H409.2V682.8C409.2 654.2 432.6 630.8 461.4 630.8H563C591.7 630.8 615.2 654.2 615.2 682.8V882.4L615.2 882.4Z" ></path></svg>
|
||||||
|
After Width: | Height: | Size: 766 B |
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<path fill="#4aaff0" d="M512,585 C415,455 330,398 215,395 C185,395 160,415 160,450 L160,770 C160,815 190,838 240,845 C340,856 430,872 512,888 C594,872 684,856 784,845 C834,838 864,815 864,770 L864,450 C864,415 839,395 809,395 C694,398 609,455 512,585 Z"/>
|
||||||
|
<path fill="#4aaff0" d="M218,312 C335,292 462,352 514,462 C452,404 334,354 218,378 Z"/>
|
||||||
|
<circle fill="#4aaff0" cx="218" cy="345" r="33"/>
|
||||||
|
<path fill="#80d976" d="M508,462 C455,310 525,145 700,105 C758,250 668,425 508,462 Z"/>
|
||||||
|
<path fill="none" stroke="#4aaff0" stroke-width="44" stroke-linecap="round" d="M210,935 Q285.5,881 361,935 T512,935 T663,935 T814,935"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 701 B |
File diff suppressed because one or more lines are too long
@@ -98,20 +98,88 @@ h4.subtitle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 表格加载(规范 §4 表格加载行):图形=通道 .ysch-spin(随 --brand),本地零图形资产 */
|
||||||
.table-spin-load {
|
.table-spin-load {
|
||||||
|
position: relative;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 90px;
|
height: 90px;
|
||||||
padding-top: 60px;
|
padding-top: 60px;
|
||||||
background-image: url(../image/loading.gif);
|
|
||||||
background-size: 60px 60px;
|
|
||||||
background-position: top center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
border: none;
|
border: none;
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.table-spin-load .ysch-spin {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
margin-left: -30px;
|
||||||
|
font-size: 60px; /* .ysch-spin 尺寸=1em,60px 图形用字号给 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.ivu-table-cell-tree {
|
.ivu-table-cell-tree {
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================= 表格操作列(utils/tableActions.ts)=================
|
||||||
|
.op-cell:flex 横排容器——把 iView Tooltip/Dropdown 的块级包装层降级为 flex item,
|
||||||
|
强制横排(根因:.operating-btn 在通道基线 display:block,.ivu-tooltip 包装层默认 block,
|
||||||
|
逐钮竖排;不要再在 cell 上动 display,会牵连其它列)。
|
||||||
|
先落工程内,四端同构后此段上提 YschTheme 主题层。 */
|
||||||
|
.op-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
// 链间距统一由容器 gap 管理,退役通道基线的 padding-right:10px
|
||||||
|
.operating-link {
|
||||||
|
padding-right: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 「更多▾」触发器(Dropdown transfer 后菜单挂 body,样式必须全局) */
|
||||||
|
.op-more {
|
||||||
|
.op-more-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.op-more-arrow {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 「更多」菜单内危险项(红色 + hover 红底,与 text-error 同源 $color-error) */
|
||||||
|
.ivu-dropdown-item.op-item-danger {
|
||||||
|
color: $color-error !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-error !important;
|
||||||
|
background-color: rgba(232, 104, 74, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* UserBase 专项(不上收通道,勿写入接入规范):左侧树卡统一 300px——
|
||||||
|
通道默认 240 装不下组树操作钮与节点名,本端树页多、统一加宽 */
|
||||||
|
.base-wrap-main .tree-wrapper .tree-container {
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 描边徽标(对齐 UserHome「当前生效」样式):view-ui-plus 预设色 Tag 的 border prop
|
||||||
|
渲染仍是实心块(无效 prop),改自定义类挂主题令牌——主态=brand 描边,muted=中性灰描边 */
|
||||||
|
.tag-outline {
|
||||||
|
color: var(--brand) !important;
|
||||||
|
border: 1px solid var(--brand) !important;
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-outline.tag-outline-muted {
|
||||||
|
color: #999 !important;
|
||||||
|
border: 1px solid #dcdee2 !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<template>
|
||||||
|
<div class="appicon-picker">
|
||||||
|
<!-- 当前选中态 / 清除 -->
|
||||||
|
<div class="picker-head">
|
||||||
|
<div class="picked" v-if="modelValue">
|
||||||
|
<span class="picked-glyph scene-tile" v-html="svgOf(modelValue)"></span>
|
||||||
|
<span class="picked-info">
|
||||||
|
<span class="picked-cn">{{ cnOf(modelValue) }}</span>
|
||||||
|
<span class="picked-key">{{ modelValue }}</span>
|
||||||
|
</span>
|
||||||
|
<Icon type="ios-close" class="picked-clear" title="清除(回到位图/默认回退链)" @click.stop="clearPick" />
|
||||||
|
</div>
|
||||||
|
<Button v-else size="small" icon="md-add" @click="open = true">选择矢量图标</Button>
|
||||||
|
<Button v-if="modelValue" size="small" class="ml-8" @click="open = true">更换</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 着色:不指定 = 端侧默认(PC 白色 / App 分组色) -->
|
||||||
|
<div class="color-row" v-if="modelValue">
|
||||||
|
<span class="color-label">着色:</span>
|
||||||
|
<Tooltip content="空 = 跟随端侧场景默认(PC 白图标、App 分组色),一般无需指定" placement="top" transfer>
|
||||||
|
<span class="help-icon">?</span>
|
||||||
|
</Tooltip>
|
||||||
|
<div class="swatches">
|
||||||
|
<div class="swatch swatch-none" :class="{ active: !color }" title="端侧默认" @click="pickColor('')">无</div>
|
||||||
|
<div v-for="c in PRESET_COLORS" :key="c" class="swatch" :class="{ active: color === c }"
|
||||||
|
:style="{ background: c }" :title="c" @click="pickColor(c)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal v-model="open" title="选择矢量图标(AppIcon 资产库)" width="640" footer-hide transfer :mask-closable="true">
|
||||||
|
<div class="picker-body">
|
||||||
|
<Input v-model="keyword" prefix="ios-search" placeholder="搜索中文名或图标名" clearable />
|
||||||
|
<div class="picker-grid">
|
||||||
|
<div v-for="item in filtered" :key="item.key" class="picker-cell"
|
||||||
|
:class="{ active: item.key === modelValue }" @click="pick(item.key)">
|
||||||
|
<span class="scene-tile" v-html="svgOf(item.key)"></span>
|
||||||
|
<div class="cell-cn">{{ item.cn }}</div>
|
||||||
|
<div class="cell-key">{{ item.key }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="picker-empty" v-if="!filtered.length">无匹配图标(清单未拉取成功时仅剩内置副本)</div>
|
||||||
|
</div>
|
||||||
|
<div class="picker-tip">
|
||||||
|
三场景同形:方块底(PC 应用卡)/ 圆底(App 首页)/ 线性(App 列表)——底色由各端场景决定,此处仅选形状。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import builtinManifest from "@/assets/appicon-icons.json";
|
||||||
|
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 矢量图标选择器(AppIcon 资产库)。
|
||||||
|
* 清单来源:可配置 manifestUrl(默认线上 /static/appicon/icons.json),拉取失败回落本地内置副本
|
||||||
|
* (P1 静态应用未部署时管理端依然可用;副本与资产库 icons.json 同步,见资产库 build_dist)。
|
||||||
|
* 预览直接内联 svg body(不依赖字体加载),tile/circle/plain 三场景同形。
|
||||||
|
*/
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
/** 矢量图标键(AppIcon 图标名),空 = 不使用矢量图标 */
|
||||||
|
modelValue?: string;
|
||||||
|
/** 着色 CSS 色值,空 = 端侧默认 */
|
||||||
|
color?: string;
|
||||||
|
/** 清单地址(默认平台静态通道) */
|
||||||
|
manifestUrl?: string;
|
||||||
|
}>(), {
|
||||||
|
modelValue: '',
|
||||||
|
color: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'update:color']);
|
||||||
|
|
||||||
|
const { $conf } = useCurrentInstance();
|
||||||
|
|
||||||
|
interface IconEntry { key: string; cn: string; body: string }
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
const keyword = ref('');
|
||||||
|
const remoteIcons = ref<IconEntry[] | null>(null);
|
||||||
|
|
||||||
|
const PRESET_COLORS = ['#366ef4', '#3fa45b', '#f2994a', '#7c5cc4', '#17b8a6', '#ff5757'];
|
||||||
|
|
||||||
|
const builtinIcons: IconEntry[] = Object.entries((builtinManifest as any).icons || {}).map(
|
||||||
|
([key, v]: [string, any]) => ({ key, cn: v?.cn || key, body: v?.body || '' })
|
||||||
|
);
|
||||||
|
|
||||||
|
const icons = computed<IconEntry[]>(() => remoteIcons.value ?? builtinIcons);
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const kw = keyword.value.trim().toLowerCase();
|
||||||
|
if (!kw) return icons.value;
|
||||||
|
return icons.value.filter(x => x.cn.includes(kw) || x.key.includes(kw));
|
||||||
|
});
|
||||||
|
|
||||||
|
function cnOf(key: string): string {
|
||||||
|
return icons.value.find(x => x.key === key)?.cn || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyOf(key: string): string {
|
||||||
|
return icons.value.find(x => x.key === key)?.body || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgOf(key: string): string {
|
||||||
|
const body = bodyOf(key);
|
||||||
|
if (!body) return '';
|
||||||
|
return `<svg viewBox="0 0 1024 1024" fill="currentColor" xmlns="http://www.w3.org/2000/svg" style="width:1em;height:1em;display:block">${body}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(key: string) {
|
||||||
|
emit('update:modelValue', key);
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPick() {
|
||||||
|
emit('update:modelValue', '');
|
||||||
|
emit('update:color', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickColor(c: string) {
|
||||||
|
emit('update:color', c);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 线上清单优先(P1 部署后自动生效);失败静默回落内置副本
|
||||||
|
try {
|
||||||
|
const url = props.manifestUrl || (($conf?.baseHost || '') + '/static/appicon/icons.json');
|
||||||
|
const res = await fetch(url, { credentials: 'omit' });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const list = Object.entries(data?.icons || {}).map(
|
||||||
|
([key, v]: [string, any]) => ({ key, cn: v?.cn || key, body: v?.body || '' })
|
||||||
|
);
|
||||||
|
if (list.length) remoteIcons.value = list;
|
||||||
|
} catch {
|
||||||
|
// 回落内置副本,不提示(本地开发 baseHost 指向本地时必然 404)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.appicon-picker {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picked {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 10px 4px 4px;
|
||||||
|
border: 1px solid #dcdee2;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
.picked-cn {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #515a6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picked-key {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picked-clear {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #999;
|
||||||
|
font-size: 18px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ed4014;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.picked-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
.color-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #515a6e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 1px solid #c5c8ce;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #808695;
|
||||||
|
cursor: help;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatches {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: #577afb;
|
||||||
|
box-shadow: 0 0 0 1px #577afb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.swatch-none {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px dashed #c5c8ce;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #808695;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-body {
|
||||||
|
.picker-grid {
|
||||||
|
margin-top: 12px;
|
||||||
|
max-height: 380px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-cell {
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px 6px;
|
||||||
|
border: 1px solid #e8eaec;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #577afb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: #577afb;
|
||||||
|
box-shadow: 0 0 0 1px #577afb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-cn {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #515a6e;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-key {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #aaa;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #bbb;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-tip {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #808695;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中态/网格预览的方块场景(与端侧 tile token 同构,仅管理端演示用) */
|
||||||
|
.scene-tile {
|
||||||
|
--appicon-bg: #366ef4;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 18%;
|
||||||
|
background: var(--appicon-bg);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -215,6 +215,11 @@ function getNodeById(id: number) {
|
|||||||
return refMyTree.value?.getNodeById(id);
|
return refMyTree.value?.getNodeById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在指定节点后追加自定义控件(见 MyTree.attachNodeControl) */
|
||||||
|
function attachNodeControl(id: number, className: string, html: string) {
|
||||||
|
return refMyTree.value?.attachNodeControl(id, className, html);
|
||||||
|
}
|
||||||
|
|
||||||
function getCheckedNodes() {
|
function getCheckedNodes() {
|
||||||
return refMyTree.value?.getCheckedNodes() || [];
|
return refMyTree.value?.getCheckedNodes() || [];
|
||||||
}
|
}
|
||||||
@@ -257,6 +262,7 @@ defineExpose({
|
|||||||
removeNode,
|
removeNode,
|
||||||
renameNode,
|
renameNode,
|
||||||
getNodeById,
|
getNodeById,
|
||||||
|
attachNodeControl,
|
||||||
getCheckedNodes,
|
getCheckedNodes,
|
||||||
checkNodeById,
|
checkNodeById,
|
||||||
addNote,
|
addNote,
|
||||||
@@ -285,10 +291,13 @@ defineExpose({
|
|||||||
.tree-body {
|
.tree-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-body :deep(.ztree) {
|
/* 滚动职责统一由 MyTree(.my-tree > .ztree) 承担:此处只负责把可用高度传给子组件 */
|
||||||
height: 100%;
|
.tree-body > .my-tree {
|
||||||
overflow-y: auto;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,26 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<Modal v-model="isModal" @on-visible-change="showChange" :title="title">
|
<!-- 确认框固定最高层:可能从任意 transfer 弹窗/抽屉(含手动抬层的 3000 抽屉)里唤起,
|
||||||
|
而 Modal 默认层级是全局计数器 1000+N,会被高 z-index 抽屉盖住 -->
|
||||||
|
<Modal v-model="isModal" @on-visible-change="showChange" :title="title" :z-index="4000" transfer>
|
||||||
<Alert type="warning" class="mb-1" show-icon>
|
<Alert type="warning" class="mb-1" show-icon>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon type="ios-bulb-outline" :class="{ 'mt-5': !info }" slot="icon" :size="28"></Icon>
|
<Icon type="ios-bulb-outline" :class="{ 'mt-5': !info }" :size="28"></Icon>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="msg" :class="['message-box', info ? '' : 'mt-10 mb-10 ml-10']">{{ msg }}</div>
|
<div v-if="msg" :class="['message-box', info ? '' : 'mt-10 mb-10 ml-10']">{{ msg }}</div>
|
||||||
<template #desc v-if="info" slot="desc">{{ info }}</template>
|
<template #desc v-if="info">{{ info }}</template>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
<div v-if="confirmName" class="confirm-name-block mb-1">
|
||||||
|
<div class="mb-5">
|
||||||
|
此操作<span style="font-weight: bold">无法</span>被回滚。<br />
|
||||||
|
请输入 <span class="confirm-name-text">{{ confirmName }}</span> 以确认:
|
||||||
|
</div>
|
||||||
|
<Input v-model="inputName" placeholder="请输入名称以确认" @on-enter="onOkClick" />
|
||||||
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button type="primary" :loading="okLoading" @click="onOkClick">{{ confirmText }}</Button>
|
<Button :type="danger ? 'error' : 'primary'" :disabled="!nameMatched" :loading="okLoading" @click="onOkClick">{{ confirmText }}</Button>
|
||||||
<Button :loading="cancleLoading" @click="onCancleClick">{{ closeText }}</Button>
|
<Button :loading="cancleLoading" @click="onCancleClick">{{ closeText }}</Button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { Modal, Button, Alert, Icon } from "view-ui-plus";
|
import { Modal, Button, Alert, Icon, Input } from "view-ui-plus";
|
||||||
|
|
||||||
const isModal = ref(false),
|
const isModal = ref(false),
|
||||||
okLoading = ref(false),
|
okLoading = ref(false),
|
||||||
cancleLoading = ref(false);
|
cancleLoading = ref(false),
|
||||||
|
inputName = ref("");
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -30,6 +40,8 @@ const props = withDefaults(
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
closeText?: string;
|
closeText?: string;
|
||||||
|
confirmName?: string;
|
||||||
|
danger?: boolean;
|
||||||
confirmFn: any;
|
confirmFn: any;
|
||||||
closeFn: any;
|
closeFn: any;
|
||||||
destroy: Function;
|
destroy: Function;
|
||||||
@@ -39,9 +51,13 @@ const props = withDefaults(
|
|||||||
isLoading: true,
|
isLoading: true,
|
||||||
confirmText: "确认",
|
confirmText: "确认",
|
||||||
closeText: "取消",
|
closeText: "取消",
|
||||||
|
confirmName: "",
|
||||||
|
danger: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const nameMatched = computed(() => !props.confirmName || inputName.value.trim() === props.confirmName);
|
||||||
|
|
||||||
onMounted(function () {
|
onMounted(function () {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isModal.value = true;
|
isModal.value = true;
|
||||||
@@ -49,6 +65,7 @@ onMounted(function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onOkClick = async () => {
|
const onOkClick = async () => {
|
||||||
|
if (!nameMatched.value) return;
|
||||||
if (!!props.isLoading) okLoading.value = true;
|
if (!!props.isLoading) okLoading.value = true;
|
||||||
const { onOk, reslove } = props.confirmFn;
|
const { onOk, reslove } = props.confirmFn;
|
||||||
await Promise.resolve(onOk());
|
await Promise.resolve(onOk());
|
||||||
@@ -73,4 +90,15 @@ const showChange = (state: boolean) => {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style></style>
|
<style lang="scss" scoped>
|
||||||
|
.confirm-name-block {
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1.6;
|
||||||
|
|
||||||
|
.confirm-name-text {
|
||||||
|
color: #ed4014;
|
||||||
|
font-weight: bold;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { MyConfirm } from "@/types/Basics";
|
|||||||
import confirm from "./Confirm.vue";
|
import confirm from "./Confirm.vue";
|
||||||
|
|
||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
export default ({ title, msg, info, confirmText, closeText, isLoading, onOk, onCancel }: MyConfirm) => {
|
export default ({ title, msg, info, confirmText, closeText, isLoading, confirmName, danger, onOk, onCancel }: MyConfirm) => {
|
||||||
return new Promise<boolean>((reslove, reject) => {
|
return new Promise<boolean>((reslove, _reject) => {
|
||||||
const instance = createApp(confirm, {
|
const instance = createApp(confirm, {
|
||||||
title,
|
title,
|
||||||
msg,
|
msg,
|
||||||
@@ -11,6 +11,8 @@ export default ({ title, msg, info, confirmText, closeText, isLoading, onOk, onC
|
|||||||
confirmText,
|
confirmText,
|
||||||
closeText,
|
closeText,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
confirmName,
|
||||||
|
danger,
|
||||||
confirmFn: {
|
confirmFn: {
|
||||||
onOk,
|
onOk,
|
||||||
reslove,
|
reslove,
|
||||||
|
|||||||
@@ -1,9 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="my-tree">
|
||||||
<div :id="domId" :ref="domId" class="ztree"></div>
|
<div :id="domId" :ref="domId" class="ztree"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
|
/**
|
||||||
|
* 节点规范化兜底:zTree 判定父节点的规则是「children 是数组即 isParent=true」(jquery.ztree.all.min.js
|
||||||
|
* 中 `tools.isArray(s) ? (data.nodeIsParent(setting,n,!0), ...)`),因此叶子节点只要被构造成
|
||||||
|
* `children: []` 就会渲染出 +/− 展开图标——此时再写 isParent:false 也无效(被数组分支覆盖)。
|
||||||
|
* 这里统一按「空 children 不挂键」,与参考实现 GroupTreeSearchPanel.formatTree 的口径一致。
|
||||||
|
*/
|
||||||
|
function normalizeTreeNodes(nodes) {
|
||||||
|
if (Array.isArray(nodes)) {
|
||||||
|
nodes.forEach(normalizeTreeNode);
|
||||||
|
} else if (nodes) {
|
||||||
|
normalizeTreeNode(nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTreeNode(node) {
|
||||||
|
if (!node || typeof node !== "object") return;
|
||||||
|
const kids = node.children;
|
||||||
|
if (Array.isArray(kids) && kids.length > 0) {
|
||||||
|
node.isParent = true;
|
||||||
|
kids.forEach(normalizeTreeNode);
|
||||||
|
} else {
|
||||||
|
delete node.children;
|
||||||
|
node.isParent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
treeData: {
|
treeData: {
|
||||||
@@ -17,6 +43,12 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
/** 受控的「全部展开」:true 时展开全部节点。由本组件在树初始化之后套用,
|
||||||
|
* 避免调用方在数据重载(切组/搜索过滤)时与 initTree 抢时序而丢失展开状态 */
|
||||||
|
expandAll: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
isCheck: {
|
isCheck: {
|
||||||
typeof: Boolean,
|
typeof: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -38,6 +70,12 @@ export default {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 受控「全部展开」:开关切换即时生效(树未初始化时忽略,initTree 会按当前值套用)
|
||||||
|
this.$watch("expandAll", val => {
|
||||||
|
if (this.treeObj) {
|
||||||
|
this.treeObj.expandAll(!!val);
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.domId = this.uuid();
|
this.domId = this.uuid();
|
||||||
@@ -65,11 +103,13 @@ export default {
|
|||||||
},
|
},
|
||||||
initTree(_data, _domId) {
|
initTree(_data, _domId) {
|
||||||
const _this = this;
|
const _this = this;
|
||||||
|
normalizeTreeNodes(_data);
|
||||||
_this.treeSetting.callback.onClick = (event, treeId, treeNode, clickFlag) => {
|
_this.treeSetting.callback.onClick = (event, treeId, treeNode, clickFlag) => {
|
||||||
// console.log(event, treeId, treeNode, clickFlag);
|
// console.log(event, treeId, treeNode, clickFlag);
|
||||||
if (_this.isCheck) {
|
if (_this.isCheck) {
|
||||||
var treeObj = $.fn.zTree.getZTreeObj(treeId);
|
var treeObj = $.fn.zTree.getZTreeObj(treeId);
|
||||||
treeObj.checkNode(treeNode, !treeNode.checked, true);
|
// 第4参 callbackFlag=true:点击文字切换勾选时同步触发 onCheck,与点击勾选框行为一致
|
||||||
|
treeObj.checkNode(treeNode, !treeNode.checked, true, true);
|
||||||
}
|
}
|
||||||
_this.$emit("on-tree-click", treeNode);
|
_this.$emit("on-tree-click", treeNode);
|
||||||
};
|
};
|
||||||
@@ -100,6 +140,10 @@ export default {
|
|||||||
};
|
};
|
||||||
|
|
||||||
_this.treeObj = $.fn.zTree.init($("#" + _domId), _this.treeSetting, _data);
|
_this.treeObj = $.fn.zTree.init($("#" + _domId), _this.treeSetting, _data);
|
||||||
|
// 数据重载后重新套用「全部展开」(节点 open 会被新数据重置)
|
||||||
|
if (_this.expandAll) {
|
||||||
|
_this.treeObj.expandAll(true);
|
||||||
|
}
|
||||||
if (_this.autoClick) {
|
if (_this.autoClick) {
|
||||||
if (Array.isArray(this.treeData)) {
|
if (Array.isArray(this.treeData)) {
|
||||||
let id = this.treeData[0].id;
|
let id = this.treeData[0].id;
|
||||||
@@ -131,7 +175,10 @@ export default {
|
|||||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||||
var pnode = node.getParentNode();
|
var pnode = node.getParentNode();
|
||||||
treeObj.removeNode(node);
|
treeObj.removeNode(node);
|
||||||
pnode.isParent = true;
|
// 同步父节点的父/叶状态:删掉最后一个子节点后不能继续显示 +/− 展开图标
|
||||||
|
const hasChild = !!(pnode.children && pnode.children.length);
|
||||||
|
pnode.isParent = hasChild;
|
||||||
|
pnode.open = hasChild && pnode.open;
|
||||||
treeObj.updateNode(pnode);
|
treeObj.updateNode(pnode);
|
||||||
this.selectNode(pnode.id);
|
this.selectNode(pnode.id);
|
||||||
},
|
},
|
||||||
@@ -175,6 +222,33 @@ export default {
|
|||||||
if (on) a.addClass('ztree-search-highlight');
|
if (on) a.addClass('ztree-search-highlight');
|
||||||
else a.removeClass('ztree-search-highlight');
|
else a.removeClass('ztree-search-highlight');
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* 给指定节点追加自定义 DOM 控件(渲染在节点文字之后)。
|
||||||
|
* 用途:在 zTree 节点行尾挂业务控件(如开关),不污染 node.name、
|
||||||
|
* 也不触发节点选中(zTree 的 onClick 绑定在节点 a 标签上,控件在其外部)。
|
||||||
|
* 树数据重载(initTree)后需重新挂载。
|
||||||
|
* @param {*} id 节点 id
|
||||||
|
* @param {string} className 控件容器类名(同时作为去重标识)
|
||||||
|
* @param {string} html 控件 HTML
|
||||||
|
* @returns {HTMLElement|null} 控件元素,失败返回 null
|
||||||
|
*/
|
||||||
|
attachNodeControl(id, className, html) {
|
||||||
|
const _domId = this.domId;
|
||||||
|
const treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||||
|
if (!treeObj || !id || !className) return null;
|
||||||
|
const node = treeObj.getNodesByFilter(n => n.id == id, true);
|
||||||
|
if (!node) return null;
|
||||||
|
const li = document.getElementById(node.tId);
|
||||||
|
if (!li) return null;
|
||||||
|
li.querySelectorAll("." + className).forEach(el => el.remove());
|
||||||
|
const box = document.createElement("span");
|
||||||
|
box.className = className;
|
||||||
|
box.innerHTML = html;
|
||||||
|
const a = document.getElementById(node.tId + "_a");
|
||||||
|
if (a && a.parentNode) a.parentNode.insertBefore(box, a.nextSibling);
|
||||||
|
else li.appendChild(box);
|
||||||
|
return box;
|
||||||
|
},
|
||||||
clearHighlight() {
|
clearHighlight() {
|
||||||
(this.highlightNodes || []).forEach(n => this.setHighlight(n, false));
|
(this.highlightNodes || []).forEach(n => this.setHighlight(n, false));
|
||||||
this.highlightNodes = [];
|
this.highlightNodes = [];
|
||||||
@@ -210,6 +284,21 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* 高度链:父级定高时 .ztree 成为唯一滚动体(滚动条只在树节点区域);
|
||||||
|
父级高度 auto(如卡片内按内容展开的树)时 height:100% 退化为 auto,表现为按内容展开,不受影响 */
|
||||||
|
.my-tree {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-tree > .ztree {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.ztree-search-highlight {
|
.ztree-search-highlight {
|
||||||
background-color: #ff6600 !important;
|
background-color: #ff6600 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||||
<template #loading>
|
<template #loading>
|
||||||
<div class="table-spin-load">加载中</div>
|
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="table-region" ref="regionRef">
|
||||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||||
<template #loading>
|
<template #loading>
|
||||||
<div class="table-spin-load">加载中</div>
|
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
||||||
<slot :name="slotName" v-bind="slotProps" />
|
<slot :name="slotName" v-bind="slotProps" />
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="page-panel">
|
<div class="page-panel">
|
||||||
<div class="fl align-middle">
|
<div class="fl ml-15 align-middle">
|
||||||
<slot :checkList="selectList" />
|
<slot :checkList="selectList" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,8 +23,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, useSlots } from 'vue'
|
import { computed, ref, useSlots, watch } from 'vue'
|
||||||
import Utils from '@/utils'
|
import { useElementHeight } from '@/utils/hook/useElementHeight'
|
||||||
import { OperatorEnum } from '@/types'
|
import { OperatorEnum } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -44,7 +46,10 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
|
|
||||||
const tableH = ref(props.tableHeight ?? Utils.useTableHeight())
|
const regionRef = ref<HTMLElement | null>(null)
|
||||||
|
const measuredH = useElementHeight(regionRef)
|
||||||
|
// 显式传 tableHeight 则尊重之;否则实测 .table-region(flex:1)高度喂固定表头,首帧未测出前用 undefined(auto)
|
||||||
|
const tableH = computed(() => props.tableHeight ?? (measuredH.value || undefined))
|
||||||
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
|
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
|
||||||
|
|
||||||
const selectList = ref<number[]>([])
|
const selectList = ref<number[]>([])
|
||||||
@@ -68,18 +73,30 @@ const {
|
|||||||
total: res => props.formatResponse(res).total,
|
total: res => props.formatResponse(res).total,
|
||||||
data: res => props.formatResponse(res).list,
|
data: res => props.formatResponse(res).list,
|
||||||
initialPage: 1,
|
initialPage: 1,
|
||||||
initialPageSize: 10,
|
// 页长记忆(bsch 应用页规范 §3:customPageCount localStorage)
|
||||||
|
initialPageSize: parseInt(localStorage.getItem('customPageCount') || '10') || 10,
|
||||||
immediate: props.autoLoad,
|
immediate: props.autoLoad,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 数据重载后清空勾选,避免批量操作用到旧选中行
|
||||||
|
watch(resp, () => {
|
||||||
|
if (selectList.value.length) {
|
||||||
|
selectList.value = []
|
||||||
|
emit('selection-change', selectList.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const list = computed(() => {
|
const list = computed(() => {
|
||||||
if (!resp.value) return []
|
if (!resp.value) return []
|
||||||
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
|
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const onPageChange = (p: number) => (page.value = p)
|
const onPageChange = (p: number) => (page.value = p)
|
||||||
const onSizeChange = (s: number) => (pageSize.value = s)
|
const onSizeChange = (s: number) => {
|
||||||
|
pageSize.value = s
|
||||||
|
localStorage.setItem('customPageCount', String(s))
|
||||||
|
}
|
||||||
|
|
||||||
function refreshData() {
|
function refreshData() {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/* 默认头像三态组件(YschTheme 公共源,scripts/sync-apps.mjs 下发三端 src/components/UserAvatar.vue):
|
||||||
|
头像 URL 非空 → <img>;否则 style==="text" 且能取到字 → 首字;否则 → 剪影(纯 CSS/SVG,零资源文件)。
|
||||||
|
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
|
||||||
|
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
|
||||||
|
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 头像 URL:非空直接 <img>(三态第一优先) */
|
||||||
|
src?: string | null;
|
||||||
|
/** 取字来源(首字态用):经 pickAvatarText 判定,取不到字回落剪影 */
|
||||||
|
name?: string | null;
|
||||||
|
/** 直径 px;font-size = round(size * 0.44) */
|
||||||
|
size?: number;
|
||||||
|
/** 配色场景:light=页面浅底(品牌实底+白内容);brand=header 品牌色底(剪影 S1 玻璃底 / 首字 S2 白底反色,§10.3 分档) */
|
||||||
|
placement?: "light" | "brand";
|
||||||
|
}>(),
|
||||||
|
{ src: null, name: null, size: 28, placement: "light" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
|
||||||
|
/* 外观偏好异步补齐(yh-theme-init 在挂载后拉服务端真源收敛 localStorage 键)会在
|
||||||
|
document 上派发 yh-appearance-updated;tick 自增让 style 依赖失效重读键,三态即时切换(无需刷新)。 */
|
||||||
|
const appearanceTick = ref(0);
|
||||||
|
const onAppearanceUpdated = () => { appearanceTick.value += 1; };
|
||||||
|
onMounted(() => document.addEventListener("yh-appearance-updated", onAppearanceUpdated));
|
||||||
|
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", onAppearanceUpdated));
|
||||||
|
const style = computed(() => {
|
||||||
|
appearanceTick.value;
|
||||||
|
return resolveAvatarStyle();
|
||||||
|
});
|
||||||
|
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
|
||||||
|
const showText = computed(() => style.value === "text" && !!text.value);
|
||||||
|
|
||||||
|
const rootClass = computed(() => [
|
||||||
|
"yh-avatar",
|
||||||
|
props.placement === "brand" ? "yh-avatar--brand" : "yh-avatar--light",
|
||||||
|
{ "is-text": showText.value },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rootStyle = computed(() => ({
|
||||||
|
width: `${props.size}px`,
|
||||||
|
height: `${props.size}px`,
|
||||||
|
fontSize: `${Math.round(props.size * 0.44)}px`,
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="rootClass" :style="rootStyle">
|
||||||
|
<img v-if="src" class="yh-avatar__img" :src="src" alt="" />
|
||||||
|
<template v-else-if="showText">{{ text }}</template>
|
||||||
|
<svg v-else class="yh-avatar__sil" viewBox="0 0 1024 1024" aria-hidden="true">
|
||||||
|
<circle cx="512" cy="399" r="174" />
|
||||||
|
<ellipse cx="512" cy="946" rx="420" ry="240" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.yh-avatar {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.yh-avatar__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* 剪影 SVG:容器圆 overflow:hidden 裁切肩部溢出(禁用 clipPath);overflow:visible 放行 viewBox 外形状 */
|
||||||
|
.yh-avatar__sil {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
/* 页面内(浅底)——品牌实底 + 白内容 */
|
||||||
|
.yh-avatar--light {
|
||||||
|
background: var(--brand, #577afb);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
/* header(品牌色底)——与页面内反向:剪影档 = S1 半透明白,首字档 = S2 纯白底 + 品牌色字 */
|
||||||
|
.yh-avatar--brand {
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.yh-avatar--brand.is-text {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--brand, #577afb);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+45
-11
@@ -1,9 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, inject, onBeforeMount, watch, toRefs, nextTick, computed } from "vue";
|
import { ref, inject, onBeforeMount, onBeforeUnmount, onMounted, watch, toRefs, nextTick, computed } from "vue";
|
||||||
import { LayoutData } from "@/types/Basics";
|
import { LayoutData } from "@/types/Basics";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { Message } from "view-ui-plus";
|
import { Message } from "view-ui-plus";
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||||
|
import SvgIcon from "@/components/SvgIcon.vue";
|
||||||
|
import UserAvatar from "@/components/UserAvatar.vue";
|
||||||
|
import defaultLogo from "@/assets/image/yunhai-logo.svg";
|
||||||
let props = defineProps<{ layoutData: LayoutData }>();
|
let props = defineProps<{ layoutData: LayoutData }>();
|
||||||
const reload: Function = inject("reload") || new Function("console.error('empty')");
|
const reload: Function = inject("reload") || new Function("console.error('empty')");
|
||||||
|
|
||||||
@@ -11,6 +14,27 @@ const appPanel = ref();
|
|||||||
let route = useRoute();
|
let route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 顶栏 Logo:localStorage["yh-logo-url"](平台键,yh-theme-init 异步补齐段写入)优先,本仓资产兜底——绝无空 src/裂图。
|
||||||
|
// 刷新:mounted 读一次 + 监听 yh-appearance-updated(服务端偏好到达后即时切换,无需刷新页面)。
|
||||||
|
const logoUrl = ref<string>(defaultLogo);
|
||||||
|
const readLogoUrl = () => {
|
||||||
|
let v: string | null = null;
|
||||||
|
try {
|
||||||
|
v = localStorage.getItem("yh-logo-url");
|
||||||
|
} catch {
|
||||||
|
v = null;
|
||||||
|
}
|
||||||
|
logoUrl.value = v || defaultLogo;
|
||||||
|
};
|
||||||
|
const onLogoError = () => {
|
||||||
|
if (logoUrl.value !== defaultLogo) logoUrl.value = defaultLogo;
|
||||||
|
};
|
||||||
|
onMounted(() => {
|
||||||
|
readLogoUrl();
|
||||||
|
document.addEventListener("yh-appearance-updated", readLogoUrl);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readLogoUrl));
|
||||||
|
|
||||||
const isPanel = ref(false),
|
const isPanel = ref(false),
|
||||||
currPath = ref<string>(""),
|
currPath = ref<string>(""),
|
||||||
navPath = ref<any[]>([]);
|
navPath = ref<any[]>([]);
|
||||||
@@ -137,7 +161,8 @@ const linkMenu = (page: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const linkUserInfo = () => {
|
const linkUserInfo = () => {
|
||||||
window.location.href = baseHost + "/v/userinfo";
|
// 头像/姓名点击统一跳 UserHome 设置页「个人信息」pane(默认头像轮 2026-09-30,原 /v/userinfo 已下线语义)
|
||||||
|
window.location.href = baseHost + "/userhome/front/index.html#/settings?pane=profile";
|
||||||
};
|
};
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
let url = window.location.href;
|
let url = window.location.href;
|
||||||
@@ -155,7 +180,8 @@ function linkHomePage() {
|
|||||||
if (!bschUtil.getIsSelf()) {
|
if (!bschUtil.getIsSelf()) {
|
||||||
bschUtil.goBackDesktop();
|
bschUtil.goBackDesktop();
|
||||||
} else {
|
} else {
|
||||||
window.location.href = baseHost + "/BschHome/front/index.html";
|
// 旧 BschHome 已下线(线上 404),首页统一指向用户中心 userhome
|
||||||
|
window.location.href = baseHost + "/userhome/front/index.html";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -163,22 +189,19 @@ function linkHomePage() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="layout-wrap">
|
<div class="layout-wrap">
|
||||||
<header class="header-container fix">
|
<header class="header-container fix">
|
||||||
<div class="fl align-middle icon-box">
|
<!-- 云海 logo 徽标(结构与 Notice layout 对齐,皮肤见通道 base.layout.css .logo-badge) -->
|
||||||
<div title="常用应用" :class="['icon-btn', isPanel ? 'active' : '']" @click="showPanel">
|
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
|
||||||
<i class="iconfont icon-apps"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="delimiter fl align-middle"></div>
|
<div class="delimiter fl align-middle"></div>
|
||||||
<h4 class="fl align-middle">{{ appName }}</h4>
|
<h4 class="fl align-middle">{{ appName }}</h4>
|
||||||
<div class="school-bg"></div>
|
<div class="school-bg"></div>
|
||||||
<div class="header-aside fr">
|
<div class="header-aside fr">
|
||||||
<img :src="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
|
<UserAvatar class="fl align-middle" :src="userInfo.avatar" :name="userInfo.name" :size="28" placement="brand" @click="linkUserInfo" />
|
||||||
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
|
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
|
||||||
{{ userInfo.name }}
|
{{ userInfo.name }}
|
||||||
</a>
|
</a>
|
||||||
<div class="delimiter fl align-middle"></div>
|
<div class="delimiter fl align-middle"></div>
|
||||||
<a href="javascript:;" @click="logout" class="logout fl align-middle" title="退出登陆">
|
<a href="javascript:;" @click="logout" class="logout fl align-middle" title="退出登陆">
|
||||||
<i class="iconfont icon-logout"></i>
|
<SvgIcon name="logout" :size="18" color="#fff" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -248,7 +271,7 @@ function linkHomePage() {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="link-panel">
|
<div class="link-panel">
|
||||||
<a :href="baseHost + '/BschHome/front/index.html#/apps'" target="_bank" rel="noopener noreferrer">
|
<a :href="baseHost + '/userhome/front/index.html#/apps'" target="_bank" rel="noopener noreferrer">
|
||||||
全部应用
|
全部应用
|
||||||
<i>→</i>
|
<i>→</i>
|
||||||
</a>
|
</a>
|
||||||
@@ -257,3 +280,14 @@ function linkHomePage() {
|
|||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 头部两个 ysch 单 svg 图标(SvgIcon):
|
||||||
|
layout.css 的 .header-aside .logout i / .icon-btn i 只命中 <i>,换 <svg> 后不再命中,
|
||||||
|
颜色与尺寸已由组件 props(#fff / 18、22)给出,此处只补布局——
|
||||||
|
<svg> 默认 inline 会带基线降部空隙,改 block 后与 Notice 的 mask 图标观感一致。
|
||||||
|
(Vue3 scoped 下子组件根节点会带父级 data-v,故 .logout svg 可命中) */
|
||||||
|
.header-aside .logout svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+3
-2
@@ -13,8 +13,9 @@ import mittBus from "@/utils/mitt";
|
|||||||
import "view-ui-plus/dist/styles/viewuiplus.css";
|
import "view-ui-plus/dist/styles/viewuiplus.css";
|
||||||
|
|
||||||
import "@/assets/styles/main.scss";
|
import "@/assets/styles/main.scss";
|
||||||
// 本地化的布局/主题样式(原远程 base.layout.beta / _iview_override / default-theme 合并)
|
// 布局/主题/图标(base.layout.css + iview.override.css + icons.css)由中央静态通道下发:
|
||||||
import "@/assets/styles/layout.css";
|
// index.html 的 yh-theme-init.js 在 DOMContentLoaded 后按 manifest 版本追加 <link>,
|
||||||
|
// 天然排在本行组件库样式与 main.scss 之后(原本地 layout.css 是通道旧版全量拷贝,已删)
|
||||||
//导入自定义指令
|
//导入自定义指令
|
||||||
import directive from "@/utils/directive/index";
|
import directive from "@/utils/directive/index";
|
||||||
|
|
||||||
|
|||||||
+36
-1
@@ -1,9 +1,44 @@
|
|||||||
import { createRouter, createWebHashHistory } from "vue-router";
|
import { createRouter, createWebHashHistory } from "vue-router";
|
||||||
|
import type { RouteLocationNormalizedLoaded, RouteRecordRaw } from "vue-router";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
|
import { useDefaultStore } from "@/store";
|
||||||
|
import { UserRoleEnum } from "@/types";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
routes,
|
routes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前角色是否可访问目标路由(meta.roles 未声明 = 登录即可访问)。
|
||||||
|
* 供路由守卫与 App.vue 角色落定后的复核共用。
|
||||||
|
*/
|
||||||
|
export function isRouteAllowed(
|
||||||
|
route: RouteLocationNormalizedLoaded | RouteRecordRaw,
|
||||||
|
role: UserRoleEnum
|
||||||
|
): boolean {
|
||||||
|
const roles = (route.meta?.roles ?? null) as UserRoleEnum[] | null;
|
||||||
|
return !roles || roles.length === 0 || roles.includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由守卫(防止直接敲 URL 绕过菜单):
|
||||||
|
* - 角色未落定(roleLoaded=false,首次导航早于 App.vue 的 GetUserScope)时放行,
|
||||||
|
* 由 App.vue 在角色落定后调用本守卫的复核逻辑重定向;
|
||||||
|
* - 角色已落定后按 meta.roles 校验,不满足重定向 /no-auth。
|
||||||
|
*/
|
||||||
|
router.beforeEach(to => {
|
||||||
|
if (to.path === "/no-auth") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const store = useDefaultStore();
|
||||||
|
if (!store.roleLoaded) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!isRouteAllowed(to, store.userRole)) {
|
||||||
|
return { path: "/no-auth", replace: true };
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
+37
-20
@@ -1,4 +1,5 @@
|
|||||||
import type { RouteRecordRaw } from "vue-router";
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
import { UserRoleEnum } from "@/types";
|
||||||
import GroupUser from "@/views/group-user/index.vue";
|
import GroupUser from "@/views/group-user/index.vue";
|
||||||
import AppList from "@/views/app/AppList.vue";
|
import AppList from "@/views/app/AppList.vue";
|
||||||
import AppCategory from "@/views/app/AppCategory.vue";
|
import AppCategory from "@/views/app/AppCategory.vue";
|
||||||
@@ -11,42 +12,55 @@ import PermissionView from "@/views/PermissionView.vue";
|
|||||||
import OperationLogList from "@/views/oplog/OperationLogList.vue";
|
import OperationLogList from "@/views/oplog/OperationLogList.vue";
|
||||||
import OapiLogList from "@/views/oapilog/OapiLogList.vue";
|
import OapiLogList from "@/views/oapilog/OapiLogList.vue";
|
||||||
import PushLog from "@/views/push/PushLog.vue";
|
import PushLog from "@/views/push/PushLog.vue";
|
||||||
import PushTest from "@/views/push/PushTest.vue";
|
import DeptManage from "@/views/dept-manage/index.vue";
|
||||||
|
import NoAuth from "@/views/NoAuth.vue";
|
||||||
|
|
||||||
|
// 管理端四级访问控制:meta.roles 为空/未声明 = 登录即可访问
|
||||||
|
const ALL_ADMIN = [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School];
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
redirect: "/group-user",
|
redirect: "/group-user",
|
||||||
},
|
},
|
||||||
|
// ===== 无权限页(路由守卫重定向目标)=====
|
||||||
|
{
|
||||||
|
path: "/no-auth",
|
||||||
|
name: "NoAuth",
|
||||||
|
component: NoAuth,
|
||||||
|
meta: { title: "暂无权限" },
|
||||||
|
},
|
||||||
// ===== 开发组(组/用户管理,多页嵌套目录)=====
|
// ===== 开发组(组/用户管理,多页嵌套目录)=====
|
||||||
{
|
{
|
||||||
path: "/group-user",
|
path: "/group-user",
|
||||||
name: "GroupUser",
|
name: "GroupUser",
|
||||||
component: GroupUser,
|
component: GroupUser,
|
||||||
meta: { title: "组,用户管理", icon: "icon-list", modId: 1101 },
|
meta: { title: "组,用户管理", icon: "icon-list", modId: 1101, roles: ALL_ADMIN },
|
||||||
},
|
},
|
||||||
// ===== 应用管理(单选项卡)=====
|
// ===== 应用管理(单选项卡)=====
|
||||||
// 应用管理主页(= 应用列表),菜单顶层「应用管理」指向此路由(modId 1102)
|
// 应用管理主页(= 应用列表),菜单顶层「应用管理」指向此路由(modId 1102)
|
||||||
|
// 局端不开放应用管理(局端的应用动作是「开通」)
|
||||||
{
|
{
|
||||||
path: "/app-manage/list",
|
path: "/app-manage/list",
|
||||||
name: "AppList",
|
name: "AppList",
|
||||||
component: AppList,
|
component: AppList,
|
||||||
meta: { title: "应用管理", icon: "icon-list", modId: 1102, keepAlive: true },
|
meta: { title: "应用管理", icon: "icon-list", modId: 1102, keepAlive: true, roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.School] },
|
||||||
},
|
},
|
||||||
// 应用类型管理(深层子页,不在菜单里):meta.parentId=1102 关联父菜单「应用管理」,
|
// 应用类型管理(深层子页,不在菜单里):meta.parentId=1102 关联父菜单「应用管理」,
|
||||||
// 由 layout.vue 的 routeChange 推导面包屑「应用管理>应用类型管理」并高亮父菜单
|
// 由 layout.vue 的 routeChange 推导面包屑「应用管理>应用类型管理」并高亮父菜单
|
||||||
|
// 校管可:类别改名/排序(写覆盖表);新增/删除类别按钮由页面按级别隐藏
|
||||||
{
|
{
|
||||||
path: "/app-manage/category",
|
path: "/app-manage/category",
|
||||||
name: "AppCategory",
|
name: "AppCategory",
|
||||||
component: AppCategory,
|
component: AppCategory,
|
||||||
meta: { title: "应用类型管理", modId: 11022, parentId: 1102 },
|
meta: { title: "应用类型管理", modId: 11022, parentId: 1102, roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.School] },
|
||||||
},
|
},
|
||||||
// 模块管理(深层子页,不在菜单里):meta.parentId=1102 关联父菜单「应用管理」
|
// 模块管理(深层子页,不在菜单里):仅超管
|
||||||
{
|
{
|
||||||
path: "/app-manage/module",
|
path: "/app-manage/module",
|
||||||
name: "ModuleManage",
|
name: "ModuleManage",
|
||||||
component: ModuleManage,
|
component: ModuleManage,
|
||||||
meta: { title: "模块管理", modId: 11023, parentId: 1102 },
|
meta: { title: "模块管理", modId: 11023, parentId: 1102, roles: [UserRoleEnum.SuperAdmin] },
|
||||||
},
|
},
|
||||||
// /app-manage 兜底重定向到主页
|
// /app-manage 兜底重定向到主页
|
||||||
{
|
{
|
||||||
@@ -59,7 +73,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: "/role-authz/list",
|
path: "/role-authz/list",
|
||||||
name: "RoleAuthzList",
|
name: "RoleAuthzList",
|
||||||
component: RoleAuthzList,
|
component: RoleAuthzList,
|
||||||
meta: { title: "角色授权", icon: "icon-list", modId: 1110, keepAlive: true },
|
meta: { title: "角色授权", icon: "icon-list", modId: 1110, keepAlive: true, roles: ALL_ADMIN },
|
||||||
},
|
},
|
||||||
// 角色管理(深层子页,不在菜单里):meta.parentId=1110 关联父菜单「角色授权」,
|
// 角色管理(深层子页,不在菜单里):meta.parentId=1110 关联父菜单「角色授权」,
|
||||||
// 由 layout.vue 推导面包屑「角色授权>角色管理」并高亮父菜单(与 /app-manage/category 范式一致)
|
// 由 layout.vue 推导面包屑「角色授权>角色管理」并高亮父菜单(与 /app-manage/category 范式一致)
|
||||||
@@ -67,7 +81,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: "/role-authz/manage",
|
path: "/role-authz/manage",
|
||||||
name: "RoleManage",
|
name: "RoleManage",
|
||||||
component: RoleManage,
|
component: RoleManage,
|
||||||
meta: { title: "角色管理", modId: 11101, parentId: 1110 },
|
meta: { title: "角色管理", modId: 11101, parentId: 1110, roles: ALL_ADMIN },
|
||||||
},
|
},
|
||||||
// /role-authz 兜底重定向到主页
|
// /role-authz 兜底重定向到主页
|
||||||
{
|
{
|
||||||
@@ -78,45 +92,48 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: "/app-open",
|
path: "/app-open",
|
||||||
name: "AppOpen",
|
name: "AppOpen",
|
||||||
component: AppOpen,
|
component: AppOpen,
|
||||||
meta: { title: "应用开通", icon: "icon-list", modId: 1103 },
|
meta: { title: "应用开通", icon: "icon-list", modId: 1103, roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/group-auth",
|
path: "/group-auth",
|
||||||
name: "GroupAuth",
|
name: "GroupAuth",
|
||||||
component: GroupAuth,
|
component: GroupAuth,
|
||||||
meta: { title: "组授权", icon: "icon-list", modId: 1104 },
|
meta: { title: "组授权", icon: "icon-list", modId: 1104, roles: ALL_ADMIN },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/permission-view",
|
path: "/permission-view",
|
||||||
name: "PermissionView",
|
name: "PermissionView",
|
||||||
component: PermissionView,
|
component: PermissionView,
|
||||||
meta: { title: "权限一览", icon: "icon-list", modId: 1105 },
|
meta: { title: "权限一览", icon: "icon-list", modId: 1105, roles: ALL_ADMIN },
|
||||||
|
},
|
||||||
|
// ===== 部门管理(2026-10-20 部门线 P0 最小批,2026-10-21 迁入「基础设置」分组)=====
|
||||||
|
// 原挂「权限管理」(modId 1108);部门属于组织基础配置而非权限,改挂「基础设置」(modId 1115)
|
||||||
|
// 无删除/成员/职务/新建入口——建部门=建组回「组,用户管理」
|
||||||
|
{
|
||||||
|
path: "/dept-manage",
|
||||||
|
name: "DeptManage",
|
||||||
|
component: DeptManage,
|
||||||
|
meta: { title: "部门管理", icon: "icon-list", modId: 1106, parentId: 1115, roles: ALL_ADMIN },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/push-log",
|
path: "/push-log",
|
||||||
name: "PushLog",
|
name: "PushLog",
|
||||||
component: PushLog,
|
component: PushLog,
|
||||||
meta: { title: "推送日志", icon: "icon-list", modId: 1112, keepAlive: true },
|
meta: { title: "推送日志", icon: "icon-list", modId: 1112, keepAlive: true, roles: [UserRoleEnum.SuperAdmin] },
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/push-test",
|
|
||||||
name: "PushTest",
|
|
||||||
component: PushTest,
|
|
||||||
meta: { title: "测试推送", icon: "icon-list", modId: 1113 },
|
|
||||||
},
|
},
|
||||||
// ===== 操作日志 =====
|
// ===== 操作日志 =====
|
||||||
{
|
{
|
||||||
path: "/oplog",
|
path: "/oplog",
|
||||||
name: "OperationLogList",
|
name: "OperationLogList",
|
||||||
component: OperationLogList,
|
component: OperationLogList,
|
||||||
meta: { title: "操作日志", icon: "icon-list", modId: 1111, keepAlive: true },
|
meta: { title: "操作日志", icon: "icon-list", modId: 1111, keepAlive: true, roles: [UserRoleEnum.SuperAdmin] },
|
||||||
},
|
},
|
||||||
// ===== 开放接口日志 =====
|
// ===== 开放接口日志 =====
|
||||||
{
|
{
|
||||||
path: "/oapilog",
|
path: "/oapilog",
|
||||||
name: "OapiLogList",
|
name: "OapiLogList",
|
||||||
component: OapiLogList,
|
component: OapiLogList,
|
||||||
meta: { title: "开放接口日志", icon: "icon-list", modId: 1114, keepAlive: true },
|
meta: { title: "开放接口日志", icon: "icon-list", modId: 1114, keepAlive: true, roles: [UserRoleEnum.SuperAdmin] },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -22,12 +22,16 @@ export interface IModule {
|
|||||||
export interface IdefaultStore {
|
export interface IdefaultStore {
|
||||||
curStatsDate: Date;
|
curStatsDate: Date;
|
||||||
userRole: UserRoleEnum;
|
userRole: UserRoleEnum;
|
||||||
|
/** 管理端访问范围(GetUserScope 返回,后端单点判定):BUREAU 区域组 / SCHOOL 可管理学校组 */
|
||||||
|
userScope: { bureauGroupId?: number | null; schoolGroupIds?: number[] | null };
|
||||||
lenLevel: Array<SchLenLevel>;
|
lenLevel: Array<SchLenLevel>;
|
||||||
subjects: Array<Subject>;
|
subjects: Array<Subject>;
|
||||||
/** 系统字典(GetSysDict 接口返回) */
|
/** 系统字典(GetSysDict 接口返回) */
|
||||||
sysDict: Record<string, any>;
|
sysDict: Record<string, any>;
|
||||||
/** 模块列表(当前写死,后续接入接口) */
|
/** 模块列表(当前写死,后续接入接口) */
|
||||||
modules: Array<IModule>;
|
modules: Array<IModule>;
|
||||||
|
/** 角色是否已落定(App.vue GetUserScope/降级判定完成后置 true,路由守卫依赖) */
|
||||||
|
roleLoaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDefaultStore = defineStore("defaultStore", {
|
export const useDefaultStore = defineStore("defaultStore", {
|
||||||
@@ -35,10 +39,12 @@ export const useDefaultStore = defineStore("defaultStore", {
|
|||||||
return {
|
return {
|
||||||
curStatsDate: new Date(),
|
curStatsDate: new Date(),
|
||||||
userRole: UserRoleEnum.School,
|
userRole: UserRoleEnum.School,
|
||||||
|
userScope: { bureauGroupId: null, schoolGroupIds: null },
|
||||||
lenLevel: [],
|
lenLevel: [],
|
||||||
subjects: [],
|
subjects: [],
|
||||||
sysDict: {},
|
sysDict: {},
|
||||||
modules: [],
|
modules: [],
|
||||||
|
roleLoaded: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
@@ -48,6 +54,12 @@ export const useDefaultStore = defineStore("defaultStore", {
|
|||||||
setUserRole(role: UserRoleEnum) {
|
setUserRole(role: UserRoleEnum) {
|
||||||
this.userRole = role;
|
this.userRole = role;
|
||||||
},
|
},
|
||||||
|
setUserScope(scope: { bureauGroupId?: number | null; schoolGroupIds?: number[] | null }) {
|
||||||
|
this.userScope = scope || { bureauGroupId: null, schoolGroupIds: null };
|
||||||
|
},
|
||||||
|
setRoleLoaded(loaded: boolean) {
|
||||||
|
this.roleLoaded = loaded;
|
||||||
|
},
|
||||||
setSchLenLevel(len: Array<SchLenLevel>) {
|
setSchLenLevel(len: Array<SchLenLevel>) {
|
||||||
this.lenLevel = len;
|
this.lenLevel = len;
|
||||||
},
|
},
|
||||||
@@ -61,9 +73,9 @@ export const useDefaultStore = defineStore("defaultStore", {
|
|||||||
this.sysDict = {};
|
this.sysDict = {};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/** 获取模块列表(数据来源见 src/assets/config/menu.ts,后续接入接口无缝替换) */
|
/** 获取模块列表(数据源 src/assets/config/menu.ts,按当前级别过滤菜单权限矩阵) */
|
||||||
async fetchModules() {
|
async fetchModules() {
|
||||||
this.modules = await getMenuList();
|
this.modules = await getMenuList(this.userRole);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
|
|||||||
Vendored
+4
@@ -105,6 +105,10 @@ export interface MyConfirm {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
closeText?: string;
|
closeText?: string;
|
||||||
|
/** 输名确认:值与 msg 对象名一致才允许点确认键(危险操作删除模,对齐 projman) */
|
||||||
|
confirmName?: string;
|
||||||
|
/** 红色确认键(危险操作) */
|
||||||
|
danger?: boolean;
|
||||||
onOk?: Function;
|
onOk?: Function;
|
||||||
onCancel?: Function;
|
onCancel?: Function;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ export enum OperatorEnum {
|
|||||||
sel,
|
sel,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理端访问级别(与后端 AccessLevelEnum / GetUserScope.level 对齐,后端单点判定)。
|
||||||
|
* 注意:School 由旧值 2 改为 3(原枚举值无消费方依赖具体数值,安全重排)。
|
||||||
|
*/
|
||||||
export enum UserRoleEnum {
|
export enum UserRoleEnum {
|
||||||
SuperAdmin = 1,
|
SuperAdmin = 1,
|
||||||
|
Bureau,
|
||||||
School,
|
School,
|
||||||
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
//学段
|
//学段
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ function closeLoading() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = conf.serviceHost;
|
/**
|
||||||
|
* mock 模式必须短路成同源相对路径,否则 alova 会带着 serviceHost(本地 8060 / 线上域)跨源直连,
|
||||||
|
* vite-plugin-mock 的中间件根本拦不到——这正是 `VITE_IS_MOCK=true` 开了却仍报 "Failed to fetch" 的根因
|
||||||
|
* (axios 那条老链路的 api.config.ts 认 isMock,迁到 alova 后这一处漏了)。
|
||||||
|
* 同源之后:mock 注册过的接口(/api/base/GetUserInfo、GetUserMod)由 mock 应答,
|
||||||
|
* 未注册的接口继续经 vite.config 的 /api 代理透传给后端(见 YH_API_PROXY_TARGET)。
|
||||||
|
*/
|
||||||
|
const baseUrl = conf.isMock ? "" : conf.serviceHost;
|
||||||
|
|
||||||
/* 创建带 View Design UI 的 Alova 实例 */
|
/* 创建带 View Design UI 的 Alova 实例 */
|
||||||
export const alovaInstance = createBschAlova(baseUrl, {
|
export const alovaInstance = createBschAlova(baseUrl, {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ function closeLoading() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// const baseUrl = conf.serviceHost + "/api";
|
// const baseUrl = conf.serviceHost + "/api";
|
||||||
const baseUrl = conf.serviceHost + "/mobiapi";
|
// 同 alova-iview:mock 模式短路成同源,否则 vite-plugin-mock / dev 代理都拦不到跨源请求
|
||||||
|
const baseUrl = (conf.isMock ? "" : conf.serviceHost) + "/mobiapi";
|
||||||
/* 创建带 Vant UI 的 Alova 实例 */
|
/* 创建带 Vant UI 的 Alova 实例 */
|
||||||
export const alovaInstance = createBschAlova(baseUrl, {
|
export const alovaInstance = createBschAlova(baseUrl, {
|
||||||
injectHeaders(method) {
|
injectHeaders(method) {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* 默认头像样式(首字 / 剪影)公共源(YschTheme,scripts/sync-apps.mjs 下发三端 src/utils/avatar.ts)。
|
||||||
|
* 取值链与主题同构(theme-core.js resolveEffectiveThemeState 的合成链写法):
|
||||||
|
* URL ?yh-avatar=text|silhouette(仅预览/排障覆盖:只影响读、绝不写回)
|
||||||
|
* > localStorage["yh-avatar"](用户自选,纯字符串)
|
||||||
|
* > localStorage["yh-avatar-sch-default"](学校默认,UserHome 登录后写入)
|
||||||
|
* > "text"(系统默认)
|
||||||
|
* 值域恒为 "text" | "silhouette",键缺失/值非法均静默回落下一级,不报错不阻塞渲染。
|
||||||
|
* 渲染三态与取字规则的唯一契约见 UserHome《设置页修复与默认头像落地方案》§10.1/§10.2。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AvatarStyle = "text" | "silhouette";
|
||||||
|
|
||||||
|
interface StorageLike {
|
||||||
|
getItem(k: string): string | null;
|
||||||
|
setItem(k: string, v: string): void;
|
||||||
|
removeItem(k: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AVATAR_STORAGE_KEY = "yh-avatar";
|
||||||
|
|
||||||
|
/** 学校默认头像存储键(UserHome 登录时写入;值格式与用户键相同,仅无"用户自选"语义) */
|
||||||
|
export const SCHOOL_DEFAULT_AVATAR_STORAGE_KEY = "yh-avatar-sch-default";
|
||||||
|
|
||||||
|
export const AVATAR_STYLES: readonly AvatarStyle[] = ["text", "silhouette"];
|
||||||
|
|
||||||
|
export const DEFAULT_AVATAR_STYLE: AvatarStyle = "text";
|
||||||
|
|
||||||
|
/** 值合法才采用(用户键/学校键同一判据;脏数据不生效) */
|
||||||
|
function readAvatarStyleOrNull(raw: string | null): AvatarStyle | null {
|
||||||
|
return raw === "text" || raw === "silhouette" ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL 覆盖参数(?yh-avatar=text|silhouette):非法值忽略,且只影响"读哪一档"、绝不写回 localStorage */
|
||||||
|
function readAvatarOverride(): AvatarStyle | null {
|
||||||
|
try {
|
||||||
|
const search = (globalThis.location && globalThis.location.search) || "";
|
||||||
|
const m = new RegExp("[?&]yh-avatar=([^&]*)").exec(search);
|
||||||
|
return m ? readAvatarStyleOrNull(decodeURIComponent(m[1])) : null;
|
||||||
|
} catch {
|
||||||
|
/* 无 location 环境(测试/SSR):无覆盖 */
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生效样式合成(与 resolveEffectiveThemeState 同构):
|
||||||
|
* URL 覆盖 > localStorage["yh-avatar"](用户自选)> localStorage["yh-avatar-sch-default"](学校默认)
|
||||||
|
* > DEFAULT_AVATAR_STYLE。
|
||||||
|
*/
|
||||||
|
export function resolveAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): AvatarStyle {
|
||||||
|
let style: AvatarStyle | null = null;
|
||||||
|
if (storage) {
|
||||||
|
style = readAvatarStyleOrNull(storage.getItem(AVATAR_STORAGE_KEY));
|
||||||
|
if (!style) style = readAvatarStyleOrNull(storage.getItem(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY));
|
||||||
|
}
|
||||||
|
if (!style) style = DEFAULT_AVATAR_STYLE;
|
||||||
|
return readAvatarOverride() || style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存用户自选(直写 localStorage,无响应式;调用方操作后自行刷新 UI) */
|
||||||
|
export function saveAvatarStyle(style: AvatarStyle, storage: StorageLike | undefined = globalThis.localStorage): void {
|
||||||
|
try {
|
||||||
|
storage?.setItem(AVATAR_STORAGE_KEY, style);
|
||||||
|
} catch {
|
||||||
|
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认) */
|
||||||
|
export function clearAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): void {
|
||||||
|
try {
|
||||||
|
storage?.removeItem(AVATAR_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
/* 隐私模式静默 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取字(顺序判定,先命中先返回):含 "@" 先截 "@" 前段(邮箱取首字母);
|
||||||
|
* 首字符必须用展开 [...s][0](禁用 charAt(0),避免代理对截半出乱码)。
|
||||||
|
* ASCII 字母转大写;纯数字回落 ""(剪影);符号/emoji/空白回落 ""(剪影);其余(CJK 等)原样。
|
||||||
|
*/
|
||||||
|
export function pickAvatarText(name: string | null | undefined): string {
|
||||||
|
let s = String(name ?? "");
|
||||||
|
const at = s.indexOf("@");
|
||||||
|
if (at > -1) s = s.slice(0, at);
|
||||||
|
const ch = [...s.trim()][0];
|
||||||
|
if (ch === undefined) return "";
|
||||||
|
if (/^[A-Za-z]$/.test(ch)) return ch.toUpperCase();
|
||||||
|
if (/^[0-9]$/.test(ch)) return "";
|
||||||
|
if (/^[\p{P}\p{S}\s]$/u.test(ch)) return "";
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
import { useDefaultStore } from "@/store";
|
||||||
|
import { UserRoleEnum } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用管理页学校上下文(管理端权限分级改造 §4.3)。<br>
|
||||||
|
* SCHOOL 模式专属:多校管理员(schoolGroupIds.length > 1)页面顶部出现「学校」下拉,
|
||||||
|
* currentSchoolId 随接口下发给 AppList/AppSortList/AppSort/EditApp/EditType/AppTypeSort,
|
||||||
|
* 覆盖只对所选学校生效;单校管理员不显示下拉(后端自动取唯一学校),
|
||||||
|
* 超管/局端不进入本上下文(局端无应用管理菜单)。
|
||||||
|
*/
|
||||||
|
export function useAppSchoolScope() {
|
||||||
|
const store = useDefaultStore();
|
||||||
|
const isSchoolMode = computed(() => store.userRole === UserRoleEnum.School);
|
||||||
|
const schoolGroupIds = computed<number[]>(() => store.userScope?.schoolGroupIds ?? []);
|
||||||
|
const multiSchool = computed(() => isSchoolMode.value && schoolGroupIds.value.length > 1);
|
||||||
|
|
||||||
|
const schoolList = ref<{ id: number; name: string }[]>([]);
|
||||||
|
// 默认不选中(currentSchoolId=undefined):查询走后端「多校并集」模式;
|
||||||
|
// 写操作(编辑/排序)要求先选定学校,由页面用 requireSchool() 守卫
|
||||||
|
const currentSchoolId = ref<number | undefined>(undefined);
|
||||||
|
|
||||||
|
/** 多校模式下写操作前置校验:未选学校时提示并阻断,返回 false */
|
||||||
|
function requireSchool(): boolean {
|
||||||
|
if (multiSchool.value && !currentSchoolId.value) {
|
||||||
|
Message.warning("您管理多所学校,请先在顶部选择学校后再操作");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (multiSchool.value) {
|
||||||
|
// 下拉数据源(SUPER/BUREAU 后端返回空数组,前端也不渲染下拉)
|
||||||
|
Promise.resolve(Apis.AppSettings.schoolList({ cacheFor: null }))
|
||||||
|
.then((list: any) => {
|
||||||
|
schoolList.value = Array.isArray(list) ? list : [];
|
||||||
|
})
|
||||||
|
.catch(e => console.warn("获取可管理学校列表失败", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isSchoolMode, multiSchool, schoolList, currentSchoolId, requireSchool };
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ref, onMounted, onBeforeUnmount, watch, type Ref } from "vue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实测某容器元素的可用高度(px),随尺寸变化实时更新。
|
||||||
|
*
|
||||||
|
* 用途:表格页把 <Table> 放进 flex:1 的 .table-region 里,用本组合式量出该区域
|
||||||
|
* 的真实高度喂给 view-ui 的 `:height`(固定表头需要一个数值高度),从而彻底替代
|
||||||
|
* 旧的 useTableHeight()「视口高 - 一堆写死常量」的算法——加/删工具条、改顶栏高
|
||||||
|
* 都不再需要同步改任何魔法数字。
|
||||||
|
*
|
||||||
|
* @param el 目标容器 ref(模板里 ref 绑定的那个元素)
|
||||||
|
* @returns 响应式高度(px);挂载测量前为 0,调用方可 `:height="h || undefined"` 兜底
|
||||||
|
*/
|
||||||
|
export function useElementHeight(el: Ref<HTMLElement | null>): Ref<number> {
|
||||||
|
const height = ref(0);
|
||||||
|
let ro: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
if (el.value) height.value = Math.floor(el.value.clientHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const observe = () => {
|
||||||
|
ro?.disconnect();
|
||||||
|
if (!el.value || typeof ResizeObserver === "undefined") return;
|
||||||
|
measure();
|
||||||
|
ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(observe);
|
||||||
|
// v-if / v-show 切换导致容器重建时,重新挂观察
|
||||||
|
watch(el, observe);
|
||||||
|
onBeforeUnmount(() => ro?.disconnect());
|
||||||
|
|
||||||
|
return height;
|
||||||
|
}
|
||||||
+26
-33
@@ -1,5 +1,5 @@
|
|||||||
import { IObj, UseTableSetting } from "@/types/Basics";
|
import { IObj, UseTableSetting } from "@/types/Basics";
|
||||||
import { resolveComponent } from "vue";
|
import { actionIcon, actionText } from "@/utils/tableActions";
|
||||||
export default class Utils {
|
export default class Utils {
|
||||||
/**
|
/**
|
||||||
* 格式化日期
|
* 格式化日期
|
||||||
@@ -67,6 +67,20 @@ export default class Utils {
|
|||||||
} else return "";
|
} else return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在线预览大小上限(字节):日志大字段(请求体/响应)超过此值时隐藏"在线预览"、只保留"下载"。
|
||||||
|
* 浏览器 JSON 美化 + <pre> 渲染在数百 KB 起会卡顿,50KB 为体验无损的保守阈值。
|
||||||
|
*/
|
||||||
|
public static readonly PREVIEW_MAX_BYTES = 50 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否允许在线预览:有内容(size > 0)且字节数不超过阈值。
|
||||||
|
* size 为 null/undefined/0(未记录请求体、saveResponse=false、响应为空)→ 不可预览,仅下载。
|
||||||
|
*/
|
||||||
|
public static canPreview(size?: number | null): boolean {
|
||||||
|
return size != null && size > 0 && size <= Utils.PREVIEW_MAX_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除指定键
|
* 删除指定键
|
||||||
*/
|
*/
|
||||||
@@ -76,7 +90,8 @@ export default class Utils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* iview Tooltip封装
|
* iview Tooltip封装(旧 API,薄封装转调 tableActions.actionIcon,签名不变)
|
||||||
|
* 2026-10-10 起 D1 修正:禁用态 onClick 置 null(原先禁用仍可点)。
|
||||||
*/
|
*/
|
||||||
public static operatingHandle(
|
public static operatingHandle(
|
||||||
h: any,
|
h: any,
|
||||||
@@ -85,30 +100,10 @@ export default class Utils {
|
|||||||
callback: Function,
|
callback: Function,
|
||||||
disabled: boolean = false,
|
disabled: boolean = false,
|
||||||
) {
|
) {
|
||||||
if (disabled) iconClass += " disabled";
|
return actionIcon(h, { key: info, label: info, icon: iconClass, on: () => callback(), disabled });
|
||||||
|
|
||||||
return h(
|
|
||||||
resolveComponent("Tooltip"),
|
|
||||||
{
|
|
||||||
content: info,
|
|
||||||
placement: "bottom",
|
|
||||||
transfer: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
default: () => [
|
|
||||||
h(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
class: `operating-btn iconfont ${iconClass}`,
|
|
||||||
onClick: callback,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 旧 API,薄封装转调 tableActions.actionText(签名不变;第三参仅识别 text-error → danger) */
|
||||||
public static operatingTextHandle(
|
public static operatingTextHandle(
|
||||||
h: any,
|
h: any,
|
||||||
info: string,
|
info: string,
|
||||||
@@ -116,15 +111,13 @@ export default class Utils {
|
|||||||
callback: Function,
|
callback: Function,
|
||||||
disabled: boolean = false,
|
disabled: boolean = false,
|
||||||
) {
|
) {
|
||||||
if (disabled) className += " disabled";
|
return actionText(h, {
|
||||||
return h(
|
key: info,
|
||||||
"div",
|
label: info,
|
||||||
{
|
danger: (className || "").includes("text-error"),
|
||||||
class: `operating-link ${className}`,
|
on: () => callback(),
|
||||||
onClick: disabled ? null : callback,
|
disabled,
|
||||||
},
|
});
|
||||||
info,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* 表格操作列统一原子(方案:outputs/table-action-buttons/操作列统一方案-20261010.md)
|
||||||
|
*
|
||||||
|
* 三条变化轴各自独立,每加一种形态 = 加 1 个渲染器 + RENDERERS 表加 1 行,不动既有分支:
|
||||||
|
* 轴1 形态 actionIcon / actionText / actionMore —— 只画节点,零策略
|
||||||
|
* 轴2 动作集 normalizeActions —— 纯数据:when 过滤 / disabled 归一 / danger 沉底
|
||||||
|
* 轴3 溢出 splitActions —— 纯函数:直出与「更多」分档,零 DOM 可单测
|
||||||
|
* 组装 actionCell / actionColumn —— 唯一懂 DOM 结构与表格列
|
||||||
|
*
|
||||||
|
* 形态由页面【显式声明】(mode: "icon" | "text",缺省 text),不按动作数自动切换——
|
||||||
|
* 工程间风格区分是既定口径(Notice=图标钮 / UserBase=文字链),各自恒定不漂移。
|
||||||
|
* 「更多▾」只是长尾收纳(动作数 ≥ maxInline+2 时启用),不改变形态、不分档。
|
||||||
|
* 菜单项纯文字不带图标(动作一多找不到贴切图标,文字更准);transfer 到 body,
|
||||||
|
* 避免 fixed/sticky 操作列裁剪与层叠上下文遮盖(原型已实证)。
|
||||||
|
*/
|
||||||
|
import { h, resolveComponent } from "vue";
|
||||||
|
|
||||||
|
export interface ActionItem {
|
||||||
|
/** 稳定标识:溢出分桶按 key 去重,必填 */
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
/** 点击回调,normalizeActions 阶段闭包绑定行数据 */
|
||||||
|
on: (row: any) => void;
|
||||||
|
/** iconfont 类名(仅 mode:"icon" 时消费;通道 icons.css 全集见 YschTheme) */
|
||||||
|
icon?: string;
|
||||||
|
/** 危险动作:文字转红(text-error)、恒沉底、「更多」内分隔线隔离 */
|
||||||
|
danger?: boolean;
|
||||||
|
/** 返回 false → 该行不渲染此动作(决定「有没有」) */
|
||||||
|
when?: (row: any) => boolean;
|
||||||
|
/** 直接置灰 */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** 按行判定置灰(决定「能不能点」),disabled 显式值优先 */
|
||||||
|
disabledWhen?: (row: any) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionCellOpts {
|
||||||
|
/** 形态显式声明:icon=图标圆钮 / text=文字链,缺省 text;不按动作数自动切换 */
|
||||||
|
mode?: "icon" | "text";
|
||||||
|
/** 超额动作收进「更多」,默认开(防 5+ 动作页面忘传爆宽) */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** 直出上限,默认 2;n ≤ maxInline+1 时全直出(3 个动作不进更多) */
|
||||||
|
maxInline?: number;
|
||||||
|
/** 列宽;缺省 icon = n*40+40 / text = 180 */
|
||||||
|
width?: number;
|
||||||
|
align?: "center" | "left" | "right";
|
||||||
|
/** 缺省不固定列(与存量页面一致);需要吸右显式传 "right" */
|
||||||
|
fixed?: "right" | "left" | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 轴 1:渲染器(只画节点,零策略) ============ */
|
||||||
|
|
||||||
|
/** 图标圆钮:Tooltip > div.operating-btn(沿用通道既有 class,主题层零改动) */
|
||||||
|
export function actionIcon(h: any, item: ActionItem) {
|
||||||
|
return h(
|
||||||
|
resolveComponent("Tooltip"),
|
||||||
|
{ content: item.label, placement: "bottom", transfer: true },
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h("div", {
|
||||||
|
class: `operating-btn iconfont ${item.icon}${item.disabled ? " disabled" : ""}`,
|
||||||
|
onClick: item.disabled ? null : item.on,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文字链:danger 输出既有 text-error 类对接主题红色 */
|
||||||
|
export function actionText(h: any, item: ActionItem) {
|
||||||
|
return h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
class: `operating-link${item.danger ? " text-error" : ""}${item.disabled ? " disabled" : ""}`,
|
||||||
|
onClick: item.disabled ? null : item.on,
|
||||||
|
},
|
||||||
|
item.label,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「更多▾」+ 下拉菜单:iView Dropdown transfer 模式,菜单挂 body */
|
||||||
|
export function actionMore(h: any, items: ActionItem[]) {
|
||||||
|
return h(
|
||||||
|
resolveComponent("Dropdown"),
|
||||||
|
{ class: "op-more", placement: "bottom-end", transfer: true },
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h("div", { class: "operating-link op-more-label" }, [
|
||||||
|
"更多",
|
||||||
|
h(resolveComponent("Icon"), { type: "ios-arrow-down", class: "op-more-arrow" }),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
list: () => [
|
||||||
|
h(resolveComponent("DropdownMenu"), {}, {
|
||||||
|
default: () => items.map((it) =>
|
||||||
|
h(
|
||||||
|
resolveComponent("DropdownItem"),
|
||||||
|
{
|
||||||
|
key: it.key,
|
||||||
|
divided: !!(it.danger && items.length > 1),
|
||||||
|
disabled: !!it.disabled,
|
||||||
|
class: it.danger ? "op-item-danger" : "",
|
||||||
|
onClick: () => it.on(),
|
||||||
|
},
|
||||||
|
{ default: () => it.label },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 轴 2:归一化(纯数据) ============ */
|
||||||
|
export function normalizeActions(items: ActionItem[], row: any): ActionItem[] {
|
||||||
|
return items
|
||||||
|
.filter((it) => (it.when ? it.when(row) : true))
|
||||||
|
.map((it) => ({
|
||||||
|
...it,
|
||||||
|
disabled: it.disabled ?? (it.disabledWhen ? it.disabledWhen(row) : false),
|
||||||
|
on: () => it.on(row),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => Number(!!a.danger) - Number(!!b.danger)); // danger 恒沉底(稳定排序)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 轴 3:溢出策略(纯函数,可单测、零 DOM) ============ */
|
||||||
|
export function splitActions(items: ActionItem[], maxInline = 2): { inline: ActionItem[]; overflow: ActionItem[] } {
|
||||||
|
if (items.length <= maxInline + 1) return { inline: items, overflow: [] };
|
||||||
|
const inline = items.filter((it) => !it.danger).slice(0, maxInline);
|
||||||
|
const inlineKeys = new Set(inline.map((it) => it.key));
|
||||||
|
return { inline, overflow: items.filter((it) => !inlineKeys.has(it.key)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 组装层 ============ */
|
||||||
|
const RENDERERS: Record<string, (h: any, item: ActionItem) => any> = { icon: actionIcon, text: actionText };
|
||||||
|
|
||||||
|
export function actionCell(h: any, items: ActionItem[], opts: ActionCellOpts = {}) {
|
||||||
|
const mode = opts.mode ?? "text";
|
||||||
|
const { inline, overflow } = (opts.collapsible ?? true)
|
||||||
|
? splitActions(items, opts.maxInline ?? 2)
|
||||||
|
: { inline: items, overflow: [] };
|
||||||
|
|
||||||
|
const render = RENDERERS[mode] || actionText;
|
||||||
|
const children = inline.map((it) => render(h, it));
|
||||||
|
if (overflow.length) children.push(actionMore(h, overflow));
|
||||||
|
|
||||||
|
return h("div", { class: "op-cell" }, children); // flex 横排容器,见 main.scss
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 产出可直接塞进 columns 的操作列对象;列宽按「声明数」估算,when 过滤后只宽不窄 */
|
||||||
|
export function actionColumn(items: ActionItem[], opts: ActionCellOpts = {}) {
|
||||||
|
const mode = opts.mode ?? "text";
|
||||||
|
return {
|
||||||
|
title: "操作",
|
||||||
|
key: "action",
|
||||||
|
width: opts.width ?? (mode === "icon" ? items.length * 40 + 40 : 180),
|
||||||
|
align: opts.align ?? "center",
|
||||||
|
...(opts.fixed === undefined ? {} : { fixed: opts.fixed === false ? undefined : opts.fixed }),
|
||||||
|
render: (h: any, p: any) => actionCell(h, normalizeActions(items, p.row), opts),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* 树勾选级联工具
|
||||||
|
*
|
||||||
|
* 级联由 zTree 原生 chkboxType 承担(两处页面均配置 {Y:"ps", N:"s"}):
|
||||||
|
* Y="ps" 勾选任意节点 -> 其父/祖先全部勾选(p)+ 其下子孙全部勾选(s)
|
||||||
|
* N="s" 取消节点 -> 其下子孙全部取消(s),不影响其父/祖先
|
||||||
|
* 即:勾选子 -> 祖先全部勾选;勾选父 -> 子孙全部勾选;取消父 -> 子孙全部取消;取消子 -> 父/祖先不受影响。
|
||||||
|
*
|
||||||
|
* zTree 原生级联只作用于 zTree 节点,本工具负责把应用子树最终的勾选态同步回本地数据
|
||||||
|
* (AppItem.modules),作为模块明细 diff 提交 / 全量保存的依据。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ModuleVo = {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
checked?: boolean;
|
||||||
|
inheritFlag?: boolean;
|
||||||
|
children?: ModuleVo[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function walkModules(nodes: ModuleVo[] | undefined, fn: (n: ModuleVo) => void): void {
|
||||||
|
(nodes || []).forEach((n) => {
|
||||||
|
fn(n);
|
||||||
|
walkModules(n.children, fn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原生级联完成后调用:把 zTree 中某应用子树的最终勾选态同步回本地 AppItem.modules。
|
||||||
|
* @param app 本地应用数据(提交依据)
|
||||||
|
* @param anyNode zTree 内该应用下任意节点(应用节点或模块节点),沿父链回溯到应用根节点
|
||||||
|
*/
|
||||||
|
export function syncAppModulesFromTree(app: { modules?: ModuleVo[] } | null | undefined, anyNode: any): void {
|
||||||
|
if (!app?.modules || !anyNode) return;
|
||||||
|
let root: any = anyNode;
|
||||||
|
while (root && root.nodeType !== "app") {
|
||||||
|
root = root.getParentNode ? root.getParentNode() : null;
|
||||||
|
}
|
||||||
|
if (!root) return;
|
||||||
|
const checkedMap = new Map<number, boolean>();
|
||||||
|
(function collect(n: any) {
|
||||||
|
if (n.nodeType === "module" && n.rawId != null) checkedMap.set(Number(n.rawId), !!n.checked);
|
||||||
|
(n.children || []).forEach(collect);
|
||||||
|
})(root);
|
||||||
|
walkModules(app.modules, (m) => {
|
||||||
|
const v = checkedMap.get(Number(m.id));
|
||||||
|
if (v !== undefined) m.checked = v;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -372,14 +372,14 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.school-name {
|
.school-name {
|
||||||
color: #2d8cf0;
|
color: var(--brand);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authz-panel {
|
.authz-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 320px;
|
min-height: 0;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|||||||
+167
-77
@@ -38,12 +38,19 @@
|
|||||||
<Icon class="collapse-icon"
|
<Icon class="collapse-icon"
|
||||||
:type="collapsedMap[String(item.cat.id)] ? 'ios-arrow-forward' : 'ios-arrow-down'" />
|
:type="collapsedMap[String(item.cat.id)] ? 'ios-arrow-forward' : 'ios-arrow-down'" />
|
||||||
<span class="cat-name" :title="item.cat.name">{{ item.cat.name }}</span>
|
<span class="cat-name" :title="item.cat.name">{{ item.cat.name }}</span>
|
||||||
<span class="cat-count">{{ item.treeData.length }}个应用</span>
|
<span class="cat-spacer"></span>
|
||||||
<span class="inherit-cap">子组继承</span>
|
<!-- 开关外层 @click.stop:否则点击会冒泡到卡片头部触发折叠 -->
|
||||||
|
<span class="expand-all" @click.stop>
|
||||||
|
<span class="expand-all-label">全部展开:</span>
|
||||||
|
<Switch size="small" v-model="expandAllMap[String(item.cat.id)]" />
|
||||||
|
</span>
|
||||||
|
<span class="inherit-cap" title="勾选后子组继承本次授权,不勾选仅本组生效">子组继承</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="!collapsedMap[String(item.cat.id)]" class="cat-card-body">
|
<div v-show="!collapsedMap[String(item.cat.id)]" class="cat-card-body">
|
||||||
<MyTree :treeData="item.treeData" :setting="treeSetting" :autoClick="false"
|
<MyTree :treeData="item.treeData" :setting="treeSetting" :autoClick="false" :isCheck="true"
|
||||||
@on-tree-check="onTreeCheck" />
|
:expandAll="!!expandAllMap[String(item.cat.id)]" @on-tree-check="onTreeCheck" />
|
||||||
|
<!-- 「N个应用」计数:绝对定位到整个卡片的右下角(与子组继承复选框同列) -->
|
||||||
|
<span class="cat-count">{{ item.treeData.length }}个应用</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,11 +69,11 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, reactive, ref, shallowRef, watch } from "vue";
|
import { computed, onMounted, reactive, ref, shallowRef, watch } from "vue";
|
||||||
import { Message } from "view-ui-plus";
|
|
||||||
import Apis from "@/api";
|
import Apis from "@/api";
|
||||||
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
|
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
|
||||||
import MyTree from "@/components/MyTree.vue";
|
import MyTree from "@/components/MyTree.vue";
|
||||||
import { useDefaultStore } from "@/store";
|
import { useDefaultStore } from "@/store";
|
||||||
|
import { syncAppModulesFromTree, walkModules } from "@/utils/treeCascade";
|
||||||
|
|
||||||
defineOptions({ name: "GroupAuth" });
|
defineOptions({ name: "GroupAuth" });
|
||||||
|
|
||||||
@@ -83,6 +90,10 @@ type AppItem = {
|
|||||||
id?: number;
|
id?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
sortNo?: number;
|
sortNo?: number;
|
||||||
|
/** 应用级勾选态:仅当 modules 为空时有意义(对应后端 module_id=0 的整应用授权记录) */
|
||||||
|
checked?: boolean;
|
||||||
|
/** 应用级子组继承:仅当 modules 为空时有意义 */
|
||||||
|
inheritFlag?: boolean;
|
||||||
modules?: AppModuleVo[];
|
modules?: AppModuleVo[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,6 +118,7 @@ type AuthzTreeNode = {
|
|||||||
nodeType: "app" | "module";
|
nodeType: "app" | "module";
|
||||||
rawId: number;
|
rawId: number;
|
||||||
appId: number;
|
appId: number;
|
||||||
|
isParent?: boolean;
|
||||||
children?: AuthzTreeNode[];
|
children?: AuthzTreeNode[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,6 +145,9 @@ const searchKeyword = ref("");
|
|||||||
const appliedKeyword = ref("");
|
const appliedKeyword = ref("");
|
||||||
const collapsedMap = reactive<Record<string, boolean>>({});
|
const collapsedMap = reactive<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
/** 各应用类型卡片的「全部展开」开关(默认关闭):状态随卡片独立保存,数据重载后仍生效 */
|
||||||
|
const expandAllMap = reactive<Record<string, boolean>>({});
|
||||||
|
|
||||||
const fallbackModeItems: DictItem[] = [
|
const fallbackModeItems: DictItem[] = [
|
||||||
{ k: 0, v: "追加" },
|
{ k: 0, v: "追加" },
|
||||||
{ k: 1, v: "覆盖" },
|
{ k: 1, v: "覆盖" },
|
||||||
@@ -210,19 +225,22 @@ function countTotalModules(nodes: AppModuleVo[]): number {
|
|||||||
|
|
||||||
function isAppChecked(app: AppItem): boolean {
|
function isAppChecked(app: AppItem): boolean {
|
||||||
const modules = app.modules || [];
|
const modules = app.modules || [];
|
||||||
if (modules.length === 0) return false;
|
// 无子模块应用(如「一周安排」):勾选态来自后端返回的整应用授权记录
|
||||||
return modules.every((m) => isModuleAllChecked(m));
|
if (modules.length === 0) return !!app.checked;
|
||||||
|
// 父级展示口径:只要有一个子节点被勾选就算选中(不采用 zTree 半选,见样式里 true_part 的覆写)
|
||||||
|
return modules.some((m) => isModuleAnyChecked(m));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAppInherit(app: AppItem): boolean {
|
function isAppInherit(app: AppItem): boolean {
|
||||||
const modules = app.modules || [];
|
const modules = app.modules || [];
|
||||||
if (modules.length === 0) return false;
|
if (modules.length === 0) return !!app.inheritFlag;
|
||||||
return modules.every((m) => isModuleAllInherit(m));
|
return modules.every((m) => isModuleAllInherit(m));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isModuleAllChecked(node: AppModuleVo): boolean {
|
/** 自身或任一子孙被勾选 */
|
||||||
if (!node.checked) return false;
|
function isModuleAnyChecked(node: AppModuleVo): boolean {
|
||||||
return (node.children || []).every((c) => isModuleAllChecked(c));
|
if (node.checked) return true;
|
||||||
|
return (node.children || []).some((c) => isModuleAnyChecked(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isModuleAllInherit(node: AppModuleVo): boolean {
|
function isModuleAllInherit(node: AppModuleVo): boolean {
|
||||||
@@ -230,33 +248,41 @@ function isModuleAllInherit(node: AppModuleVo): boolean {
|
|||||||
return (node.children || []).every((c) => isModuleAllInherit(c));
|
return (node.children || []).every((c) => isModuleAllInherit(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 空 children 不挂键:zTree 只要见到 children 是数组就强制 isParent=true 而渲染 +/−(叶子也会出现展开图标)
|
||||||
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
|
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
|
||||||
return {
|
const kids = (m.children || []).map((c) => buildModuleNode(c, appId));
|
||||||
|
const node: AuthzTreeNode = {
|
||||||
id: `mod_${m.id}`,
|
id: `mod_${m.id}`,
|
||||||
name: String(m.name || ""),
|
name: String(m.name || ""),
|
||||||
open: true,
|
open: true,
|
||||||
checked: !!m.checked,
|
// 父模块节点沿用「有子节点选中即选中」的展示口径,与顶层应用节点一致
|
||||||
|
checked: isModuleAnyChecked(m),
|
||||||
inheritFlag: !!m.inheritFlag,
|
inheritFlag: !!m.inheritFlag,
|
||||||
nodeType: "module",
|
nodeType: "module",
|
||||||
rawId: Number(m.id),
|
rawId: Number(m.id),
|
||||||
appId,
|
appId,
|
||||||
children: (m.children || []).map((c) => buildModuleNode(c, appId)),
|
isParent: kids.length > 0,
|
||||||
};
|
};
|
||||||
|
if (kids.length > 0) node.children = kids;
|
||||||
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAppNode(app: AppItem): AuthzTreeNode {
|
function buildAppNode(app: AppItem): AuthzTreeNode {
|
||||||
const appId = Number(app.id);
|
const appId = Number(app.id);
|
||||||
return {
|
const kids = (app.modules || []).map((m) => buildModuleNode(m, appId));
|
||||||
|
const node: AuthzTreeNode = {
|
||||||
id: `app_${appId}`,
|
id: `app_${appId}`,
|
||||||
name: String(app.name || ""),
|
name: String(app.name || ""),
|
||||||
open: true,
|
open: false, // 一级默认闭合,点击才展开模块
|
||||||
checked: isAppChecked(app),
|
checked: isAppChecked(app),
|
||||||
inheritFlag: isAppInherit(app),
|
inheritFlag: isAppInherit(app),
|
||||||
nodeType: "app",
|
nodeType: "app",
|
||||||
rawId: appId,
|
rawId: appId,
|
||||||
appId,
|
appId,
|
||||||
children: (app.modules || []).map((m) => buildModuleNode(m, appId)),
|
isParent: kids.length > 0,
|
||||||
};
|
};
|
||||||
|
if (kids.length > 0) node.children = kids;
|
||||||
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分类 -> zTree 数据(应用为一级节点,模块为子节点);weight 用于三列均衡
|
// 分类 -> zTree 数据(应用为一级节点,模块为子节点);weight 用于三列均衡
|
||||||
@@ -330,8 +356,9 @@ const treeSetting = {
|
|||||||
check: {
|
check: {
|
||||||
enable: true,
|
enable: true,
|
||||||
chkStyle: "checkbox",
|
chkStyle: "checkbox",
|
||||||
// 不自动级联:模块逐条独立授权;应用节点勾选后由代码手动同步子节点
|
// 原生级联即可满足需求:
|
||||||
chkboxType: { Y: "", N: "" },
|
// Y="ps" 勾选子->父/祖先全部勾选、勾选父->子孙全部勾选;N="s" 取消父->子孙全部取消、取消子->父/祖先不受影响
|
||||||
|
chkboxType: { Y: "ps", N: "s" },
|
||||||
},
|
},
|
||||||
callback: {},
|
callback: {},
|
||||||
};
|
};
|
||||||
@@ -342,18 +369,6 @@ function getTreeObjByNode(node: any) {
|
|||||||
return $?.fn?.zTree?.getZTreeObj(treeId) || null;
|
return $?.fn?.zTree?.getZTreeObj(treeId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 模块勾选变化后,按本地数据回算应用节点的勾选/继承态并同步到 zTree */
|
|
||||||
function syncAppNodeState(treeObj: any, appId: number) {
|
|
||||||
if (!treeObj) return;
|
|
||||||
const app = findLocalApp(appId);
|
|
||||||
if (!app) return;
|
|
||||||
const appNode = treeObj.getNodesByFilter((n: any) => n.id === `app_${appId}`, true);
|
|
||||||
if (!appNode) return;
|
|
||||||
appNode.checked = isAppChecked(app);
|
|
||||||
appNode.inheritFlag = isAppInherit(app);
|
|
||||||
treeObj.checkNode(appNode, appNode.checked, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按 zTree 当前节点状态刷新所有"子组继承"复选框的勾选与禁用态 */
|
/** 按 zTree 当前节点状态刷新所有"子组继承"复选框的勾选与禁用态 */
|
||||||
function syncInheritDoms(treeObj: any) {
|
function syncInheritDoms(treeObj: any) {
|
||||||
const $ = jq();
|
const $ = jq();
|
||||||
@@ -411,25 +426,15 @@ function findLocalApp(appId: number): AppItem | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function walkModules(nodes: AppModuleVo[], fn: (n: AppModuleVo) => void) {
|
// walkModules / 本地勾选态同步(syncAppModulesFromTree)统一在 utils/treeCascade.ts
|
||||||
for (const node of nodes) {
|
|
||||||
fn(node);
|
|
||||||
walkModules(node.children || [], fn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setLocalAppChecked(appId: number, checked: boolean) {
|
|
||||||
const app = findLocalApp(appId);
|
|
||||||
if (!app) return;
|
|
||||||
walkModules(app.modules || [], (n) => {
|
|
||||||
n.checked = checked;
|
|
||||||
if (!checked) n.inheritFlag = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setLocalAppInherit(appId: number, inherit: boolean) {
|
function setLocalAppInherit(appId: number, inherit: boolean) {
|
||||||
const app = findLocalApp(appId);
|
const app = findLocalApp(appId);
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
|
if ((app.modules || []).length === 0) {
|
||||||
|
app.inheritFlag = inherit;
|
||||||
|
return;
|
||||||
|
}
|
||||||
walkModules(app.modules || [], (n) => {
|
walkModules(app.modules || [], (n) => {
|
||||||
if (n.checked) n.inheritFlag = inherit;
|
if (n.checked) n.inheritFlag = inherit;
|
||||||
});
|
});
|
||||||
@@ -445,49 +450,91 @@ function setLocalModuleState(appId: number, moduleId: number, patch: Partial<App
|
|||||||
|
|
||||||
// ---------------- 授权操作 ----------------
|
// ---------------- 授权操作 ----------------
|
||||||
|
|
||||||
/** 勾选/取消勾选:应用节点 => moduleId=0 整应用处理;模块节点 => 单模块处理 */
|
/**
|
||||||
|
* 勾选/取消勾选:级联由 zTree 原生 chkboxType(Y:"ps" N:"s")完成,
|
||||||
|
* 这里把该应用最终勾选态同步回本地数据(提交依据),仅按模块明细 diff 提交 追加/删除。
|
||||||
|
*/
|
||||||
async function onTreeCheck(node: any) {
|
async function onTreeCheck(node: any) {
|
||||||
const groupId = selectedGroupId.value;
|
const groupId = selectedGroupId.value;
|
||||||
if (!groupId || !node) return;
|
if (!groupId || !node) return;
|
||||||
|
|
||||||
const checked = !!node.checked;
|
|
||||||
const isApp = node.nodeType === "app";
|
|
||||||
const treeObj = getTreeObjByNode(node);
|
const treeObj = getTreeObjByNode(node);
|
||||||
|
const app = findLocalApp(node.appId);
|
||||||
|
if (!app) {
|
||||||
|
await reloadCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无子模块应用(如「一周安排」):授权粒度即整个应用,落 moduleId=0 的整应用记录。
|
||||||
|
// 这类应用没有可 diff 的模块明细,必须单独走应用级提交,否则勾选只是个视觉状态、刷新即失效。
|
||||||
|
const appModules = app.modules || [];
|
||||||
|
if (appModules.length === 0) {
|
||||||
|
const checked = !!node.checked;
|
||||||
try {
|
try {
|
||||||
|
if (checked !== !!app.checked) {
|
||||||
|
app.checked = checked;
|
||||||
await Apis.GroupAuthz.setGroupModule({
|
await Apis.GroupAuthz.setGroupModule({
|
||||||
data: [{
|
data: [{
|
||||||
groupId,
|
groupId,
|
||||||
appId: node.appId,
|
appId: node.appId,
|
||||||
moduleId: isApp ? 0 : node.rawId,
|
moduleId: 0,
|
||||||
mode: checked ? addMode.value : deleteMode.value,
|
mode: checked ? addMode.value : deleteMode.value,
|
||||||
isAll: checked && node.inheritFlag ? 1 : 0,
|
isAll: 0,
|
||||||
}],
|
}],
|
||||||
cacheFor: null,
|
cacheFor: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isApp) {
|
|
||||||
setLocalAppChecked(node.appId, checked);
|
|
||||||
// 应用节点整批操作:手动同步 zTree 子节点勾选态与继承标记
|
|
||||||
if (treeObj) {
|
|
||||||
const affected = treeObj.transformToArray([node]) || [];
|
|
||||||
affected.forEach((n: any) => {
|
|
||||||
if (n !== node) treeObj.checkNode(n, checked, false);
|
|
||||||
n.checked = checked;
|
|
||||||
if (!checked) n.inheritFlag = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Message.success(checked ? "已授权该应用全部模块" : "已取消该应用全部模块授权");
|
|
||||||
} else {
|
|
||||||
if (!checked) node.inheritFlag = false;
|
|
||||||
setLocalModuleState(node.appId, node.rawId, checked ? { checked: true } : { checked: false, inheritFlag: false });
|
|
||||||
syncAppNodeState(treeObj, node.appId);
|
|
||||||
}
|
}
|
||||||
syncInheritDoms(treeObj);
|
syncInheritDoms(treeObj);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.warn(e?.message || "操作失败");
|
console.warn(e?.message || "操作失败");
|
||||||
await reloadCurrent();
|
await reloadCurrent();
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 级联前快照:用于计算本次实际变化的模块明细
|
||||||
|
const before = new Map<number, boolean>();
|
||||||
|
walkModules(app.modules, (m) => before.set(Number(m.id), !!m.checked));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// zTree 原生级联已生效,同步最终勾选态到本地数据
|
||||||
|
syncAppModulesFromTree(app, node);
|
||||||
|
syncInheritDoms(treeObj);
|
||||||
|
|
||||||
|
const after = new Map<number, boolean>();
|
||||||
|
walkModules(app.modules, (m) => after.set(Number(m.id), !!m.checked));
|
||||||
|
const changes = buildModuleDiff(groupId, node.appId, before, after);
|
||||||
|
if (changes.length > 0) {
|
||||||
|
await Apis.GroupAuthz.setGroupModule({ data: changes, cacheFor: null });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(e?.message || "操作失败");
|
||||||
|
await reloadCurrent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对比前后模块勾选快照,仅输出变化的模块(追加/删除);父/祖先节点只做展示,不进提交 */
|
||||||
|
function buildModuleDiff(
|
||||||
|
groupId: number,
|
||||||
|
appId: number,
|
||||||
|
before: Map<number, boolean>,
|
||||||
|
after: Map<number, boolean>
|
||||||
|
): Array<Record<string, any>> {
|
||||||
|
const changes: Array<Record<string, any>> = [];
|
||||||
|
const ids = new Set<number>([...before.keys(), ...after.keys()]);
|
||||||
|
ids.forEach((id) => {
|
||||||
|
const prev = before.get(id);
|
||||||
|
const next = after.get(id);
|
||||||
|
if (prev === next) return;
|
||||||
|
changes.push({
|
||||||
|
groupId,
|
||||||
|
appId,
|
||||||
|
moduleId: id,
|
||||||
|
mode: next ? addMode.value : deleteMode.value,
|
||||||
|
isAll: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 子组继承开关:应用节点 => moduleId=0 整应用处理;模块节点 => 单模块处理 */
|
/** 子组继承开关:应用节点 => moduleId=0 整应用处理;模块节点 => 单模块处理 */
|
||||||
@@ -595,14 +642,14 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.group-name {
|
.group-name {
|
||||||
color: #2d8cf0;
|
color: var(--brand);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authz-panel {
|
.authz-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 320px;
|
min-height: 0;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -656,29 +703,61 @@ onMounted(async () => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
|
/* 「N个应用」计数已绝对定位脱离主行,主行只剩分类名 + 全部展开 + 子组继承 */
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-count {
|
.cat-count {
|
||||||
|
/* 计数贴在整张卡片右下角:与右侧「子组继承」复选框同列,脱离 tree 的文档流 */
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
bottom: 6px;
|
||||||
color: #999;
|
color: #999;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-left: 8px;
|
line-height: 18px;
|
||||||
flex: 1;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 弹性空隙:把「全部展开 / 子组继承」推到卡片头右端,与分类名拉开距离 */
|
||||||
|
.cat-spacer {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.inherit-cap {
|
.inherit-cap {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-right: 6px;
|
margin-left: 10px;
|
||||||
|
padding: 0 6px;
|
||||||
|
line-height: 18px;
|
||||||
|
background: #f0f2f5;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-all {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-all-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-card-body {
|
.cat-card-body {
|
||||||
padding: 6px 8px;
|
/* 底部多留一行:容纳右下角的「N个应用」计数,避免压住最后一行节点 */
|
||||||
/* 子组继承复选框的绝对定位锚点:保证所有层级节点的复选框右对齐成一列 */
|
padding: 6px 8px 24px;
|
||||||
|
/* 子组继承复选框 / 计数角标的绝对定位锚点:保证两者右对齐成一列 */
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,13 +768,22 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.open-link {
|
.open-link {
|
||||||
color: #2d8cf0;
|
color: var(--brand);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 父级勾选样式(2026-10-20):
|
||||||
|
zTree 的「半选」在两个状态位各有一套图标——true_part(父已勾但子未全勾)/
|
||||||
|
false_part(父未勾但子有勾)。这里把 true_part 的图标换成 true_full 的蓝底勾,
|
||||||
|
让"只要有一个子节点被选中"的父节点统一呈现为选中态,避免折叠时看着像没授权。 */
|
||||||
|
:deep(.ztree li span.button.chk.checkbox_true_part),
|
||||||
|
:deep(.ztree li span.button.chk.checkbox_true_part_focus) {
|
||||||
|
background-position: -26px -5px;
|
||||||
|
}
|
||||||
|
|
||||||
/* zTree 节点行尾的"子组继承"复选框:
|
/* zTree 节点行尾的"子组继承"复选框:
|
||||||
float 会跟随节点缩进产生阶梯,改为绝对定位锚定卡片体右边界,所有层级右对齐成一列 */
|
float 会跟随节点缩进产生阶梯,改为绝对定位锚定卡片体右边界,所有层级右对齐成一列 */
|
||||||
:deep(.inherit-wrap) {
|
:deep(.inherit-wrap) {
|
||||||
@@ -715,7 +803,9 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.ztree li a) {
|
:deep(.ztree li a) {
|
||||||
max-width: calc(100% - 90px);
|
/* 子组继承复选框仅悬停时由 addHoverDom 注入(绝对定位 right:14,占位约 30px),
|
||||||
|
常态无需预留 90px——收窄预留,消除「明明有空间却截断成 ...」 */
|
||||||
|
max-width: calc(100% - 36px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<div class="no-auth-page">
|
||||||
|
<h3>暂无权限</h3>
|
||||||
|
<h6>如需查看,请联络管理员为您分配权限</h6>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({ name: "NoAuth" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.no-auth-page {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: normal;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+234
-58
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-wrapper tree-wrapper">
|
<div class="main-wrapper tree-wrapper permission-view">
|
||||||
<div class="tree-container">
|
<div class="tree-container permission-tree">
|
||||||
<Input v-model="userKeyword" placeholder="输入用户,回车查找" suffix="ios-search" search clearable
|
<Input v-model="userKeyword" placeholder="输入用户,回车查找" suffix="ios-search" search clearable
|
||||||
class="search-input" @on-search="onSearchUser" @on-clear="onClearUser" />
|
class="search-input" @on-search="onSearchUser" @on-clear="onClearUser" />
|
||||||
<GroupTreeSearchPanel ref="groupTreeRef" :treeApi="fetchGroupTree" :autoClick="true"
|
<GroupTreeSearchPanel ref="groupTreeRef" :treeApi="fetchGroupTree" :autoClick="true"
|
||||||
@@ -19,8 +19,9 @@
|
|||||||
<template v-else>未找到匹配用户</template>
|
<template v-else>未找到匹配用户</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="user-list">
|
<div v-else class="user-list">
|
||||||
<div v-for="user in userList" :key="user.id" class="user-item"
|
<div v-for="user in userList" :key="`${user.id}-${user.groupId ?? ''}`" class="user-item"
|
||||||
:class="{ active: selectedUser?.id === user.id }" @click="onSelectUser(user)">
|
:class="{ active: selectedUser?.id === user.id && selectedUser?.groupId === user.groupId }"
|
||||||
|
@click="onSelectUser(user)">
|
||||||
<div class="user-name">{{ userDisplayName(user) }}</div>
|
<div class="user-name">{{ userDisplayName(user) }}</div>
|
||||||
<div v-if="mode === 'user' && user.groupPath" class="user-path">{{ user.groupPath }}</div>
|
<div v-if="mode === 'user' && user.groupPath" class="user-path">{{ user.groupPath }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,7 +46,8 @@
|
|||||||
<div class="side-body">
|
<div class="side-body">
|
||||||
<div v-if="treeLoading" class="loading-tip small">加载中...</div>
|
<div v-if="treeLoading" class="loading-tip small">加载中...</div>
|
||||||
<div v-else-if="deptTree.length === 0" class="empty-tip small">暂无部门归属</div>
|
<div v-else-if="deptTree.length === 0" class="empty-tip small">暂无部门归属</div>
|
||||||
<MyTree v-else :treeData="deptTree" :setting="treeSetting" />
|
<MyTree v-else :treeData="deptTree" :setting="treeSetting" :autoClick="false"
|
||||||
|
@on-tree-click="onDeptNodeClick" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="side-block">
|
<div class="side-block">
|
||||||
@@ -53,17 +55,26 @@
|
|||||||
<div class="side-body">
|
<div class="side-body">
|
||||||
<div v-if="treeLoading" class="loading-tip small">加载中...</div>
|
<div v-if="treeLoading" class="loading-tip small">加载中...</div>
|
||||||
<div v-else-if="roleTree.length === 0" class="empty-tip small">暂无所属角色</div>
|
<div v-else-if="roleTree.length === 0" class="empty-tip small">暂无所属角色</div>
|
||||||
<MyTree v-else :treeData="roleTree" :setting="treeSetting" />
|
<MyTree v-else :treeData="roleTree" :setting="treeSetting" :autoClick="false"
|
||||||
|
@on-tree-click="onRoleNodeClick" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 右列:已授权应用(原有) -->
|
<!-- 右列:已授权应用(原有);支持三种视图:用户聚合 / 部门树节点 / 角色树节点 -->
|
||||||
<div class="perm-main">
|
<div class="perm-main">
|
||||||
<div class="side-title">已授权应用</div>
|
<div class="side-title">
|
||||||
|
<span class="side-title-text">{{ permMainTitle }}</span>
|
||||||
|
<!-- 应用层默认折叠,这里提供一键展开/收起(事件不冒泡到标题行) -->
|
||||||
|
<span class="expand-all" @click.stop>
|
||||||
|
<span class="expand-all-label">全部展开:</span>
|
||||||
|
<Switch size="small" v-model="permExpandAll" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="side-body">
|
<div class="side-body">
|
||||||
<div v-if="treeLoading" class="loading-tip">权限加载中...</div>
|
<div v-if="permLoading" class="loading-tip">权限加载中...</div>
|
||||||
<div v-else-if="permissionTree.length === 0" class="empty-tip">该用户暂无已授权应用模块</div>
|
<div v-else-if="permissionTree.length === 0" class="empty-tip">{{ permEmptyTip }}</div>
|
||||||
<MyTree v-else ref="permTreeRef" :treeData="permissionTree" :setting="treeSetting" />
|
<MyTree v-else ref="permTreeRef" :treeData="permissionTree" :setting="treeSetting"
|
||||||
|
:expand-all="permExpandAll" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +84,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { Input } from "view-ui-plus";
|
import { Input } from "view-ui-plus";
|
||||||
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
|
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
|
||||||
import MyTree from "@/components/MyTree.vue";
|
import MyTree from "@/components/MyTree.vue";
|
||||||
@@ -84,6 +95,8 @@ interface UserItem {
|
|||||||
account?: string;
|
account?: string;
|
||||||
realName?: string;
|
realName?: string;
|
||||||
isMain?: number;
|
isMain?: number;
|
||||||
|
/** 该行对应的组ID:同一用户多组时后端每个组出一行,选中行按该组查看权限 */
|
||||||
|
groupId?: number;
|
||||||
groupPath?: string;
|
groupPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +156,28 @@ const deptTree = ref<any[]>([]);
|
|||||||
const roleTree = ref<any[]>([]);
|
const roleTree = ref<any[]>([]);
|
||||||
const userLoading = ref(false);
|
const userLoading = ref(false);
|
||||||
const treeLoading = ref(false);
|
const treeLoading = ref(false);
|
||||||
|
// 右列「已授权应用」独立 loading:部门/角色节点点击只刷新右列,不能把左列两棵树刷成加载中
|
||||||
|
const permLoading = ref(false);
|
||||||
|
// 右列当前展示上下文:user=选中用户的聚合权限 / group=部门树节点的组授权 / role=角色树节点的角色授权
|
||||||
|
const permScope = ref<{ type: "user" | "group" | "role"; id?: number; label: string }>({
|
||||||
|
type: "user",
|
||||||
|
label: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 已授权应用面板的「全部展开」开关(默认关闭):应用层默认折叠,需按需展开模块
|
||||||
|
const permExpandAll = ref(false);
|
||||||
|
|
||||||
|
const permMainTitle = computed(() => {
|
||||||
|
if (permScope.value.type === "group") return `已授权应用(部门:${permScope.value.label})`;
|
||||||
|
if (permScope.value.type === "role") return `已授权应用(角色:${permScope.value.label})`;
|
||||||
|
return "已授权应用";
|
||||||
|
});
|
||||||
|
|
||||||
|
const permEmptyTip = computed(() => {
|
||||||
|
if (permScope.value.type === "group") return "该部门暂无已授权应用模块";
|
||||||
|
if (permScope.value.type === "role") return "该角色暂无已授权应用模块";
|
||||||
|
return "该用户暂无已授权应用模块";
|
||||||
|
});
|
||||||
|
|
||||||
const treeSetting = ref({
|
const treeSetting = ref({
|
||||||
view: { dblClickExpand: false, showIcon: false, showLine: true },
|
view: { dblClickExpand: false, showIcon: false, showLine: true },
|
||||||
@@ -177,6 +212,7 @@ function clearPermPanels() {
|
|||||||
permissionTree.value = [];
|
permissionTree.value = [];
|
||||||
deptTree.value = [];
|
deptTree.value = [];
|
||||||
roleTree.value = [];
|
roleTree.value = [];
|
||||||
|
permScope.value = { type: "user", label: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadGroupUsers(groupId?: number) {
|
async function loadGroupUsers(groupId?: number) {
|
||||||
@@ -231,12 +267,16 @@ function onClearUser() {
|
|||||||
async function onSelectUser(user: UserItem) {
|
async function onSelectUser(user: UserItem) {
|
||||||
selectedUser.value = user;
|
selectedUser.value = user;
|
||||||
treeLoading.value = true;
|
treeLoading.value = true;
|
||||||
|
// 切换用户后右列回到「用户聚合权限」视图(覆盖可能存在的部门/角色节点视图)
|
||||||
|
permScope.value = { type: "user", id: user.id, label: "" };
|
||||||
const userId = user.id as number;
|
const userId = user.id as number;
|
||||||
const groupId = selectedGroup.value?.id;
|
// 行自带组ID(多组用户每个组一行)优先;组列表模式回退左侧选中组
|
||||||
|
const groupId = user.groupId ?? (selectedGroup.value?.id ? Number(selectedGroup.value.id) : undefined);
|
||||||
// 三块数据并行加载,互不阻塞
|
// 三块数据并行加载,互不阻塞
|
||||||
// userGroupTree 为新增接口:apiDefinitions 未重新生成(npm run api:gen)时方法不存在,降级为友好报错
|
// userGroupTree 为新增接口:apiDefinitions 未重新生成(npm run api:gen)时方法不存在,降级为友好报错;
|
||||||
|
// groupId 为行所在组(每行是「用户×组」对),所属部门只画该组链路而非用户全部归属组
|
||||||
const userGroupTreeApi = (Apis.GroupAuthz as any).userGroupTree
|
const userGroupTreeApi = (Apis.GroupAuthz as any).userGroupTree
|
||||||
? Apis.GroupAuthz.userGroupTree({ params: { userId }, cacheFor: null })
|
? Apis.GroupAuthz.userGroupTree({ params: { userId, groupId: groupId ?? undefined }, cacheFor: null })
|
||||||
: Promise.reject(new Error("所属部门接口未生成,请重启后端后执行 npm run api:gen"));
|
: Promise.reject(new Error("所属部门接口未生成,请重启后端后执行 npm run api:gen"));
|
||||||
const [permRes, deptRes, roleRes] = await Promise.allSettled([
|
const [permRes, deptRes, roleRes] = await Promise.allSettled([
|
||||||
Apis.GroupAuthz.userPermissionTree({
|
Apis.GroupAuthz.userPermissionTree({
|
||||||
@@ -247,11 +287,16 @@ async function onSelectUser(user: UserItem) {
|
|||||||
Apis.GroupAuthz.userRoles({ params: { userId }, cacheFor: null }),
|
Apis.GroupAuthz.userRoles({ params: { userId }, cacheFor: null }),
|
||||||
]);
|
]);
|
||||||
if (permRes.status === "fulfilled") {
|
if (permRes.status === "fulfilled") {
|
||||||
|
// 加载期间用户可能已点了部门/角色节点切走视图,仅在仍处于该用户视图时回填
|
||||||
|
if (permScope.value.type === "user" && permScope.value.id === userId) {
|
||||||
permissionTree.value = formatPermissionTree((permRes.value as CategoryNode[]) || []);
|
permissionTree.value = formatPermissionTree((permRes.value as CategoryNode[]) || []);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn(permRes.reason?.message || "加载权限树失败");
|
console.warn(permRes.reason?.message || "加载权限树失败");
|
||||||
|
if (permScope.value.type === "user" && permScope.value.id === userId) {
|
||||||
permissionTree.value = [];
|
permissionTree.value = [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (deptRes.status === "fulfilled") {
|
if (deptRes.status === "fulfilled") {
|
||||||
deptTree.value = formatDeptTree((deptRes.value as GroupNode[]) || []);
|
deptTree.value = formatDeptTree((deptRes.value as GroupNode[]) || []);
|
||||||
} else {
|
} else {
|
||||||
@@ -267,15 +312,122 @@ async function onSelectUser(user: UserItem) {
|
|||||||
treeLoading.value = false;
|
treeLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 所属部门树节点点击:右列切换为「该组的组授权」视图(开通池=本组+祖先并集、就近继承;不含任何角色授权)
|
||||||
|
function onDeptNodeClick(node: any) {
|
||||||
|
const id = String(node?.id ?? "");
|
||||||
|
if (!id.startsWith("grp_")) return;
|
||||||
|
const groupId = Number(id.slice(4));
|
||||||
|
if (!groupId || (permScope.value.type === "group" && permScope.value.id === groupId)) return;
|
||||||
|
permScope.value = { type: "group", id: groupId, label: String(node.name || "") };
|
||||||
|
permLoading.value = true;
|
||||||
|
Apis.GroupAuthz.groupPermissionTree({ params: { groupId }, cacheFor: null })
|
||||||
|
.then((cats: any) => {
|
||||||
|
if (permScope.value.type === "group" && permScope.value.id === groupId) {
|
||||||
|
permissionTree.value = formatPermissionTree((cats as CategoryNode[]) || []);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e: any) => {
|
||||||
|
console.warn(e?.message || "加载组权限树失败");
|
||||||
|
if (permScope.value.type === "group" && permScope.value.id === groupId) {
|
||||||
|
permissionTree.value = [];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
permLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所属角色树节点点击:右列切换为「该角色的授权模块」视图。
|
||||||
|
// 复用角色授权面板的 RoleModuleTree 回显接口,仅保留勾选(checked)模块,无勾选模块的应用整棵不显示。
|
||||||
|
function onRoleNodeClick(node: any) {
|
||||||
|
const id = String(node?.id ?? "");
|
||||||
|
if (!id.startsWith("role_")) return; // 「平台角色」/应用名分组节点不响应
|
||||||
|
const roleId = Number(id.slice(5));
|
||||||
|
if (!roleId || (permScope.value.type === "role" && permScope.value.id === roleId)) return;
|
||||||
|
permScope.value = { type: "role", id: roleId, label: String(node.name || "") };
|
||||||
|
permLoading.value = true;
|
||||||
|
Promise.all([
|
||||||
|
Apis.AppRole.roleModuleTree({ params: { roleId }, cacheFor: null }),
|
||||||
|
Apis.AppSettings.appList({ params: { pageIndex: 1, pageSize: 9999 }, cacheFor: null }),
|
||||||
|
Apis.AppSettings.appTypeList({ cacheFor: null }),
|
||||||
|
])
|
||||||
|
.then(([apps, appListRes, typeRes]: any[]) => {
|
||||||
|
if (permScope.value.type === "role" && permScope.value.id === roleId) {
|
||||||
|
permissionTree.value = formatRoleAuthzTree(apps, appListRes, typeRes);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e: any) => {
|
||||||
|
console.warn(e?.message || "加载角色权限树失败");
|
||||||
|
if (permScope.value.type === "role" && permScope.value.id === roleId) {
|
||||||
|
permissionTree.value = [];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
permLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 角色授权模块 → 按应用分类分组(应用无 categoryId 时落「未分类」,与角色授权面板同口径)
|
||||||
|
function formatRoleAuthzTree(apps: any[], appListRes: any, typeRes: any): any[] {
|
||||||
|
const appArr: any[] = appListRes?.content ?? appListRes ?? [];
|
||||||
|
const catMap = new Map<number, number>();
|
||||||
|
appArr.forEach((a: any) => {
|
||||||
|
if (a.id != null) catMap.set(Number(a.id), Number(a.categoryId));
|
||||||
|
});
|
||||||
|
const typeMap = new Map<number, string>();
|
||||||
|
(typeRes ?? []).forEach((t: any) => typeMap.set(Number(t.id), String(t.name || `分类${t.id}`)));
|
||||||
|
|
||||||
|
const groups = new Map<number, any>();
|
||||||
|
for (const app of apps || []) {
|
||||||
|
const children = filterCheckedModules(app.modules || []);
|
||||||
|
if (children.length === 0) continue; // 角色未勾选任何模块的应用不展示
|
||||||
|
const cid = catMap.get(Number(app.id)) ?? -1;
|
||||||
|
if (!groups.has(cid)) {
|
||||||
|
groups.set(cid, { id: cid, name: typeMap.get(cid) || "未分类", open: true, isParent: true, children: [] });
|
||||||
|
}
|
||||||
|
groups.get(cid).children.push({
|
||||||
|
id: `app_${app.id}_${cid}`,
|
||||||
|
name: app.name || `应用${app.id}`,
|
||||||
|
open: false, // 与用户视图一致:应用一级默认闭合,点击才展开模块
|
||||||
|
children,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 分类按 id 升序,「未分类」(-1) 沉底;应用沿用接口返回顺序(后端已按 sortNo 排好)
|
||||||
|
return [...groups.values()].sort((a, b) => {
|
||||||
|
if (a.id === -1) return 1;
|
||||||
|
if (b.id === -1) return -1;
|
||||||
|
return a.id - b.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// zTree 的父节点判定是「children 是数组即 isParent=true」——空数组也会渲染出 +/− 展开图标,
|
||||||
|
// 因此统一约定:有子节点才挂 children 键,否则不挂(与组用户管理左侧树的构造口径一致)
|
||||||
|
function treeNode(base: Record<string, any>, kids: any[]): any {
|
||||||
|
const node: any = { ...base, isParent: kids.length > 0 };
|
||||||
|
if (kids.length > 0) node.children = kids;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅保留勾选(checked)的模块;父未勾选但子孙有勾选时保留父节点以维持层级
|
||||||
|
function filterCheckedModules(nodes: any[]): any[] {
|
||||||
|
const out: any[] = [];
|
||||||
|
for (const n of nodes || []) {
|
||||||
|
const kids = filterCheckedModules(n.children || []);
|
||||||
|
if (n.checked || kids.length > 0) {
|
||||||
|
out.push(treeNode({ id: `mod_${n.id}`, name: n.name || `模块${n.id}`, open: true }, kids));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// 所属部门:祖先链树,用户直接归属的组加「本组」标记
|
// 所属部门:祖先链树,用户直接归属的组加「本组」标记
|
||||||
function formatDeptTree(nodes: GroupNode[]): any[] {
|
function formatDeptTree(nodes: GroupNode[]): any[] {
|
||||||
return (nodes || []).map((n) => ({
|
return (nodes || []).map((n) =>
|
||||||
id: `grp_${n.id}`,
|
treeNode(
|
||||||
name: n.own ? `${n.name || ""}(本组)` : n.name || "",
|
{ id: `grp_${n.id}`, name: n.own ? `${n.name || ""}(本组)` : n.name || "", open: true },
|
||||||
open: true,
|
formatDeptTree(n.children || [])
|
||||||
children: formatDeptTree(n.children || []),
|
)
|
||||||
isParent: (n.children || []).length > 0,
|
);
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 所属角色:2 级树 —— 一级为「平台角色」(全应用) / 应用名分组,二级为角色
|
// 所属角色:2 级树 —— 一级为「平台角色」(全应用) / 应用名分组,二级为角色
|
||||||
@@ -298,40 +450,30 @@ function formatRoleTree(roles: RoleItem[]): any[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatPermissionTree(categories: CategoryNode[]): any[] {
|
function formatPermissionTree(categories: CategoryNode[]): any[] {
|
||||||
const result: any[] = [];
|
return (categories || []).map((cat) =>
|
||||||
for (const cat of categories) {
|
treeNode(
|
||||||
const catNode: any = {
|
{ id: `cat_${cat.id}`, name: cat.name || `应用类型${cat.id}`, open: true },
|
||||||
id: `cat_${cat.id}`,
|
formatApps(cat.apps || [], cat.id)
|
||||||
name: cat.name || `应用类型${cat.id}`,
|
)
|
||||||
open: true,
|
);
|
||||||
children: formatApps(cat.apps || [], cat.id),
|
|
||||||
};
|
|
||||||
catNode.isParent = catNode.children.length > 0;
|
|
||||||
result.push(catNode);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatApps(apps: AppNode[], catId?: number): any[] {
|
function formatApps(apps: AppNode[], catId?: number): any[] {
|
||||||
return (apps || []).map((app) => ({
|
return (apps || []).map((app) =>
|
||||||
id: `app_${app.id}_${catId || 0}`,
|
treeNode(
|
||||||
name: app.name || `应用${app.id}`,
|
{ id: `app_${app.id}_${catId || 0}`, name: app.name || `应用${app.id}`, open: false }, // 应用一级默认闭合,点击才展开模块
|
||||||
open: true,
|
formatModules(app.modules || [])
|
||||||
children: formatModules(app.modules || []),
|
)
|
||||||
}));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatModules(modules: ModuleNode[]): any[] {
|
function formatModules(modules: ModuleNode[]): any[] {
|
||||||
return (modules || []).map((m) => {
|
return (modules || []).map((m) =>
|
||||||
const children = formatModules(m.children || []);
|
treeNode(
|
||||||
return {
|
{ id: `mod_${m.id}`, name: m.name || `模块${m.id}`, open: true },
|
||||||
id: `mod_${m.id}`,
|
formatModules(m.children || [])
|
||||||
name: m.name || `模块${m.id}`,
|
)
|
||||||
open: true,
|
);
|
||||||
children,
|
|
||||||
isParent: children.length > 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -440,6 +582,30 @@ function formatModules(modules: ModuleNode[]): any[] {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-title-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-all {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-all-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-body {
|
.side-body {
|
||||||
@@ -495,19 +661,29 @@ function formatModules(modules: ModuleNode[]): any[] {
|
|||||||
color: #2d8cf0;
|
color: #2d8cf0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.group-tree-search-panel) {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ztree) {
|
:deep(.ztree) {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
||||||
li {
|
|
||||||
line-height: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
/* 左列对齐「组用户管理」页的写法(不覆盖全局 .tree-container):
|
||||||
|
容器改 flex 纵向并收起自身滚动,把滚动条收敛到树节点区域,避免整列滚动把搜索框滚走、树尾被裁。
|
||||||
|
注意不要在此处覆盖 zTree 的行高:zTree 官方样式的图标/连线按 21px 节距排布,
|
||||||
|
把 li line-height 改大(如 26px)会让每行多出的空隙在连接线上露出断口。 */
|
||||||
|
.tree-wrapper.permission-view .tree-container.permission-tree {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.group-tree-search-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ let onSuccessCallback: (() => void) | null = null;
|
|||||||
// useForm 托管提交
|
// useForm 托管提交
|
||||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
||||||
(dto) => Apis.AppSettings.addApp({ data: dto }),
|
(dto) => Apis.AppSettings.addApp({ data: dto }),
|
||||||
{ initialForm: { name: '', categoryId: undefined, desc: '', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
|
{ initialForm: { name: '', categoryId: undefined, desc: '', appCode: '', appNature: 'BUSINESS', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', iconKey: '', iconColor: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
onSuccess(() => {
|
onSuccess(() => {
|
||||||
@@ -50,11 +50,15 @@ async function handleSubmit(formData: Record<string, any>) {
|
|||||||
name: formData.name,
|
name: formData.name,
|
||||||
categoryId: formData.categoryId,
|
categoryId: formData.categoryId,
|
||||||
desc: formData.desc,
|
desc: formData.desc,
|
||||||
|
appCode: formData.appCode,
|
||||||
|
appNature: formData.appNature,
|
||||||
clients: formData.clients,
|
clients: formData.clients,
|
||||||
pcUrl: formData.pcUrl,
|
pcUrl: formData.pcUrl,
|
||||||
pcIcon: formData.pcIcon,
|
pcIcon: formData.pcIcon,
|
||||||
appUrl: formData.appUrl,
|
appUrl: formData.appUrl,
|
||||||
appIcon: formData.appIcon,
|
appIcon: formData.appIcon,
|
||||||
|
iconKey: formData.iconKey,
|
||||||
|
iconColor: formData.iconColor,
|
||||||
officialAccountUrl: formData.officialAccountUrl,
|
officialAccountUrl: formData.officialAccountUrl,
|
||||||
dingtalkUrl: formData.dingtalkUrl,
|
dingtalkUrl: formData.dingtalkUrl,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
<div class="main-wrapper">
|
<div class="main-wrapper">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<Alert show-icon>设置说明:拖动排序,被使用的分类不能删除</Alert>
|
<Alert show-icon>设置说明:拖动排序,被使用的分类不能删除</Alert>
|
||||||
<Button class="mb-5" type="primary" @click="addModuleShow">新增应用类型</Button>
|
<!-- 多校管理员:学校切换(改名/排序仅对所选学校生效) -->
|
||||||
|
<Select v-if="multiSchool" v-model="currentSchoolId" class="mb-5 school-select" transfer
|
||||||
|
placeholder="全部学校(并集)" @on-change="fetchTypeList">
|
||||||
|
<Option v-for="s in schoolList" :key="s.id" :value="s.id">{{ s.name }}</Option>
|
||||||
|
</Select>
|
||||||
|
<Button v-if="!isSchoolMode" class="mb-5" :class="{ 'ml-8': multiSchool }" type="primary"
|
||||||
|
@click="addModuleShow">新增应用类型</Button>
|
||||||
<draggable class="type_set-wrap fix" tag="ul" v-model="typeList" :group="'appType'" @change="typeSortSave"
|
<draggable class="type_set-wrap fix" tag="ul" v-model="typeList" :group="'appType'" @change="typeSortSave"
|
||||||
item-key="id">
|
item-key="id">
|
||||||
<template #item="{ element }">
|
<template #item="{ element }">
|
||||||
@@ -16,7 +22,7 @@
|
|||||||
<span>编辑</span>
|
<span>编辑</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action_delete" @click="removeSave(element)">
|
<div class="action_delete" v-if="!isSchoolMode" @click="removeSave(element)">
|
||||||
<span>删除</span>
|
<span>删除</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,16 +55,25 @@
|
|||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
|
import { useAppSchoolScope } from "@/utils/hook/useAppSchoolScope";
|
||||||
|
|
||||||
const { data: typeList, send: sendTypeList } = useRequest(
|
// 学校管理员上下文:仅改名/排序(写学校覆盖表);新增/删除类型仅超管(按钮隐藏 + 接口 requireSuper 双挡)
|
||||||
Apis.AppSettings.appTypeList({
|
const { isSchoolMode, multiSchool, schoolList, currentSchoolId, requireSchool } = useAppSchoolScope();
|
||||||
transform: (res: any) => {
|
|
||||||
return res ?? [];
|
const typeList = ref<any[]>([])
|
||||||
},
|
|
||||||
|
async function fetchTypeList() {
|
||||||
|
try {
|
||||||
|
const res: any = await Apis.AppSettings.appTypeList({
|
||||||
|
params: { schoolId: currentSchoolId.value ?? undefined },
|
||||||
cacheFor: null,
|
cacheFor: null,
|
||||||
}),
|
});
|
||||||
{ initialData: [], immediate: true }
|
typeList.value = (res ?? []).slice().sort((a: any, b: any) => (a.sortNo ?? 0) - (b.sortNo ?? 0));
|
||||||
)
|
} catch (e: any) {
|
||||||
|
console.warn(e?.message || "获取应用类型失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchTypeList()
|
||||||
|
|
||||||
const refFormData = useTemplateRef('formData');
|
const refFormData = useTemplateRef('formData');
|
||||||
const editModal = ref(false);
|
const editModal = ref(false);
|
||||||
@@ -80,13 +95,22 @@ async function addModuleShow() {
|
|||||||
editModal.value = true;
|
editModal.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
//拖动排序:提交当前顺序的 id 数组
|
//拖动排序:提交当前顺序的 id 数组(SCHOOL 模式写学校覆盖表快照;多校须先选定学校)
|
||||||
async function typeSortSave() {
|
async function typeSortSave() {
|
||||||
|
if (!requireSchool()) {
|
||||||
|
// 未选学校:拒绝提交并回拉列表,撤销拖拽造成的本地顺序变化
|
||||||
|
await fetchTypeList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ids = (typeList.value ?? []).map((s: any) => s.id);
|
const ids = (typeList.value ?? []).map((s: any) => s.id);
|
||||||
await Apis.AppSettings.appTypeSort({ data: ids });
|
await Apis.AppSettings.appTypeSort({
|
||||||
|
data: ids,
|
||||||
|
params: currentSchoolId.value != null ? { schoolId: currentSchoolId.value } : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showSetting(element: any) {
|
async function showSetting(element: any) {
|
||||||
|
if (!requireSchool()) return;
|
||||||
currentId.value = element.id;
|
currentId.value = element.id;
|
||||||
form.value = {
|
form.value = {
|
||||||
id: element.id,
|
id: element.id,
|
||||||
@@ -106,7 +130,7 @@ async function removeSave(element: any) {
|
|||||||
try {
|
try {
|
||||||
await Apis.AppSettings.deleteType({ pathParams: { id: element.id } });
|
await Apis.AppSettings.deleteType({ pathParams: { id: element.id } });
|
||||||
Message.success("操作成功!");
|
Message.success("操作成功!");
|
||||||
await sendTypeList();
|
await fetchTypeList();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -138,11 +162,13 @@ async function onRawEditSave() {
|
|||||||
id: currentId.value,
|
id: currentId.value,
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
},
|
},
|
||||||
|
// SCHOOL 模式:改名写学校覆盖表(仅该校生效)
|
||||||
|
params: currentSchoolId.value != null ? { schoolId: currentSchoolId.value } : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Message.success("操作成功!");
|
Message.success("操作成功!");
|
||||||
editModal.value = false;
|
editModal.value = false;
|
||||||
await sendTypeList();
|
await fetchTypeList();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -163,6 +189,10 @@ function onReset() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.school-select {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
.type_set-wrap {
|
.type_set-wrap {
|
||||||
margin: 12px 0 16px;
|
margin: 12px 0 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
+179
-23
@@ -2,14 +2,14 @@
|
|||||||
<Modal v-model="show" :mask-closable="false" width="800" scrollable :title="title"
|
<Modal v-model="show" :mask-closable="false" width="800" scrollable :title="title"
|
||||||
@on-visible-change="onVisibleChange">
|
@on-visible-change="onVisibleChange">
|
||||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="130" class="app-form width-95">
|
<Form ref="formRef" :model="form" :rules="rules" :label-width="130" class="app-form width-95">
|
||||||
<!-- 第一行:应用名称 / 应用类型 -->
|
<!-- 第一行:应用名称 / 应用类型(学校模式隐藏类型——类型归属平台主数据) -->
|
||||||
<Row>
|
<Row>
|
||||||
<Col span="12">
|
<Col span="12">
|
||||||
<FormItem label="应用名称:" prop="name">
|
<FormItem label="应用名称:" prop="name">
|
||||||
<Input v-model="form.name" placeholder="请输入应用名称" />
|
<Input v-model="form.name" placeholder="请输入应用名称" />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span="12">
|
<Col span="12" v-if="!schoolMode">
|
||||||
<FormItem label="应用类型:" prop="categoryId">
|
<FormItem label="应用类型:" prop="categoryId">
|
||||||
<Select v-model="form.categoryId" transfer placeholder="请选择应用类型">
|
<Select v-model="form.categoryId" transfer placeholder="请选择应用类型">
|
||||||
<Option v-for="item in categoryList" :key="item.id" :value="item.id" :label="item.name" />
|
<Option v-for="item in categoryList" :key="item.id" :value="item.id" :label="item.name" />
|
||||||
@@ -26,8 +26,40 @@
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<!-- 第三行:appKey / appSecret(编辑模式才显示) -->
|
<!-- 学校模式说明:仅 名称/描述/图标 可编辑,且仅对本校生效 -->
|
||||||
<Row v-if="isEdit">
|
<Row v-if="schoolMode">
|
||||||
|
<Col span="24">
|
||||||
|
<Alert show-icon>学校模式:此处修改仅对本校生效(应用别名),平台主数据与密钥/推送等配置不可修改。</Alert>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<!-- 应用性质 / 技术标识(学校模式隐藏——性质与技术标识均为平台主数据) -->
|
||||||
|
<Row v-if="!schoolMode">
|
||||||
|
<Col span="12">
|
||||||
|
<FormItem label="应用性质:">
|
||||||
|
<Switch v-model="natureBase" size="small" />
|
||||||
|
<span class="nature-hint">{{ natureBase ? '基石应用' : '业务应用' }}</span>
|
||||||
|
<Tooltip :max-width="320" content="基石应用不参与应用开通 / 授权 / 模块管理,不出现在门户应用列表与跳转注册表" placement="top" transfer>
|
||||||
|
<Icon type="md-help" class="help-icon" />
|
||||||
|
</Tooltip>
|
||||||
|
</FormItem>
|
||||||
|
</Col>
|
||||||
|
<Col span="12">
|
||||||
|
<FormItem label="技术标识:" prop="appCode">
|
||||||
|
<div class="secret-row">
|
||||||
|
<Input v-model="form.appCode" placeholder="如 flow-center(= 部署名,创建后尽量不改)" style="flex:1" />
|
||||||
|
<Tooltip :max-width="320"
|
||||||
|
content="技术标识(app_code):kebab-case,小写字母开头,仅小写字母/数字/连字符,≤32 位;= projman 部署名(容器名/网关 ctx/服务目录键),创建后尽量不改。"
|
||||||
|
placement="top" transfer>
|
||||||
|
<Button size="small" class="ml-8" icon="md-help" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<!-- 第三行:appKey / appSecret(编辑模式且非学校模式才显示) -->
|
||||||
|
<Row v-if="isEdit && !schoolMode">
|
||||||
<Col span="12">
|
<Col span="12">
|
||||||
<FormItem label="appKey:">
|
<FormItem label="appKey:">
|
||||||
<Input :model-value="form.appKey?.toString()" readonly />
|
<Input :model-value="form.appKey?.toString()" readonly />
|
||||||
@@ -43,8 +75,8 @@
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<!-- 第四行:应用前端类型(tag 多选) -->
|
<!-- 第四行:应用前端类型(tag 多选;学校模式/基石应用隐藏——基石不进门户,无投放载体) -->
|
||||||
<Row>
|
<Row v-if="!schoolMode && !natureBase">
|
||||||
<Col span="24">
|
<Col span="24">
|
||||||
<FormItem label="应用前端类型:">
|
<FormItem label="应用前端类型:">
|
||||||
<Select v-model="form.clients" multiple transfer placeholder="请选择前端类型" class="client-select">
|
<Select v-model="form.clients" multiple transfer placeholder="请选择前端类型" class="client-select">
|
||||||
@@ -54,14 +86,14 @@
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<!-- 第五行:Pc应用地址 / Pc应用图标 -->
|
<!-- 第五行:Pc应用地址 / 应用图标(平台模式=AppIcon 矢量选择器;学校模式保留位图上传——学校覆盖线只收 pcIcon) -->
|
||||||
<Row>
|
<Row v-if="!natureBase">
|
||||||
<Col span="12">
|
<Col span="12" v-if="!schoolMode">
|
||||||
<FormItem label="Pc应用地址:">
|
<FormItem label="Pc应用地址:">
|
||||||
<Input v-model="form.pcUrl" placeholder="请输入Pc应用地址" />
|
<Input v-model="form.pcUrl" placeholder="请输入Pc应用地址" />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span="12">
|
<Col span="12" v-if="schoolMode">
|
||||||
<FormItem label="Pc应用图标:">
|
<FormItem label="Pc应用图标:">
|
||||||
<Upload :on-success="(res: any) => onUploadSuccess(res, 'pcIcon')" :show-upload-list="false"
|
<Upload :on-success="(res: any) => onUploadSuccess(res, 'pcIcon')" :show-upload-list="false"
|
||||||
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
|
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
|
||||||
@@ -77,35 +109,73 @@
|
|||||||
</Upload>
|
</Upload>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col span="12" v-else>
|
||||||
|
<FormItem label="应用图标:">
|
||||||
|
<AppIconPicker v-model="form.iconKey" v-model:color="form.iconColor" />
|
||||||
|
</FormItem>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<!-- 第六行:App接口地址 / App应用图标 -->
|
<!-- 第六行:App接口地址(学校模式/基石应用隐藏;App位图图标移入下方高级折叠区) -->
|
||||||
<Row>
|
<Row v-if="!schoolMode && !natureBase">
|
||||||
<Col span="12">
|
<Col span="12">
|
||||||
<FormItem label="App接口地址:">
|
<FormItem label="App接口地址:">
|
||||||
<Input v-model="form.appUrl" placeholder="请输入App接口地址" />
|
<Input v-model="form.appUrl" placeholder="请输入App接口地址" />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span="12">
|
</Row>
|
||||||
<FormItem label="App应用图标:">
|
|
||||||
<Upload :on-success="(res: any) => onUploadSuccess(res, 'appIcon')" :show-upload-list="false"
|
<!-- 位图上传(高级 · 遗留通道):存量 pc_icon/app_icon 位图链路保留,新应用优先矢量图标键 -->
|
||||||
|
<Row v-if="!schoolMode && !natureBase">
|
||||||
|
<Col span="24">
|
||||||
|
<Collapse>
|
||||||
|
<Panel name="bitmap">
|
||||||
|
上传位图图标(高级 · 遗留通道)
|
||||||
|
<template #content>
|
||||||
|
<div class="bitmap-upload-row">
|
||||||
|
<div class="bitmap-upload-item">
|
||||||
|
<div class="bitmap-label">Pc应用图标:</div>
|
||||||
|
<Upload :on-success="(res: any) => onUploadSuccess(res, 'pcIcon')"
|
||||||
|
:before-upload="beforeBitmapUpload" :show-upload-list="false"
|
||||||
|
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
|
||||||
|
:with-credentials="true" :headers="{ yschapirequest: 1 }">
|
||||||
|
<div v-if="!form.pcIcon" class="icon-upload-wrap">
|
||||||
|
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
|
||||||
|
</div>
|
||||||
|
<div class="icon-preview-wrap" v-else>
|
||||||
|
<SvgIcon @click.stop="form.pcIcon = null" class="cloase-btn"
|
||||||
|
name="widget-close" size="18" color="#fff" />
|
||||||
|
<img :src="previewUrl(form.pcIcon)" alt="" />
|
||||||
|
</div>
|
||||||
|
</Upload>
|
||||||
|
<div class="bitmap-hint">矢量图标(上方)优先生效;此位图为存量回退。</div>
|
||||||
|
</div>
|
||||||
|
<div class="bitmap-upload-item">
|
||||||
|
<div class="bitmap-label">App应用图标:</div>
|
||||||
|
<Upload :on-success="(res: any) => onUploadSuccess(res, 'appIcon')"
|
||||||
|
:before-upload="beforeBitmapUpload" :show-upload-list="false"
|
||||||
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
|
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
|
||||||
:with-credentials="true" :headers="{ yschapirequest: 1 }">
|
:with-credentials="true" :headers="{ yschapirequest: 1 }">
|
||||||
<div v-if="!form.appIcon" class="icon-upload-wrap">
|
<div v-if="!form.appIcon" class="icon-upload-wrap">
|
||||||
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
|
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
|
||||||
</div>
|
</div>
|
||||||
<div class="icon-preview-wrap" v-else>
|
<div class="icon-preview-wrap" v-else>
|
||||||
<SvgIcon @click.stop="form.appIcon = null" class="cloase-btn" name="widget-close" size="18"
|
<SvgIcon @click.stop="form.appIcon = null" class="cloase-btn"
|
||||||
color="#fff" />
|
name="widget-close" size="18" color="#fff" />
|
||||||
<img :src="previewUrl(form.appIcon)" alt="" />
|
<img :src="previewUrl(form.appIcon)" alt="" />
|
||||||
</div>
|
</div>
|
||||||
</Upload>
|
</Upload>
|
||||||
</FormItem>
|
<div class="bitmap-hint">存量移动端位图;矢量图标优先生效。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Panel>
|
||||||
|
</Collapse>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<!-- 第七行:公众号应用地址 / 钉钉应用地址 -->
|
<!-- 第七行:公众号应用地址 / 钉钉应用地址(学校模式/基石应用隐藏) -->
|
||||||
<Row>
|
<Row v-if="!schoolMode && !natureBase">
|
||||||
<Col span="12">
|
<Col span="12">
|
||||||
<FormItem label="公众号应用地址:">
|
<FormItem label="公众号应用地址:">
|
||||||
<Input v-model="form.officialAccountUrl" placeholder="请输入公众号应用地址" />
|
<Input v-model="form.officialAccountUrl" placeholder="请输入公众号应用地址" />
|
||||||
@@ -128,6 +198,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SvgIcon from "@/components/SvgIcon.vue";
|
import SvgIcon from "@/components/SvgIcon.vue";
|
||||||
|
import AppIconPicker from "@/components/AppIconPicker.vue";
|
||||||
import { computed, reactive, watch } from "vue";
|
import { computed, reactive, watch } from "vue";
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||||
|
|
||||||
@@ -139,11 +210,14 @@ const props = withDefaults(defineProps<{
|
|||||||
categoryList?: any[];
|
categoryList?: any[];
|
||||||
isEdit?: boolean;
|
isEdit?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
/** 学校管理员模式:仅 名称/描述/Pc图标 三字段可编辑(写学校覆盖表) */
|
||||||
|
schoolMode?: boolean;
|
||||||
}>(), {
|
}>(), {
|
||||||
initForm: () => ({}),
|
initForm: () => ({}),
|
||||||
categoryList: () => [],
|
categoryList: () => [],
|
||||||
isEdit: false,
|
isEdit: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
schoolMode: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'submit', 'cancel', 'reset-secret']);
|
const emit = defineEmits(['update:modelValue', 'submit', 'cancel', 'reset-secret']);
|
||||||
@@ -163,11 +237,16 @@ const defaultForm = (): Record<string, any> => ({
|
|||||||
desc: '',
|
desc: '',
|
||||||
appKey: null,
|
appKey: null,
|
||||||
appSecret: '',
|
appSecret: '',
|
||||||
|
appCode: '',
|
||||||
|
appNature: 'BUSINESS',
|
||||||
clients: [] as number[],
|
clients: [] as number[],
|
||||||
pcUrl: '',
|
pcUrl: '',
|
||||||
pcIcon: '',
|
pcIcon: '',
|
||||||
appUrl: '',
|
appUrl: '',
|
||||||
appIcon: '',
|
appIcon: '',
|
||||||
|
// 矢量图标(AppIcon 资产库):iconKey 空 = 不使用矢量,端侧走 关键词→位图→首字 原回退链
|
||||||
|
iconKey: '',
|
||||||
|
iconColor: '',
|
||||||
officialAccountUrl: '',
|
officialAccountUrl: '',
|
||||||
dingtalkUrl: '',
|
dingtalkUrl: '',
|
||||||
});
|
});
|
||||||
@@ -201,17 +280,48 @@ function onUploadSuccess(response: any, field: 'pcIcon' | 'appIcon') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 位图上传前置校验:≤64KB(与后端 P4 校验同口径;svg 另由后端消毒) */
|
||||||
|
const BITMAP_MAX_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
function beforeBitmapUpload(file: File) {
|
||||||
|
if (file.size > BITMAP_MAX_BYTES) {
|
||||||
|
Message.error(`图标文件 ${(file.size / 1024).toFixed(0)}KB 超过 64KB 上限,请压缩后上传`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 前端类型选项 ---------- */
|
/* ---------- 前端类型选项 ---------- */
|
||||||
|
// value 对齐后端平台枚举 com.yunhai.platform.utils.enums.ClientEnum(位掩码 key):
|
||||||
|
// 1=Pc浏览器 2=移动App 8=微信公众号 32=钉钉;公众号/钉钉地址见表单第七行
|
||||||
const clientOptions = [
|
const clientOptions = [
|
||||||
{ value: 1, label: 'Pc浏览器' },
|
{ value: 1, label: 'Pc浏览器' },
|
||||||
{ value: 2, label: '移动App' },
|
{ value: 2, label: '移动App' },
|
||||||
|
{ value: 8, label: '微信公众号' },
|
||||||
|
{ value: 32, label: '钉钉' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ---------- 校验规则 ---------- */
|
/* ---------- 应用性质 Switch(开=BASE 基石 / 关=BUSINESS 业务) ---------- */
|
||||||
const rules = {
|
const natureBase = computed({
|
||||||
|
get: () => form.appNature === 'BASE',
|
||||||
|
set: (val: boolean) => { form.appNature = val ? 'BASE' : 'BUSINESS'; },
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- 校验规则(学校模式不校验 categoryId——类型字段不展示且不可改) ---------- */
|
||||||
|
const rules = computed(() => ({
|
||||||
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
|
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
|
||||||
|
...(props.schoolMode ? {} : {
|
||||||
categoryId: [{ required: true, type: 'number' as const, message: '请选择应用类型', trigger: 'change' }],
|
categoryId: [{ required: true, type: 'number' as const, message: '请选择应用类型', trigger: 'change' }],
|
||||||
};
|
}),
|
||||||
|
// 技术标识新增与编辑均必填(2026-09-28 拍板,后端 Create/Update 组同口径);学校模式不展示该字段
|
||||||
|
...(props.schoolMode ? {} : {
|
||||||
|
appCode: [
|
||||||
|
{ required: true, message: '请输入技术标识', trigger: 'blur' },
|
||||||
|
// 2026-09-28 拍板命名规范:app_code 一律 kebab-case 且 = 部署名(PlatformService 枚举头注释同源)
|
||||||
|
{ pattern: /^[a-z][a-z0-9-]{0,31}$/, message: '小写字母开头,仅小写字母/数字/连字符,≤32 位', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
/* ---------- 提交 ---------- */
|
/* ---------- 提交 ---------- */
|
||||||
const formRef = ref();
|
const formRef = ref();
|
||||||
@@ -332,4 +442,50 @@ function onVisibleChange(val: boolean) {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 位图上传(高级折叠区):并排两枚上传位 */
|
||||||
|
.bitmap-upload-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bitmap-upload-item {
|
||||||
|
.bitmap-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #515a6e;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bitmap-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 应用性质说明文字 */
|
||||||
|
.nature-hint {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: #808695;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单帮助图标(Tooltip 触发器):静默态用中性灰阶令牌,交互态才升主色(含淡底),
|
||||||
|
一律不写死色号——切档位(blue/ocean/ink/violet/teal)自动跟随。
|
||||||
|
括号内兜底值 = 默认档 blue,仅防通道 CSS 不可达时图标失色(接入后由通道令牌接管)。 */
|
||||||
|
.help-icon {
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, background-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--brand, #577afb);
|
||||||
|
background-color: var(--brand-soft, #eef2ff);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+101
-70
@@ -18,23 +18,29 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<Button class="fl" type="primary" @click="handleAdd">新增应用</Button>
|
<!-- 多校管理员:学校切换(覆盖仅对所选学校生效) -->
|
||||||
|
<Select v-if="multiSchool" v-model="currentSchoolId" class="fl school-select" transfer
|
||||||
|
placeholder="全部学校(并集)" @on-change="onSchoolChange">
|
||||||
|
<Option v-for="s in schoolList" :key="s.id" :value="s.id">{{ s.name }}</Option>
|
||||||
|
</Select>
|
||||||
|
<Button v-if="!isSchoolMode" class="fl" :class="{ 'ml-8': multiSchool }" type="primary"
|
||||||
|
@click="handleAdd">新增应用</Button>
|
||||||
<Button class="fl ml-8" type="default" v-if="dataParams.categoryId != null"
|
<Button class="fl ml-8" type="default" v-if="dataParams.categoryId != null"
|
||||||
@click="sortModalVisible = true">排序管理</Button>
|
@click="openSortModal">排序管理</Button>
|
||||||
<Input class="fr" v-model="keyword" placeholder="输入应用名称或appKey筛选" search clearable suffix="ios-search"
|
<Input class="fr" v-model="keyword" placeholder="输入应用名称或appKey筛选" search clearable suffix="ios-search"
|
||||||
style="width:220px" @on-search="onSearch" @on-clear="onSearch" />
|
style="width:220px" @on-search="onSearch" @on-clear="onSearch" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columnsComp" :tableHeight="tableHeight" />
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columnsComp" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 容器组件:新增 / 编辑 -->
|
<!-- 容器组件:新增 / 编辑 -->
|
||||||
<AddAppContainer ref="addRef" />
|
<AddAppContainer ref="addRef" />
|
||||||
<EditAppContainer ref="editRef" />
|
<EditAppContainer ref="editRef" :schoolId="currentSchoolId" />
|
||||||
|
|
||||||
<!-- 排序管理 -->
|
<!-- 排序管理 -->
|
||||||
<AppSort v-model="sortModalVisible" :categoryId="dataParams.categoryId" :categoryName="currentCategoryName"
|
<AppSort v-model="sortModalVisible" :categoryId="dataParams.categoryId" :categoryName="currentCategoryName"
|
||||||
@saved="onSortSaved" />
|
:schoolId="currentSchoolId" @saved="onSortSaved" />
|
||||||
|
|
||||||
<!-- 推送设置 -->
|
<!-- 推送设置 -->
|
||||||
<PushSettingModal v-model="pushSettingVisible" :appId="currentApp.id" :appName="currentApp.name" />
|
<PushSettingModal v-model="pushSettingVisible" :appId="currentApp.id" :appName="currentApp.name" />
|
||||||
@@ -45,7 +51,6 @@
|
|||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
defineOptions({ name: "AppList" });
|
defineOptions({ name: "AppList" });
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
import AddAppContainer from "./AddAppContainer.vue";
|
import AddAppContainer from "./AddAppContainer.vue";
|
||||||
import EditAppContainer from "./EditAppContainer.vue";
|
import EditAppContainer from "./EditAppContainer.vue";
|
||||||
@@ -53,9 +58,13 @@ import PushSettingModal from "./PushSettingModal.vue";
|
|||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
import { OperatorEnum } from '@/types';
|
import { OperatorEnum } from '@/types';
|
||||||
import AppSort from './AppSort.vue';
|
import AppSort from './AppSort.vue';
|
||||||
|
import { useAppSchoolScope } from "@/utils/hook/useAppSchoolScope";
|
||||||
|
import { actionColumn } from "@/utils/tableActions";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
// 学校管理员上下文:多校显示切换下拉,覆盖仅对所选学校生效(默认并集,写操作须先选定学校)
|
||||||
|
const { isSchoolMode, multiSchool, schoolList, currentSchoolId, requireSchool } = useAppSchoolScope();
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
|
|
||||||
@@ -79,29 +88,39 @@ function onCategoryClick(id: number | null) {
|
|||||||
tableRef.value?.refreshData();
|
tableRef.value?.refreshData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- 多校切换:刷新类型与列表 ---------------- */
|
||||||
|
function onSchoolChange() {
|
||||||
|
fetchTypeList();
|
||||||
|
tableRef.value?.refreshData();
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- 输入框搜索:仅在触发搜索时提交 ---------------- */
|
/* ---------------- 输入框搜索:仅在触发搜索时提交 ---------------- */
|
||||||
function onSearch() {
|
function onSearch() {
|
||||||
dataParams.value.name = keyword.value.trim() || undefined;
|
dataParams.value.name = keyword.value.trim() || undefined;
|
||||||
tableRef.value?.refreshData();
|
tableRef.value?.refreshData();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 左侧应用类型 ---------------- */
|
/* ---------------- 左侧应用类型(SCHOOL 模式合并学校私有改名/排序;随学校切换刷新) ---------------- */
|
||||||
const { data: appTypeList, send: fetchTypeList } = useRequest(
|
const appTypeList = ref<any[]>([]);
|
||||||
Apis.AppSettings.appTypeList({
|
async function fetchTypeList() {
|
||||||
transform: (res: any) => {
|
try {
|
||||||
const list: any[] = res ?? [];
|
const res: any = await Apis.AppSettings.appTypeList({
|
||||||
return list.slice().sort((a, b) => (a.sortNo ?? 0) - (b.sortNo ?? 0));
|
params: { schoolId: currentSchoolId.value ?? undefined },
|
||||||
},
|
|
||||||
cacheFor: null,
|
cacheFor: null,
|
||||||
}),
|
});
|
||||||
{ initialData: [] }
|
appTypeList.value = ((res ?? []) as any[]).slice().sort((a, b) => (a.sortNo ?? 0) - (b.sortNo ?? 0));
|
||||||
);
|
} catch (e: any) {
|
||||||
|
console.warn(e?.message || "获取应用类型失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchTypeList();
|
||||||
|
|
||||||
const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
||||||
return Apis.AppSettings.appList({
|
return Apis.AppSettings.appList({
|
||||||
params: {
|
params: {
|
||||||
categoryId: dataParams.value.categoryId ?? undefined,
|
categoryId: dataParams.value.categoryId ?? undefined,
|
||||||
name: dataParams.value.name || undefined,
|
name: dataParams.value.name || undefined,
|
||||||
|
schoolId: currentSchoolId.value ?? undefined,
|
||||||
pageIndex: params.pageIndex,
|
pageIndex: params.pageIndex,
|
||||||
pageSize: params.pageSize,
|
pageSize: params.pageSize,
|
||||||
},
|
},
|
||||||
@@ -126,10 +145,6 @@ function onSortSaved() {
|
|||||||
tableRef.value?.reloadData(OperatorEnum.sel);
|
tableRef.value?.reloadData(OperatorEnum.sel);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableHeight = ref(
|
|
||||||
$utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true })
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ---------------- 表格固定列(不含“应用类型”列) ---------------- */
|
/* ---------------- 表格固定列(不含“应用类型”列) ---------------- */
|
||||||
const columns = [
|
const columns = [
|
||||||
{ type: "index", title: "序号", width: 70 },
|
{ type: "index", title: "序号", width: 70 },
|
||||||
@@ -141,26 +156,54 @@ const columns = [
|
|||||||
key: "clients",
|
key: "clients",
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (h: any, { row }: any) => {
|
render: (h: any, { row }: any) => {
|
||||||
const map: Record<number, string> = { 1: "Pc浏览器", 2: "移动App" };
|
// value 对齐平台 ClientEnum:1=Pc浏览器 2=移动App 8=微信公众号 32=钉钉
|
||||||
|
const map: Record<number, string> = { 1: "Pc浏览器", 2: "移动App", 8: "微信公众号", 32: "钉钉" };
|
||||||
const arr: number[] = Array.isArray(row.clients) ? row.clients : [];
|
const arr: number[] = Array.isArray(row.clients) ? row.clients : [];
|
||||||
if (!arr.length) return h("span", "-");
|
if (!arr.length) return h("span", "-");
|
||||||
return h("span", arr.map((v) => map[v] ?? v).join("、"));
|
return h("span", arr.map((v) => map[v] ?? v).join("、"));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "操作",
|
title: "性质",
|
||||||
key: "action",
|
key: "appNature",
|
||||||
align: 'center',
|
width: 80,
|
||||||
width: 280,
|
render: (h: any, { row }: any) => h("span", row.appNature === "BASE" ? "基石" : "业务"),
|
||||||
render: (h: any, { row }: any) => {
|
|
||||||
return [
|
|
||||||
$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row)),
|
|
||||||
$utils.operatingTextHandle(h, '模块管理', '', () => goModuleManage(row)),
|
|
||||||
$utils.operatingTextHandle(h, '推送设置', '', () => openPushSetting(row)),
|
|
||||||
$utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
actionColumn(
|
||||||
|
[
|
||||||
|
// SCHOOL 模式:仅编辑(名称/描述/图标,写学校覆盖表);模块管理/推送设置/删除全部隐藏。
|
||||||
|
// 基石应用(app_nature=BASE)只做身份注册:模块/推送置灰;删除允许——基石也可下线,
|
||||||
|
// 但走「输名确认」删除模(危险确认,与 projman 应用删除同款)。
|
||||||
|
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => handleEdit(r) },
|
||||||
|
{
|
||||||
|
key: "module",
|
||||||
|
label: "模块管理",
|
||||||
|
icon: "icon-apps",
|
||||||
|
on: (r: any) => goModuleManage(r),
|
||||||
|
when: () => !isSchoolMode.value,
|
||||||
|
disabledWhen: (r: any) => r.appNature === "BASE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "push",
|
||||||
|
label: "推送设置",
|
||||||
|
icon: "icon-fasong",
|
||||||
|
on: (r: any) => openPushSetting(r),
|
||||||
|
when: () => !isSchoolMode.value,
|
||||||
|
disabledWhen: (r: any) => r.appNature === "BASE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "del",
|
||||||
|
label: "删除",
|
||||||
|
icon: "icon-ashbin",
|
||||||
|
on: (r: any) => handleDelete(r),
|
||||||
|
when: () => !isSchoolMode.value,
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// UserBase 恒文字链(工程风格显式声明,不随动作数漂移);
|
||||||
|
// icon 字段仅留作未来切 mode:"icon" 的备选。4 动作 → 2 直出 + 推送设置/删除进「更多」
|
||||||
|
{ mode: "text", width: 180 },
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 条件列:仅在「未筛选应用类型」时插入到“应用名称”之后
|
// 条件列:仅在「未筛选应用类型」时插入到“应用名称”之后
|
||||||
@@ -195,23 +238,37 @@ function handleAdd() {
|
|||||||
const editRef = ref();
|
const editRef = ref();
|
||||||
|
|
||||||
function handleEdit(row: any) {
|
function handleEdit(row: any) {
|
||||||
|
// 多校管理员:编辑写学校覆盖表,必须先选定学校(后端对模糊写目标直接拒绝)
|
||||||
|
if (!requireSchool()) return;
|
||||||
editRef.value?.open(row.id, row, appTypeList.value, () => {
|
editRef.value?.open(row.id, row, appTypeList.value, () => {
|
||||||
tableRef.value?.reloadData(OperatorEnum.edit);
|
tableRef.value?.reloadData(OperatorEnum.edit);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 删除应用 ---------------- */
|
/* ---------------- 排序管理入口(多校须先选定学校) ---------------- */
|
||||||
|
function openSortModal() {
|
||||||
|
if (!requireSchool()) return;
|
||||||
|
sortModalVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 删除应用(删除模:输名确认 + 红色确认键,对齐 projman;基石应用同样可删) ---------------- */
|
||||||
async function handleDelete(row: any) {
|
async function handleDelete(row: any) {
|
||||||
|
const name = row.name || String(row.id);
|
||||||
await MyConfirm({
|
await MyConfirm({
|
||||||
msg: `确定删除应用「${row.name || row.id}」吗?`,
|
title: "删除应用",
|
||||||
info: "删除后不可恢复!",
|
msg: `确定删除应用「${name}」吗?`,
|
||||||
|
info: "删除后不可恢复,关联就绪资源需手动清理!"
|
||||||
|
+ (row.appNature === "BASE" ? "(基石应用:请先确认无平台组件仍依赖其 appKey 签名身份)" : ""),
|
||||||
|
confirmName: name,
|
||||||
|
danger: true,
|
||||||
|
confirmText: "删除",
|
||||||
async onOk() {
|
async onOk() {
|
||||||
try {
|
try {
|
||||||
await Apis.AppSettings.deleteApp({ pathParams: { id: row.id } });
|
await Apis.AppSettings.deleteApp({ pathParams: { id: row.id } });
|
||||||
Message.success('删除成功!');
|
Message.success('删除成功!');
|
||||||
tableRef.value?.reloadData(OperatorEnum.del);
|
tableRef.value?.reloadData(OperatorEnum.del);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.warn(e?.message || '删除失败');
|
Message.error(e?.message || '删除失败');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -242,6 +299,9 @@ const goModuleManage = (row: any) => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-select {
|
||||||
width: 180px;
|
width: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +320,7 @@ const goModuleManage = (row: any) => {
|
|||||||
border-top: 1px solid #eeeeee;
|
border-top: 1px solid #eeeeee;
|
||||||
|
|
||||||
.ivu-btn-text {
|
.ivu-btn-text {
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,37 +341,8 @@ const goModuleManage = (row: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
li.active {
|
li.active {
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
border-color: #577afb;
|
border-color: var(--brand);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-link {
|
|
||||||
cursor: pointer;
|
|
||||||
color: #577afb;
|
|
||||||
font-size: 13px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #3d5afe;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.danger {
|
|
||||||
color: #ed4014;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #c51d04;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-divider {
|
|
||||||
margin: 0 8px;
|
|
||||||
color: #dcdee2;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -40,9 +40,12 @@ const props = withDefaults(
|
|||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
categoryId: number | null;
|
categoryId: number | null;
|
||||||
categoryName: string;
|
categoryName: string;
|
||||||
|
/** 多校管理员当前所选学校:排序快照仅对该校生效 */
|
||||||
|
schoolId?: number | null;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
categoryName: "",
|
categoryName: "",
|
||||||
|
schoolId: null,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -83,7 +86,10 @@ async function fetchSortList() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await Apis.AppSettings.appSortList({
|
const res = await Apis.AppSettings.appSortList({
|
||||||
params: { categoryId: props.categoryId },
|
params: {
|
||||||
|
categoryId: props.categoryId,
|
||||||
|
schoolId: props.schoolId ?? undefined,
|
||||||
|
},
|
||||||
cacheFor: null,
|
cacheFor: null,
|
||||||
});
|
});
|
||||||
sortList.value = (res ?? []).slice().sort((a: any, b: any) => (a.sortNo ?? 0) - (b.sortNo ?? 0));
|
sortList.value = (res ?? []).slice().sort((a: any, b: any) => (a.sortNo ?? 0) - (b.sortNo ?? 0));
|
||||||
@@ -109,7 +115,10 @@ async function handleSave() {
|
|||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
const ids = sortList.value.map((item) => item.id);
|
const ids = sortList.value.map((item) => item.id);
|
||||||
await Apis.AppSettings.appSort({ data: ids });
|
await Apis.AppSettings.appSort({
|
||||||
|
data: ids,
|
||||||
|
params: props.schoolId != null ? { schoolId: props.schoolId } : undefined,
|
||||||
|
});
|
||||||
Message.success("排序保存成功!");
|
Message.success("排序保存成功!");
|
||||||
hasChanged.value = false;
|
hasChanged.value = false;
|
||||||
modalVisible.value = false;
|
modalVisible.value = false;
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppForm v-model="visible" :initForm="detailData" :categoryList="categoryList" :isEdit="true" :loading="submitting"
|
<AppForm v-model="visible" :initForm="detailData" :categoryList="categoryList" :isEdit="true" :loading="submitting"
|
||||||
@submit="handleSubmit" @cancel="visible = false" @reset-secret="handleResetSecret" />
|
:schoolMode="isSchoolMode" @submit="handleSubmit" @cancel="visible = false" @reset-secret="handleResetSecret" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import AppForm from "./AppForm.vue";
|
import AppForm from "./AppForm.vue";
|
||||||
import MyConfirm from '@/components/MyConfirm'
|
import MyConfirm from '@/components/MyConfirm'
|
||||||
|
import { UserRoleEnum } from "@/types";
|
||||||
|
import { useDefaultStore } from "@/store";
|
||||||
|
|
||||||
/* ---------- 对外暴露 ---------- */
|
/* ---------- 对外暴露 ---------- */
|
||||||
const visible = ref(false);
|
const visible = ref(false);
|
||||||
const categoryList = ref<any[]>([]);
|
const categoryList = ref<any[]>([]);
|
||||||
|
|
||||||
// 编辑模式:从 appList 中根据 id 取出详情回填(无独立详情接口)
|
const store = useDefaultStore();
|
||||||
|
const isSchoolMode = store.userRole === UserRoleEnum.School;
|
||||||
|
|
||||||
|
// 多校管理员当前所选学校(单校/超管为 undefined,后端按 scope 自动解析)
|
||||||
|
const props = defineProps<{ schoolId?: number | null }>();
|
||||||
|
|
||||||
|
// 编辑模式:从 appList 中根据 id 取出详情回填(无独立详情接口;SCHOOL 模式列表已合并覆盖值)
|
||||||
const detailData = ref<Record<string, any>>({});
|
const detailData = ref<Record<string, any>>({});
|
||||||
const currentId = ref<number | null>(null);
|
const currentId = ref<number | null>(null);
|
||||||
|
|
||||||
@@ -21,12 +29,16 @@ let onSuccessCallback: (() => void) | null = null;
|
|||||||
|
|
||||||
// useForm 托管提交——currentId 是 ref,闭包内 .value 在 sendForm 时取当前值
|
// useForm 托管提交——currentId 是 ref,闭包内 .value 在 sendForm 时取当前值
|
||||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
||||||
(dto) => Apis.AppSettings.editApp({ pathParams: { id: currentId.value }, data: dto }),
|
(dto) => Apis.AppSettings.editApp({
|
||||||
{ initialForm: { id: null, name: '', categoryId: undefined, desc: '', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
|
pathParams: { id: currentId.value },
|
||||||
|
data: dto,
|
||||||
|
params: props.schoolId != null ? { schoolId: props.schoolId } : undefined,
|
||||||
|
}),
|
||||||
|
{ initialForm: { id: null, name: '', categoryId: undefined, desc: '', appCode: '', appNature: 'BUSINESS', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', iconKey: '', iconColor: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
onSuccess(() => {
|
onSuccess(() => {
|
||||||
Message.success('编辑应用成功!');
|
Message.success(isSchoolMode ? '保存成功(仅对本校生效)!' : '编辑应用成功!');
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
onSuccessCallback?.();
|
onSuccessCallback?.();
|
||||||
});
|
});
|
||||||
@@ -54,23 +66,42 @@ function open(id: number, row: any, categories: any[], onSuccess?: () => void) {
|
|||||||
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
|
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
|
||||||
async function handleSubmit(formData: Record<string, any>) {
|
async function handleSubmit(formData: Record<string, any>) {
|
||||||
if (!currentId.value) return;
|
if (!currentId.value) return;
|
||||||
|
if (isSchoolMode) {
|
||||||
|
// 学校模式:仅 名称/描述/Pc图标 三字段写学校覆盖表(后端 editAppForSchool 只读这三个);
|
||||||
|
// categoryId/appCode/appNature 原样回传仅为满足平台级 Update 校验(后端不落平台表),缺失会 400;
|
||||||
|
// iconKey/iconColor 不送键(后端 null=不改——学校线不动平台矢量配置)
|
||||||
form.value = {
|
form.value = {
|
||||||
id: currentId.value,
|
id: currentId.value,
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
categoryId: formData.categoryId,
|
categoryId: formData.categoryId,
|
||||||
desc: formData.desc,
|
desc: formData.desc,
|
||||||
|
appCode: formData.appCode,
|
||||||
|
appNature: formData.appNature,
|
||||||
|
pcIcon: formData.pcIcon,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
form.value = {
|
||||||
|
id: currentId.value,
|
||||||
|
name: formData.name,
|
||||||
|
categoryId: formData.categoryId,
|
||||||
|
desc: formData.desc,
|
||||||
|
appCode: formData.appCode,
|
||||||
|
appNature: formData.appNature,
|
||||||
clients: formData.clients,
|
clients: formData.clients,
|
||||||
pcUrl: formData.pcUrl,
|
pcUrl: formData.pcUrl,
|
||||||
pcIcon: formData.pcIcon,
|
pcIcon: formData.pcIcon,
|
||||||
appUrl: formData.appUrl,
|
appUrl: formData.appUrl,
|
||||||
appIcon: formData.appIcon,
|
appIcon: formData.appIcon,
|
||||||
|
iconKey: formData.iconKey,
|
||||||
|
iconColor: formData.iconColor,
|
||||||
officialAccountUrl: formData.officialAccountUrl,
|
officialAccountUrl: formData.officialAccountUrl,
|
||||||
dingtalkUrl: formData.dingtalkUrl,
|
dingtalkUrl: formData.dingtalkUrl,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
await sendForm();
|
await sendForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 重置密钥(非表单提交,直接 MyConfirm + await) ---------- */
|
/* ---------- 重置密钥(非表单提交,直接 MyConfirm + await;学校模式入口已隐藏) ---------- */
|
||||||
async function handleResetSecret() {
|
async function handleResetSecret() {
|
||||||
if (!currentId.value) return;
|
if (!currentId.value) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -27,10 +27,14 @@
|
|||||||
<Input placeholder="请输入模块名称" v-model="form.name" />
|
<Input placeholder="请输入模块名称" v-model="form.name" />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="模块路径:" prop="path">
|
<FormItem label="模块路径:" prop="path">
|
||||||
<Input placeholder="请输入模块路径" v-model="form.path" />
|
<Input :placeholder="openingIsChild ? '选填;作按钮权限时可留空' : '请输入模块路径'" v-model="form.path" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="模块图标:" prop="icon">
|
||||||
|
<Input placeholder="选填,YschIcons 图标名(如 notice-list);留空则不下发图标"
|
||||||
|
v-model="form.icon" clearable />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="自定义ID:" prop="customId">
|
<FormItem label="自定义ID:" prop="customId">
|
||||||
<InputNumber :style="{ width: '100%' }" placeholder="请输入自定义ID" v-model="form.customId" />
|
<InputNumber :style="{ width: '100%' }" :placeholder="openingIsChild ? '选填;作按钮权限时可留空' : '请输入自定义ID'" v-model="form.customId" />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="排序号:" prop="sortNo">
|
<FormItem label="排序号:" prop="sortNo">
|
||||||
<InputNumber :style="{ width: '100%' }" placeholder="请输入排序号" v-model="form.sortNo" />
|
<InputNumber :style="{ width: '100%' }" placeholder="请输入排序号" v-model="form.sortNo" />
|
||||||
@@ -50,9 +54,7 @@ import { useRoute, useRouter } from "vue-router";
|
|||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import SvgIcon from "@/components/SvgIcon.vue";
|
import SvgIcon from "@/components/SvgIcon.vue";
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
import { actionColumn } from "@/utils/tableActions";
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -91,25 +93,39 @@ const flatData = computed(() => flattenTree(moduleList.value));
|
|||||||
/* ---------------- 表格列 ---------------- */
|
/* ---------------- 表格列 ---------------- */
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: "模块名称", key: "name", minWidth: 260, slot: "name" },
|
{ title: "模块名称", key: "name", minWidth: 260, slot: "name" },
|
||||||
|
{
|
||||||
|
title: "图标",
|
||||||
|
key: "icon",
|
||||||
|
width: 170,
|
||||||
|
align: "center",
|
||||||
|
render: (h: any, { row }: any) => {
|
||||||
|
if (!row.icon) return h("span", { style: { color: "#c5c8ce" } }, "-");
|
||||||
|
return h("div", {
|
||||||
|
style: { display: "flex", alignItems: "center", justifyContent: "center", gap: "6px" },
|
||||||
|
title: row.icon,
|
||||||
|
}, [
|
||||||
|
h(SvgIcon, { name: row.icon, size: 16 }),
|
||||||
|
h("span", {
|
||||||
|
style: { maxWidth: "110px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
||||||
|
}, row.icon),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
{ title: "ID", key: "customId", width: 120, align: "center", render: (h: any, { row }: any) => h("span", row.customId || "-") },
|
{ title: "ID", key: "customId", width: 120, align: "center", render: (h: any, { row }: any) => h("span", row.customId || "-") },
|
||||||
{ title: "路径", key: "path", minWidth: 200, render: (h: any, { row }: any) => h("span", row.path || "-") },
|
{ title: "路径", key: "path", minWidth: 200, render: (h: any, { row }: any) => h("span", row.path || "-") },
|
||||||
{ title: "排序号", key: "sortNo", width: 100, align: "center", render: (h: any, { row }: any) => h("span", row.sortNo ?? "-") },
|
{ title: "排序号", key: "sortNo", width: 100, align: "center", render: (h: any, { row }: any) => h("span", row.sortNo ?? "-") },
|
||||||
|
// UserBase 恒文字链;3 动作 ≤ maxInline+1 → 全直出(与改前形态一致),列宽 220 → 180
|
||||||
|
actionColumn([
|
||||||
|
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => handleEdit(r) },
|
||||||
{
|
{
|
||||||
title: "操作",
|
key: "add",
|
||||||
key: "action",
|
label: "添加子模块",
|
||||||
width: 220,
|
icon: "icon-tianjia",
|
||||||
align: "center",
|
on: (r: any) => handleAddChild(r),
|
||||||
render: (h: any, { row }: any) => {
|
when: (r: any) => r.level < 5,
|
||||||
const actions: any[] = [
|
|
||||||
$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row)),
|
|
||||||
$utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row)),
|
|
||||||
];
|
|
||||||
if (row.level < 5) {
|
|
||||||
actions.splice(1, 0, $utils.operatingTextHandle(h, '添加子模块', '', () => handleAddChild(row)));
|
|
||||||
}
|
|
||||||
return h("div", { style: { display: "flex", justifyContent: "center", gap: "4px" } }, actions);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
{ key: "del", label: "删除", icon: "icon-ashbin", on: (r: any) => handleDelete(r), danger: true },
|
||||||
|
], { mode: "text", width: 180 }),
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ---------------- 弹窗状态 ---------------- */
|
/* ---------------- 弹窗状态 ---------------- */
|
||||||
@@ -123,6 +139,7 @@ const form = ref<{
|
|||||||
id: number | null;
|
id: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
icon: string;
|
||||||
customId: number | null;
|
customId: number | null;
|
||||||
parentId: number | null;
|
parentId: number | null;
|
||||||
sortNo: number | null;
|
sortNo: number | null;
|
||||||
@@ -130,18 +147,22 @@ const form = ref<{
|
|||||||
id: null,
|
id: null,
|
||||||
name: '',
|
name: '',
|
||||||
path: '',
|
path: '',
|
||||||
|
icon: '',
|
||||||
customId: null,
|
customId: null,
|
||||||
parentId: null,
|
parentId: null,
|
||||||
sortNo: null,
|
sortNo: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ruleValidate = ref({
|
const openingIsChild = ref(false);
|
||||||
|
|
||||||
|
/* 子模块双定位:树节点(带路径/编码)或纯按钮权限(两者皆可空)→ 仅根模块必填 */
|
||||||
|
const ruleValidate = computed(() => ({
|
||||||
name: [{ required: true, message: "请输入模块名称", trigger: "blur" }],
|
name: [{ required: true, message: "请输入模块名称", trigger: "blur" }],
|
||||||
|
...(openingIsChild.value ? {} : {
|
||||||
path: [{ required: true, message: "请输入模块路径", trigger: "blur" }],
|
path: [{ required: true, message: "请输入模块路径", trigger: "blur" }],
|
||||||
customId: [{ required: true, type: "number", message: "请输入自定义ID", trigger: "change" }],
|
customId: [{ required: true, type: "number", message: "请输入自定义ID", trigger: "change" }],
|
||||||
});
|
}),
|
||||||
|
}));
|
||||||
const openingIsChild = ref(false);
|
|
||||||
const modalTitle = computed(() => {
|
const modalTitle = computed(() => {
|
||||||
if (currentId.value != null) return '编辑模块';
|
if (currentId.value != null) return '编辑模块';
|
||||||
return openingIsChild.value ? '新增子模块' : '新增模块';
|
return openingIsChild.value ? '新增子模块' : '新增模块';
|
||||||
@@ -177,6 +198,7 @@ function openEditModal(item: any) {
|
|||||||
f.id = item?.id ?? null;
|
f.id = item?.id ?? null;
|
||||||
f.name = item?.name || '';
|
f.name = item?.name || '';
|
||||||
f.path = item?.path || '';
|
f.path = item?.path || '';
|
||||||
|
f.icon = item?.icon || '';
|
||||||
f.customId = item?.customId != null ? Number(item.customId) : null;
|
f.customId = item?.customId != null ? Number(item.customId) : null;
|
||||||
f.parentId = item?.parentId ?? null;
|
f.parentId = item?.parentId ?? null;
|
||||||
f.sortNo = item?.sortNo != null ? Number(item.sortNo) : null;
|
f.sortNo = item?.sortNo != null ? Number(item.sortNo) : null;
|
||||||
@@ -189,13 +211,13 @@ function openEditModal(item: any) {
|
|||||||
/* ---------------- 新增模块 ---------------- */
|
/* ---------------- 新增模块 ---------------- */
|
||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
openingIsChild.value = false;
|
openingIsChild.value = false;
|
||||||
openEditModal({ id: null, name: '', path: '', customId: null, parentId: null, sortNo: null });
|
openEditModal({ id: null, name: '', path: '', icon: '', customId: null, parentId: null, sortNo: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 新增子模块 ---------------- */
|
/* ---------------- 新增子模块 ---------------- */
|
||||||
function handleAddChild(parent: any) {
|
function handleAddChild(parent: any) {
|
||||||
openingIsChild.value = true;
|
openingIsChild.value = true;
|
||||||
openEditModal({ id: null, name: '', path: '', customId: null, parentId: parent.id, sortNo: null });
|
openEditModal({ id: null, name: '', path: '', icon: '', customId: null, parentId: parent.id, sortNo: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 编辑 ---------------- */
|
/* ---------------- 编辑 ---------------- */
|
||||||
@@ -239,7 +261,8 @@ async function onRawSave() {
|
|||||||
data: {
|
data: {
|
||||||
appId: appId.value,
|
appId: appId.value,
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
path: form.value.path,
|
path: form.value.path?.trim() || undefined,
|
||||||
|
icon: form.value.icon?.trim() || undefined,
|
||||||
customId: form.value.customId == null ? '' : String(form.value.customId),
|
customId: form.value.customId == null ? '' : String(form.value.customId),
|
||||||
parentId: form.value.parentId ?? undefined,
|
parentId: form.value.parentId ?? undefined,
|
||||||
sortNo: form.value.sortNo == null ? undefined : Number(form.value.sortNo),
|
sortNo: form.value.sortNo == null ? undefined : Number(form.value.sortNo),
|
||||||
@@ -252,7 +275,9 @@ async function onRawSave() {
|
|||||||
id: currentId.value,
|
id: currentId.value,
|
||||||
appId: appId.value,
|
appId: appId.value,
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
path: form.value.path,
|
path: form.value.path?.trim() || undefined,
|
||||||
|
// 后端 edit 逐字段整体赋值,字段缺省即置空 → 清空图标/路径能生效
|
||||||
|
icon: form.value.icon?.trim() || undefined,
|
||||||
customId: form.value.customId == null ? '' : String(form.value.customId),
|
customId: form.value.customId == null ? '' : String(form.value.customId),
|
||||||
parentId: form.value.parentId ?? undefined,
|
parentId: form.value.parentId ?? undefined,
|
||||||
sortNo: form.value.sortNo == null ? undefined : Number(form.value.sortNo),
|
sortNo: form.value.sortNo == null ? undefined : Number(form.value.sortNo),
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<!-- 自定义部门卡片(已被 CustomDeptPanel.vue 的表格取代,2026-10-23)。保留文件仅作参考,无任何引用,可安全删除。 -->
|
||||||
|
<template>
|
||||||
|
<article class="cdept-card" :class="{ 'is-off': !dept.enabled }">
|
||||||
|
<div class="row-1">
|
||||||
|
<span class="status-dot" :class="{ 'is-off': !dept.enabled }"></span>
|
||||||
|
<h3 class="card-name" :title="dept.name">{{ dept.name }}</h3>
|
||||||
|
<span class="sw" :class="{ 'is-off': !dept.enabled }" :title="dept.enabled ? '点击停用' : '点击启用'"
|
||||||
|
@click="emit('toggle', dept)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="row-2">
|
||||||
|
<span class="sub">{{ memberText }}</span>
|
||||||
|
<span class="ops">
|
||||||
|
<i class="op" :class="{ 'is-disabled': !canMoveUp }" title="上移" @click="emit('moveUp')">↑</i>
|
||||||
|
<i class="op" :class="{ 'is-disabled': !canMoveDown }" title="下移" @click="emit('moveDown')">↓</i>
|
||||||
|
<a class="link" @click="emit('members', dept)">成员</a>
|
||||||
|
<a class="link" @click="emit('edit', dept)">编辑</a>
|
||||||
|
<a class="link danger" @click="emit('remove', dept)">删除</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { CustomDepartmentVo } from "@/api/globals";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CUSTOM 部门卡片:与 SYSTEM 的 DeptCard 同款外观,但字段来自 ub_custom_department。
|
||||||
|
* 停用在管理端照常可见(灰态),软删的部门由后端列表过滤、不会走到这里。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
dept: CustomDepartmentVo;
|
||||||
|
canMoveUp?: boolean;
|
||||||
|
canMoveDown?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "toggle", dept: CustomDepartmentVo): void;
|
||||||
|
(e: "edit", dept: CustomDepartmentVo): void;
|
||||||
|
(e: "remove", dept: CustomDepartmentVo): void;
|
||||||
|
(e: "members", dept: CustomDepartmentVo): void;
|
||||||
|
(e: "moveUp"): void;
|
||||||
|
(e: "moveDown"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const memberText = computed(() => {
|
||||||
|
const count = Number(props.dept?.memberCount ?? 0);
|
||||||
|
if (!props.dept?.enabled) return count ? `已停用 · ${count} 人` : "已停用 · 无成员";
|
||||||
|
return count ? `${count} 人` : "无成员(可先建部门再配人)";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.cdept-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 13px 14px;
|
||||||
|
background: var(--panel, #fff);
|
||||||
|
border: 1px solid var(--line, #e9edf5);
|
||||||
|
border-radius: var(--r, 10px);
|
||||||
|
box-shadow: 0 1px 2px rgba(30, 38, 53, .03);
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdept-card:hover {
|
||||||
|
border-color: #dbe2fb;
|
||||||
|
box-shadow: 0 6px 18px rgba(30, 38, 53, .07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdept-card.is-off {
|
||||||
|
background: #fbfcfd;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
flex: 0 0 6px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green, #20a162);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.is-off {
|
||||||
|
background: #cbd2df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text, #1e2635);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdept-card.is-off .card-name {
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 26px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 9px;
|
||||||
|
border-top: 1px dashed var(--line-2, #f0f3f9);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-2 .sub {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: var(--brand, #577afb);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link.danger {
|
||||||
|
color: #e54545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.op {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-2, #5a6478);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.op:hover {
|
||||||
|
background: #e8ecfa;
|
||||||
|
color: var(--brand, #577afb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.op.is-disabled {
|
||||||
|
color: #d3d9e6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 启停开关(与 SYSTEM 卡片同一套视觉) */
|
||||||
|
.sw {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--brand, #577afb);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 18px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(30, 38, 53, .22);
|
||||||
|
transition: left .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw.is-off {
|
||||||
|
background: #d3d9e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw.is-off::after {
|
||||||
|
left: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,485 @@
|
|||||||
|
<template>
|
||||||
|
<Modal v-model="innerVisible" :title="title" width="760" :mask-closable="false"
|
||||||
|
@on-visible-change="onVisibleChange">
|
||||||
|
<div class="member-body">
|
||||||
|
<!-- 左:全部人员(本校教职工,服务端已排除学生组与在册成员) -->
|
||||||
|
<section class="pane">
|
||||||
|
<div class="pane-head">
|
||||||
|
<Checkbox :model-value="leftAllChecked" :indeterminate="leftIndeterminate"
|
||||||
|
@on-change="(v: any) => toggleAll(leftRows, leftChecked, !!v)" />
|
||||||
|
<span class="pane-title">全部人员</span>
|
||||||
|
<span class="pane-sub">{{ leftCheckedCount }}/{{ leftRows.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pane-filter">
|
||||||
|
<Input v-model="kwLeft" size="small" placeholder="请输入搜索内容" clearable search
|
||||||
|
@on-search="loadCandidates" @on-change="onKwLeftChange" />
|
||||||
|
</div>
|
||||||
|
<div v-if="loadingCandidates" class="pane-tip">加载中…</div>
|
||||||
|
<div v-else-if="!leftRows.length" class="pane-tip">
|
||||||
|
{{ kwLeft ? '没有匹配的人员' : '没有可添加的教职工' }}
|
||||||
|
</div>
|
||||||
|
<ul v-else class="pane-list">
|
||||||
|
<li v-for="u in leftRows" :key="u.userId" class="row" @dblclick="moveRightOne(u)">
|
||||||
|
<Checkbox :model-value="isChecked(leftChecked, u)" @on-change="(v: any) => toggleOne(leftChecked, u, !!v)">
|
||||||
|
<span class="row-name">{{ u.name || u.account }}</span>
|
||||||
|
<span class="row-sub">{{ u.account }}</span>
|
||||||
|
</Checkbox>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-if="candidates.length >= CANDIDATE_PAGE_HINT" class="pane-foot">
|
||||||
|
仅显示前 {{ CANDIDATE_PAGE_HINT }} 条,可输入关键字缩小范围。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 中:加入 / 移出 -->
|
||||||
|
<div class="mid">
|
||||||
|
<Button size="small" class="mid-btn" :disabled="!rightCheckedCount" title="移出成员"
|
||||||
|
@click="moveLeft()"><</Button>
|
||||||
|
<Button size="small" type="primary" class="mid-btn" :disabled="!leftCheckedCount"
|
||||||
|
title="加入成员" @click="moveRight()">></Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右:已有成员 -->
|
||||||
|
<section class="pane">
|
||||||
|
<div class="pane-head">
|
||||||
|
<Checkbox :model-value="rightAllChecked" :indeterminate="rightIndeterminate"
|
||||||
|
@on-change="(v: any) => toggleAll(rightRows, rightChecked, !!v)" />
|
||||||
|
<span class="pane-title">已有成员</span>
|
||||||
|
<span class="pane-sub">{{ rightRows.length }} 人</span>
|
||||||
|
</div>
|
||||||
|
<div class="pane-filter">
|
||||||
|
<Input v-model="kwRight" size="small" placeholder="请输入搜索内容" clearable />
|
||||||
|
</div>
|
||||||
|
<div v-if="loadingMembers" class="pane-tip">加载中…</div>
|
||||||
|
<div v-else-if="!rightRows.length" class="pane-tip">
|
||||||
|
{{ kwRight ? '没有匹配的成员' : '该部门还没有成员' }}
|
||||||
|
</div>
|
||||||
|
<ul v-else class="pane-list">
|
||||||
|
<li v-for="u in rightRows" :key="u.userId" class="row" @dblclick="moveLeftOne(u)">
|
||||||
|
<Checkbox :model-value="isChecked(rightChecked, u)" @on-change="(v: any) => toggleOne(rightChecked, u, !!v)">
|
||||||
|
<span class="row-name">{{ u.name || u.account }}</span>
|
||||||
|
<span class="row-sub">{{ u.account }}</span>
|
||||||
|
</Checkbox>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<span v-if="pendingText" class="pending-tip">{{ pendingText }}</span>
|
||||||
|
<Button @click="innerVisible = false">取消</Button>
|
||||||
|
<Button type="primary" :loading="saving" :disabled="!hasPending" @click="save">保存</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
import Apis from "@/api";
|
||||||
|
import type { CustomDepartmentMemberVo, CustomDepartmentVo } from "@/api/globals";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义部门成员管理弹窗(穿梭框式)。
|
||||||
|
* 左「全部人员」=本校教职工候选(服务端按「排除学生组 + 未在本部门」给),右「已有成员」=部门在册成员。
|
||||||
|
* 中间箭头只改本地待提交状态,点「保存」才一次性提交:先 Add 再 Remove,取消则整体丢弃。
|
||||||
|
* 候选走 {@code /api/customdept/CandidateUsers/{id}},不能用组维度的 GroupUsers(会带进学生)。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ visible: boolean; dept?: CustomDepartmentVo | null }>();
|
||||||
|
const emit = defineEmits<{ "update:visible": [boolean]; success: [] }>();
|
||||||
|
|
||||||
|
/** 后端候选上限 200,达到就提示用关键字筛选 */
|
||||||
|
const CANDIDATE_PAGE_HINT = 200;
|
||||||
|
|
||||||
|
const innerVisible = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (v) => emit("update:visible", v),
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = computed(() => `部门成员 · ${props.dept?.name || ""}`);
|
||||||
|
const deptId = computed(() => props.dept?.id ?? null);
|
||||||
|
|
||||||
|
/** 服务端数据 */
|
||||||
|
const members = ref<CustomDepartmentMemberVo[]>([]);
|
||||||
|
const candidates = ref<CustomDepartmentMemberVo[]>([]);
|
||||||
|
const loadingMembers = ref(false);
|
||||||
|
const loadingCandidates = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
/** 待提交:新增(左→右)与移出(右→左)。点保存才落库,取消整体丢弃 */
|
||||||
|
const pendingAdd = ref<CustomDepartmentMemberVo[]>([]);
|
||||||
|
const pendingRemove = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const kwLeft = ref("");
|
||||||
|
const kwRight = ref("");
|
||||||
|
const leftChecked = ref<Set<number>>(new Set());
|
||||||
|
const rightChecked = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
|
function matchKw(u: CustomDepartmentMemberVo, k: string) {
|
||||||
|
return (
|
||||||
|
String(u.name || "").toLowerCase().includes(k) ||
|
||||||
|
String(u.account || "").toLowerCase().includes(k)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 左列表:候选池去掉待加入的,再补上「待移出的成员」——
|
||||||
|
* 服务端候选池天然不含在册成员,若不补回来,移出后想反悔就找不到了。
|
||||||
|
*/
|
||||||
|
const leftRows = computed<CustomDepartmentMemberVo[]>(() => {
|
||||||
|
const k = kwLeft.value.trim().toLowerCase();
|
||||||
|
const hit = (u: CustomDepartmentMemberVo) => !k || matchKw(u, k);
|
||||||
|
const back = members.value.filter((m) => pendingRemove.value.has(m.userId as number) && hit(m));
|
||||||
|
const pool = candidates.value.filter(
|
||||||
|
(c) => !pendingRemove.value.has(c.userId as number) && !isPendingAdd(c.userId as number) && hit(c)
|
||||||
|
);
|
||||||
|
return [...back, ...pool];
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 右列表:在册成员去掉待移出的,加上待加入的 */
|
||||||
|
const rightRows = computed<CustomDepartmentMemberVo[]>(() => {
|
||||||
|
const k = kwRight.value.trim().toLowerCase();
|
||||||
|
const hit = (u: CustomDepartmentMemberVo) => !k || matchKw(u, k);
|
||||||
|
const held = members.value.filter((m) => !pendingRemove.value.has(m.userId as number));
|
||||||
|
return [...held, ...pendingAdd.value].filter(hit);
|
||||||
|
});
|
||||||
|
|
||||||
|
function isPendingAdd(userId: number) {
|
||||||
|
return pendingAdd.value.some((u) => u.userId === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPending = computed(() => pendingAdd.value.length > 0 || pendingRemove.value.size > 0);
|
||||||
|
const pendingText = computed(() => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (pendingAdd.value.length) parts.push(`新增 ${pendingAdd.value.length} 人`);
|
||||||
|
if (pendingRemove.value.size) parts.push(`移出 ${pendingRemove.value.size} 人`);
|
||||||
|
return parts.join(" · ");
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ================= 勾选 ================= */
|
||||||
|
|
||||||
|
function isChecked(box: Set<number>, u: CustomDepartmentMemberVo) {
|
||||||
|
return box.has(u.userId as number);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOne(box: Set<number>, u: CustomDepartmentMemberVo, checked: boolean) {
|
||||||
|
const userId = u.userId as number;
|
||||||
|
if (checked) box.add(userId);
|
||||||
|
else box.delete(userId);
|
||||||
|
// Set 自身不是响应式依赖,换一个实例触发重算
|
||||||
|
if (box === leftChecked.value) leftChecked.value = new Set(box);
|
||||||
|
else rightChecked.value = new Set(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveRightOne(u: CustomDepartmentMemberVo) {
|
||||||
|
if (u.userId) moveRight([u.userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveLeftOne(u: CustomDepartmentMemberVo) {
|
||||||
|
if (u.userId) moveLeft([u.userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(rows: CustomDepartmentMemberVo[], box: Set<number>, checked: boolean) {
|
||||||
|
const ids = rows.map((u) => u.userId as number);
|
||||||
|
const next = new Set(box);
|
||||||
|
ids.forEach((id) => (checked ? next.add(id) : next.delete(id)));
|
||||||
|
if (box === leftChecked.value) leftChecked.value = next;
|
||||||
|
else rightChecked.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全选 / 半选一律按「当前可见行」判断:关键字筛选后仍被勾着的行不算数 */
|
||||||
|
const leftCheckedCount = computed(
|
||||||
|
() => leftRows.value.filter((u) => leftChecked.value.has(u.userId as number)).length
|
||||||
|
);
|
||||||
|
const rightCheckedCount = computed(
|
||||||
|
() => rightRows.value.filter((u) => rightChecked.value.has(u.userId as number)).length
|
||||||
|
);
|
||||||
|
const leftAllChecked = computed(() => leftRows.value.length > 0 && leftCheckedCount.value >= leftRows.value.length);
|
||||||
|
const leftIndeterminate = computed(() => leftCheckedCount.value > 0 && !leftAllChecked.value);
|
||||||
|
const rightAllChecked = computed(() => rightRows.value.length > 0 && rightCheckedCount.value >= rightRows.value.length);
|
||||||
|
const rightIndeterminate = computed(() => rightCheckedCount.value > 0 && !rightAllChecked.value);
|
||||||
|
|
||||||
|
/* ================= 左右移动 ================= */
|
||||||
|
|
||||||
|
/** 加入:左侧勾选(或双击某行)→ 挂到待新增 */
|
||||||
|
function moveRight(userIds?: number[]) {
|
||||||
|
const ids = userIds ?? leftRows.value.filter((u) => leftChecked.value.has(u.userId as number)).map((u) => u.userId as number);
|
||||||
|
if (!ids.length) return;
|
||||||
|
const picked = leftRows.value.filter((u) => ids.includes(u.userId as number));
|
||||||
|
pendingAdd.value = [...pendingAdd.value, ...picked];
|
||||||
|
// 移出后再加入 = 撤销移出,不用重复 Add
|
||||||
|
const nextRemove = new Set(pendingRemove.value);
|
||||||
|
picked.forEach((u) => nextRemove.delete(u.userId as number));
|
||||||
|
pendingRemove.value = nextRemove;
|
||||||
|
leftChecked.value = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移出:右侧勾选(或双击某行)→ 撤掉待新增,或记账到待移出 */
|
||||||
|
function moveLeft(userIds?: number[]) {
|
||||||
|
const ids = userIds ?? rightRows.value.filter((u) => rightChecked.value.has(u.userId as number)).map((u) => u.userId as number);
|
||||||
|
if (!ids.length) return;
|
||||||
|
const stillPending = new Set<number>();
|
||||||
|
pendingAdd.value = pendingAdd.value.filter((u) => {
|
||||||
|
if (ids.includes(u.userId as number)) {
|
||||||
|
stillPending.add(u.userId as number);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const nextRemove = new Set(pendingRemove.value);
|
||||||
|
ids.forEach((id) => {
|
||||||
|
if (!stillPending.has(id)) nextRemove.add(id);
|
||||||
|
});
|
||||||
|
pendingRemove.value = nextRemove;
|
||||||
|
rightChecked.value = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= 数据加载 ================= */
|
||||||
|
|
||||||
|
async function loadMembers() {
|
||||||
|
if (!deptId.value) return;
|
||||||
|
loadingMembers.value = true;
|
||||||
|
try {
|
||||||
|
members.value = ((await Apis.CustomDept.customDeptMembers({
|
||||||
|
pathParams: { id: deptId.value },
|
||||||
|
cacheFor: null,
|
||||||
|
})) as CustomDepartmentMemberVo[]) || [];
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(e?.message || "加载部门成员失败");
|
||||||
|
members.value = [];
|
||||||
|
} finally {
|
||||||
|
loadingMembers.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCandidates() {
|
||||||
|
if (!deptId.value) return;
|
||||||
|
loadingCandidates.value = true;
|
||||||
|
try {
|
||||||
|
candidates.value = ((await Apis.CustomDept.customDeptCandidateUsers({
|
||||||
|
pathParams: { id: deptId.value },
|
||||||
|
params: { keyword: kwLeft.value.trim() || undefined },
|
||||||
|
cacheFor: null,
|
||||||
|
})) as CustomDepartmentMemberVo[]) || [];
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(e?.message || "加载候选教职工失败");
|
||||||
|
candidates.value = [];
|
||||||
|
} finally {
|
||||||
|
loadingCandidates.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKwLeftChange() {
|
||||||
|
// clearable 清空时也当一次搜索
|
||||||
|
if (!kwLeft.value) loadCandidates();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= 保存 ================= */
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!deptId.value || !hasPending.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (pendingAdd.value.length) {
|
||||||
|
await Apis.CustomDept.customDeptAddMembers({
|
||||||
|
pathParams: { id: deptId.value },
|
||||||
|
data: {
|
||||||
|
schoolId: props.dept?.schoolId as number,
|
||||||
|
userIds: pendingAdd.value.map((u) => u.userId as number),
|
||||||
|
},
|
||||||
|
cacheFor: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (pendingRemove.value.size) {
|
||||||
|
await Apis.CustomDept.customDeptRemoveMembers({
|
||||||
|
pathParams: { id: deptId.value },
|
||||||
|
data: { schoolId: props.dept?.schoolId as number, userIds: [...pendingRemove.value] },
|
||||||
|
cacheFor: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Message.success("已保存");
|
||||||
|
emit("success");
|
||||||
|
innerVisible.value = false;
|
||||||
|
} catch (e: any) {
|
||||||
|
// 失败提示由全局 alova 拦截器弹出,弹窗保持打开让用户重试
|
||||||
|
console.warn(e?.message || "保存成员失败");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= 生命周期 ================= */
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
members.value = [];
|
||||||
|
candidates.value = [];
|
||||||
|
pendingAdd.value = [];
|
||||||
|
pendingRemove.value = new Set();
|
||||||
|
leftChecked.value = new Set();
|
||||||
|
rightChecked.value = new Set();
|
||||||
|
kwLeft.value = "";
|
||||||
|
kwRight.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibleChange(v: boolean) {
|
||||||
|
if (!v) return;
|
||||||
|
reset();
|
||||||
|
loadMembers();
|
||||||
|
loadCandidates();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 外部直接改 visible 时同步一次
|
||||||
|
watch(() => props.visible, (v) => {
|
||||||
|
if (v) onVisibleChange(true);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.member-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
height: 430px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--line, #e2e6ef);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-head {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-bottom: 1px solid #f1f4f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, #1e2635);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-sub {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-filter {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 8px 10px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 6px 6px;
|
||||||
|
list-style: none;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-tip {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-foot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 6px 10px 8px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
border-top: 1px solid #f6f8fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f5f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ivu-checkbox-wrapper) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ivu-checkbox) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #272829;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-sub {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9aa1ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中间箭头列 */
|
||||||
|
.mid {
|
||||||
|
flex: 0 0 36px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mid-btn {
|
||||||
|
width: 32px;
|
||||||
|
padding: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-tip {
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<!-- 系统部门卡片(已被 index.vue 的表格取代,2026-10-23)。保留文件仅作参考,无任何引用,可安全删除。 -->
|
||||||
|
<template>
|
||||||
|
<article class="dept-card" :class="{ 'is-off': disabled, 'has-kids': showKids }">
|
||||||
|
<div class="row-1">
|
||||||
|
<span class="status-dot" :class="{ 'is-off': disabled }"></span>
|
||||||
|
<h3 class="card-name" :title="dept.name">{{ dept.name }}</h3>
|
||||||
|
<span class="sw" :class="{ 'is-off': disabled }" title="启用 / 停用" @click.stop="emit('toggle', dept)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="row-2">
|
||||||
|
<span class="sub">{{ subText }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="showKids" class="kid-list">
|
||||||
|
<DeptCard v-for="kid in kids" :key="kid.id" :dept="kid" :level="level + 1"
|
||||||
|
:include-children="includeChildren" @toggle="onKidToggle" />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
/** 部门口径:教师组下挂的都是部门,只有学生组(type=8)不算部门 */
|
||||||
|
const STUDENT_GROUP_TYPE = 8;
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
dept: any;
|
||||||
|
/** 层级深度:0 = 一级部门,>0 = 下级部门 */
|
||||||
|
level?: number;
|
||||||
|
/** 是否包含下级部门(对应学校上的「包含子组」开关) */
|
||||||
|
includeChildren?: boolean;
|
||||||
|
}>(), {
|
||||||
|
level: 0,
|
||||||
|
includeChildren: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: "toggle", dept: any): void }>();
|
||||||
|
|
||||||
|
const onKidToggle = (dept: any) => emit("toggle", dept);
|
||||||
|
|
||||||
|
const kids = computed<any[]>(() => props.dept?.children || []);
|
||||||
|
const showKids = computed(() => props.includeChildren !== false && kids.value.length > 0);
|
||||||
|
const disabled = computed(() => props.dept?.status === 1);
|
||||||
|
|
||||||
|
const subText = computed(() => {
|
||||||
|
if (props.dept?.type === STUDENT_GROUP_TYPE) return "学生组 · 非部门";
|
||||||
|
if (props.level > 0) return "下级部门";
|
||||||
|
return kids.value.length ? `${kids.value.length} 个下级部门` : "无下级部门";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.dept-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 13px 14px;
|
||||||
|
background: var(--panel, #fff);
|
||||||
|
border: 1px solid var(--line, #e9edf5);
|
||||||
|
border-radius: var(--r, 10px);
|
||||||
|
box-shadow: 0 1px 2px rgba(30, 38, 53, .03);
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-card:hover {
|
||||||
|
border-color: #dbe2fb;
|
||||||
|
box-shadow: 0 6px 18px rgba(30, 38, 53, .07);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 有下级部门:跨整行成组,避免层级被压平 */
|
||||||
|
.dept-card.has-kids {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-card.is-off {
|
||||||
|
background: #fbfcfd;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
flex: 0 0 6px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green, #20a162);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.is-off {
|
||||||
|
background: #cbd2df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text, #1e2635);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-card.is-off .card-name {
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 26px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 9px;
|
||||||
|
border-top: 1px dashed var(--line-2, #f0f3f9);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-2 .sub {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 启停开关 */
|
||||||
|
.sw {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--brand, #577afb);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 18px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(30, 38, 53, .22);
|
||||||
|
transition: left .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw.is-off {
|
||||||
|
background: #d3d9e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw.is-off::after {
|
||||||
|
left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下级部门区 */
|
||||||
|
.kid-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 11px;
|
||||||
|
padding-top: 11px;
|
||||||
|
border-top: 1px dashed var(--line-2, #f0f3f9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kid-list :deep(.dept-card) {
|
||||||
|
flex: 1 1 168px;
|
||||||
|
min-width: 150px;
|
||||||
|
background: #fafbfe;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kid-list :deep(.dept-card:hover) {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kid-list :deep(.dept-card .card-name) {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下级部门里若还有下级,占满一行表达更深层级 */
|
||||||
|
.kid-list :deep(.dept-card.has-kids) {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,771 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrapper tree-wrapper dept-manage-page">
|
||||||
|
<div v-if="!roleReady" class="dept-loading">加载中…</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 左侧:zTree 组树(超管 / 局端;只到学校层) -->
|
||||||
|
<div v-if="showTree" class="tree-container dept-manage-tree">
|
||||||
|
<GroupTreeSearchPanel class="tree-inner" ref="refTreePanel" v-model:curTreeNode="selectedNode"
|
||||||
|
:treeApi="treeApi" :defaultSearchVisible="true" @on-change="onTreeChange" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:部门表格(白卡由主题的 .main-container 提供) -->
|
||||||
|
<div class="main-container dept-manage-main">
|
||||||
|
<div class="panel-title">
|
||||||
|
<span class="panel-caption">部门管理</span>
|
||||||
|
<Tag v-if="currentSchoolName" color="primary" class="ml-8">{{ currentSchoolName }}</Tag>
|
||||||
|
<Select v-if="multiSchool" v-model="currentSchoolId" size="small" class="ml-8 school-select">
|
||||||
|
<Option v-for="s in schoolOptions" :key="s.id" :value="s.id">{{ s.name }}</Option>
|
||||||
|
</Select>
|
||||||
|
<!-- 来源切换:只切右侧管理视图,学校上下文共用,不改底层组织树 -->
|
||||||
|
<div class="title-tail">
|
||||||
|
<span v-if="source === 'SYSTEM'" class="inc-ctrl" title="打开后列出该校下所有层级的部门"
|
||||||
|
@click="toggleInclude">
|
||||||
|
<span class="inc-label">包含子组</span>
|
||||||
|
<i class="sw-mini" :class="{ 'is-on': includeOn }"></i>
|
||||||
|
</span>
|
||||||
|
<div class="source-tabs">
|
||||||
|
<span class="source-tab" :class="{ 'is-active': source === 'SYSTEM' }"
|
||||||
|
@click="source = 'SYSTEM'">系统部门</span>
|
||||||
|
<span class="source-tab" :class="{ 'is-active': source === 'CUSTOM' }"
|
||||||
|
@click="source = 'CUSTOM'">自定义部门</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="source === 'SYSTEM'">
|
||||||
|
<Alert show-icon type="success" class="usage-note">
|
||||||
|
<b>设置说明:</b>
|
||||||
|
停用只影响「选」(新建事项 / 流程绑定时选不到),不影响「看」(已有事项展示)与「办」(在途流程照常解析);
|
||||||
|
关闭「包含子组」只展示一级部门,打开后连同下级部门一并列出。
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Alert v-if="!currentSchoolName" show-icon type="info" class="state-tip">
|
||||||
|
{{ showTree ? '请在左侧选择学校节点' : '当前账号没有可管理的学校' }}
|
||||||
|
</Alert>
|
||||||
|
<Alert v-else-if="!deptRows.length" show-icon type="info" class="state-tip">
|
||||||
|
该校教师组下暂无部门。部门 = 教师组的直接子节点,可前往「组,用户管理」建组。
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="row-sum">{{ deptRows.length }} 个一级部门 · 共 {{ flatRows.length }} 行</div>
|
||||||
|
<Table class="dept-itable" border :columns="sysColumns" :data="flatRows"
|
||||||
|
:row-class-name="rowClassName" :loading="false" no-data-text="暂无部门">
|
||||||
|
<template #name="{ row }">
|
||||||
|
<span class="nmcell" :style="{ paddingLeft: row.level * 20 + 'px' }">
|
||||||
|
<i v-if="row._kids.length" class="tw" @click.stop="toggleRow(row.id)">{{ row.open ? '▾' : '▸' }}</i>
|
||||||
|
<i v-else class="tw blank"></i>
|
||||||
|
<span class="nm" :class="{ root: row.level === 0 }" :title="row.name">{{ row.name }}</span>
|
||||||
|
<span v-if="row.kidCount" class="cnt">{{ row.kidCount }} 个下级</span>
|
||||||
|
<span v-if="row.isStudent" class="cnt grey">学生组</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #members="{ row }">
|
||||||
|
<Tooltip v-if="row.memberCount" placement="top" max-width="360" :content="memberTip(row)">
|
||||||
|
<span class="mem" :class="memberClickable(row) ? 'mem-cell' : 'mem-plain'"
|
||||||
|
@click="onMemberClick(row)">
|
||||||
|
<span class="names">{{ memberHead(row) }}</span>
|
||||||
|
<span v-if="memberRest(row)" class="more">+{{ memberRest(row) }}</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<span v-else class="dim">无成员</span>
|
||||||
|
</template>
|
||||||
|
<template #status="{ row }">
|
||||||
|
<i class="sw-mini" :class="{ 'is-on': row.status !== 1 }"
|
||||||
|
:title="row.status === 1 ? '点击启用' : '点击停用'"
|
||||||
|
@click="onToggle(row.raw)"></i>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
<div class="table-foot">共 {{ flatRows.length }} 个部门</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<CustomDeptPanel v-else :school-id="currentSchoolId" />
|
||||||
|
|
||||||
|
<!-- 系统部门人员:预览之外看全部,走 /api/sysuser/List?groupId= -->
|
||||||
|
<Modal v-model="memModal.visible" :title="`部门人员 · ${memModal.deptName}`" width="560"
|
||||||
|
:mask-closable="true" footer-hide>
|
||||||
|
<div class="mem-modal-body">
|
||||||
|
<div v-if="memModal.loading" class="mem-empty">加载中…</div>
|
||||||
|
<div v-else-if="!memModal.rows.length" class="mem-empty">该部门暂无直属成员</div>
|
||||||
|
<ul v-else class="mem-list">
|
||||||
|
<li v-for="u in memModal.rows" :key="u.id">
|
||||||
|
<span class="mname">{{ u.realName || u.account }}</span>
|
||||||
|
<span class="macc">{{ u.account }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div v-if="memModal.total > memModal.pageSize" class="mem-pager">
|
||||||
|
<Page :current="memModal.page" :total="memModal.total" :page-size="memModal.pageSize"
|
||||||
|
size="small" show-total @on-change="onMemPageChange" />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
|
import { cloneDeep } from "lodash";
|
||||||
|
import Apis from "@/api";
|
||||||
|
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
|
||||||
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
|
import CustomDeptPanel from "./CustomDeptPanel.vue";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
import { useDefaultStore } from "@/store";
|
||||||
|
import { UserRoleEnum } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部门管理(2026-10-23 表格版)。
|
||||||
|
* 口径:部门 = 教师组(type=7)的直接子节点,下可再挂子部门(只有教师组下才可能有多级)。
|
||||||
|
* 视角:超管 / 局端 → 左侧 zTree 树只到「学校」层,右侧是该学校的部门表格;
|
||||||
|
* 学校管理员 → 无树,直接列出本校部门(多校时顶部下拉切换)。
|
||||||
|
* 表格:层级用缩进 + 折叠表达;不提供新增 / 排序 / 行内操作,只保留启停与「包含子组」。
|
||||||
|
* 人员:getTree 已带 memberCount + memberNames(前 50),超出走 /api/sysuser/List 看全部。
|
||||||
|
*/
|
||||||
|
const TEACHER_GROUP_TYPE = 7;
|
||||||
|
const STUDENT_GROUP_TYPE = 8;
|
||||||
|
const SCHOOL_GROUP_TYPE = 2;
|
||||||
|
|
||||||
|
const store = useDefaultStore();
|
||||||
|
const isSchoolMode = computed(() => store.userRole === UserRoleEnum.School);
|
||||||
|
/** 超管 / 局端才需要左侧树 */
|
||||||
|
const showTree = computed(() => !isSchoolMode.value);
|
||||||
|
/** 角色未落定前不渲染,避免先按默认角色取数再纠正 */
|
||||||
|
const roleReady = computed(() => store.roleLoaded);
|
||||||
|
|
||||||
|
const refTreePanel = ref<InstanceType<typeof GroupTreeSearchPanel>>();
|
||||||
|
const selectedNode = ref<any>({});
|
||||||
|
/** 部门来源页签:SYSTEM=原组织树视图(行为零改动);CUSTOM=学校自建分类/部门 */
|
||||||
|
const source = ref<"SYSTEM" | "CUSTOM">("SYSTEM");
|
||||||
|
/** 全量组树:右侧部门数据一律从这里按 id 回查真实 children */
|
||||||
|
const fullTree = ref<any[]>([]);
|
||||||
|
/** 当前学校:超管由树选中,学校管理员默认取可管理学校 */
|
||||||
|
const currentSchoolId = ref<number | undefined>(undefined);
|
||||||
|
const schoolIds = computed<number[]>(() => store.userScope?.schoolGroupIds ?? []);
|
||||||
|
const multiSchool = computed(() => isSchoolMode.value && schoolIds.value.length > 1);
|
||||||
|
|
||||||
|
const schoolOptions = computed(() => schoolIds.value.map((id) => ({
|
||||||
|
id,
|
||||||
|
name: findNodeById(fullTree.value, id)?.name ?? String(id),
|
||||||
|
})));
|
||||||
|
|
||||||
|
const currentSchool = computed(() => findNodeById(fullTree.value, currentSchoolId.value));
|
||||||
|
const currentSchoolName = computed(() => currentSchool.value?.name || "");
|
||||||
|
|
||||||
|
/** 折叠状态:默认全展开,只记「被收起的 id」 */
|
||||||
|
const closedIds = ref<Set<number>>(new Set());
|
||||||
|
function toggleRow(id: number) {
|
||||||
|
const next = new Set(closedIds.value);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
closedIds.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeptRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
parentName: string;
|
||||||
|
level: number;
|
||||||
|
isStudent: boolean;
|
||||||
|
memberCount: number;
|
||||||
|
memberNames: string[];
|
||||||
|
kidCount: number;
|
||||||
|
open: boolean;
|
||||||
|
no: number;
|
||||||
|
raw: any;
|
||||||
|
/**
|
||||||
|
* 子级只给自己用。
|
||||||
|
* ⚠️ 绝不能叫 children:iView Table 发现行上有 children 就切「树形数据模式」(还要求 row-key),
|
||||||
|
* 会和这里自画的缩进 + 折叠打架,表现是整表空白(关掉包含子组后 children 为空数组才恢复)。
|
||||||
|
*/
|
||||||
|
_kids: DeptRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表格树:一级部门来自各教师组下的直接子节点,子级递归;「包含子组」关闭时不带子级 */
|
||||||
|
const deptRows = computed<DeptRow[]>(() => {
|
||||||
|
const school = currentSchool.value;
|
||||||
|
if (!school) return [];
|
||||||
|
const rows: DeptRow[] = [];
|
||||||
|
(school.children || [])
|
||||||
|
.filter((c: any) => c.type === TEACHER_GROUP_TYPE)
|
||||||
|
.forEach((t: any) => {
|
||||||
|
(t.children || []).forEach((d: any) => rows.push(toRow(d, t.name, 0)));
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toRow(d: any, parentName: string, level: number): DeptRow {
|
||||||
|
const kids: any[] = d.children || [];
|
||||||
|
return {
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
status: d.status ?? 0,
|
||||||
|
parentName,
|
||||||
|
level,
|
||||||
|
isStudent: d.type === STUDENT_GROUP_TYPE,
|
||||||
|
memberCount: Number(d.memberCount ?? 0),
|
||||||
|
memberNames: Array.isArray(d.memberNames) ? d.memberNames : [],
|
||||||
|
// 下级数始终按真实层级给:关闭包含子组时只收起子行,徽标仍要能看出「这个部门有下级」
|
||||||
|
kidCount: kids.length,
|
||||||
|
open: !closedIds.value.has(d.id),
|
||||||
|
no: 0,
|
||||||
|
raw: d,
|
||||||
|
_kids: includeOn.value ? kids.map((k) => toRow(k, d.name, level + 1)) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拍平成可渲染行(按折叠状态裁剪子级),并顺序编号 */
|
||||||
|
const flatRows = computed<DeptRow[]>(() => {
|
||||||
|
const out: DeptRow[] = [];
|
||||||
|
const walk = (list: DeptRow[]) => {
|
||||||
|
list.forEach((row) => {
|
||||||
|
out.push(row);
|
||||||
|
if (row.open && row._kids.length) walk(row._kids);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
walk(deptRows.value);
|
||||||
|
out.forEach((row, i) => (row.no = i + 1));
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** iView Table 列定义:层级用「部门名称」列的缩进 + 折叠表达,不用 Table 自带树(避免样式不可控) */
|
||||||
|
const sysColumns: any[] = [
|
||||||
|
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||||
|
{ title: "部门名称", slot: "name", width: 236 },
|
||||||
|
{ title: "上级", key: "parentName", width: 130 },
|
||||||
|
{ title: "人员", slot: "members", minWidth: 200 },
|
||||||
|
{ title: "状态", slot: "status", width: 90, align: "center" },
|
||||||
|
];
|
||||||
|
/** 停用行整行灰化 */
|
||||||
|
function rowClassName(row: any) {
|
||||||
|
return row?.status === 1 ? "is-off" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人员列:最多平铺 10 个姓名,超出用「+N」提示;已全部显示(≤10 人)时不可点 */
|
||||||
|
const MEMBER_PREVIEW = 10;
|
||||||
|
function memberHead(row: DeptRow) {
|
||||||
|
// 姓名预览还没回来(后端未升级 / 未重启)时先退回人数,避免出现空单元格
|
||||||
|
if (!row.memberNames.length) return `${row.memberCount} 人`;
|
||||||
|
return row.memberNames.slice(0, MEMBER_PREVIEW).join("、");
|
||||||
|
}
|
||||||
|
function memberRest(row: DeptRow) {
|
||||||
|
if (!row.memberNames.length) return 0;
|
||||||
|
return Math.max(0, row.memberCount - Math.min(MEMBER_PREVIEW, row.memberNames.length));
|
||||||
|
}
|
||||||
|
/** 只有列不完时才值得点开弹窗(全在表格里看完了,弹窗没有增量信息) */
|
||||||
|
function memberClickable(row: DeptRow) {
|
||||||
|
return row.memberCount > MEMBER_PREVIEW;
|
||||||
|
}
|
||||||
|
function onMemberClick(row: DeptRow) {
|
||||||
|
if (memberClickable(row)) openMembers(row);
|
||||||
|
}
|
||||||
|
function memberTip(row: DeptRow) {
|
||||||
|
if (!row.memberNames.length) return `共 ${row.memberCount} 人 点击查看全部`;
|
||||||
|
const list = row.memberNames.join("、");
|
||||||
|
const more = row.memberCount > row.memberNames.length ? `…(仅预览前 ${row.memberNames.length} 人)` : "";
|
||||||
|
return `共 ${row.memberCount} 人:${list}${more}${memberClickable(row) ? " 点击查看全部" : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= 人员弹窗(查看全部,分页) ================= */
|
||||||
|
const memModal = reactive({
|
||||||
|
visible: false,
|
||||||
|
loading: false,
|
||||||
|
deptId: 0,
|
||||||
|
deptName: "",
|
||||||
|
rows: [] as any[],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
function openMembers(row: DeptRow) {
|
||||||
|
Object.assign(memModal, {
|
||||||
|
visible: true, loading: true, deptId: row.id, deptName: row.name,
|
||||||
|
rows: [], total: 0, page: 1,
|
||||||
|
});
|
||||||
|
loadMembers();
|
||||||
|
}
|
||||||
|
function onMemPageChange(page: number) {
|
||||||
|
memModal.page = page;
|
||||||
|
loadMembers();
|
||||||
|
}
|
||||||
|
async function loadMembers() {
|
||||||
|
memModal.loading = true;
|
||||||
|
try {
|
||||||
|
const res: any = await Apis.SysUser.list({
|
||||||
|
params: { groupId: memModal.deptId, pageIndex: memModal.page, pageSize: memModal.pageSize },
|
||||||
|
cacheFor: null,
|
||||||
|
});
|
||||||
|
memModal.rows = res?.content ?? [];
|
||||||
|
memModal.total = Number(res?.total ?? 0);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(e?.message || "加载部门人员失败");
|
||||||
|
memModal.rows = [];
|
||||||
|
memModal.total = 0;
|
||||||
|
} finally {
|
||||||
|
memModal.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前学校的「包含子组」:读学校节点上的 includeChildren(1=含子组 / 0=仅本级)。
|
||||||
|
* 历史数据或旧库该列为 null 时按「含子组」兜底,与升级前表现一致。
|
||||||
|
*/
|
||||||
|
const includeOn = computed(() => {
|
||||||
|
const school = currentSchool.value;
|
||||||
|
return school ? school.includeChildren !== 0 : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ================= 数据加载 ================= */
|
||||||
|
|
||||||
|
/** 树数据源:取全量树存起来给右侧用,树上只渲染到学校层 */
|
||||||
|
async function treeApi() {
|
||||||
|
const full: any[] = (await Apis.SysGroup.getTree()) || [];
|
||||||
|
fullTree.value = cloneDeep(full);
|
||||||
|
return pruneToSchoolLevel(full);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 剪枝:学校节点保留但不再下钻(超管视角树只到学校层) */
|
||||||
|
function pruneToSchoolLevel(list: any[]): any[] {
|
||||||
|
return (list || []).map((n: any) => {
|
||||||
|
const node: any = { ...n };
|
||||||
|
if (node.type === SCHOOL_GROUP_TYPE) {
|
||||||
|
delete node.children;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.children) && node.children.length) {
|
||||||
|
node.children = pruneToSchoolLevel(node.children);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNodeById(list: any[], id?: number | null): any {
|
||||||
|
if (id == null) return null;
|
||||||
|
for (const n of list || []) {
|
||||||
|
if (Number(n.id) === Number(id)) return n;
|
||||||
|
const hit = findNodeById(n.children || [], id);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= 交互 ================= */
|
||||||
|
|
||||||
|
function onTreeChange(node: any) {
|
||||||
|
selectedNode.value = node || {};
|
||||||
|
if (node?.type === SCHOOL_GROUP_TYPE) {
|
||||||
|
currentSchoolId.value = node.id;
|
||||||
|
closedIds.value = new Set();
|
||||||
|
} else if (node?.id) {
|
||||||
|
// 点到中间层(区域 / 学校组等)不作为当前学校
|
||||||
|
currentSchoolId.value = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换「包含子组」:落库到学校节点,成功后就地更新,避免整树重绘丢失选中态 */
|
||||||
|
async function toggleInclude() {
|
||||||
|
const school = currentSchool.value;
|
||||||
|
if (!school?.id) return;
|
||||||
|
const next = !includeOn.value;
|
||||||
|
try {
|
||||||
|
await Apis.SysGroup.setIncludeChildren({
|
||||||
|
pathParams: { id: school.id },
|
||||||
|
data: { includeChildren: next },
|
||||||
|
});
|
||||||
|
school.includeChildren = next ? 1 : 0;
|
||||||
|
closedIds.value = new Set();
|
||||||
|
Message.success(next ? "已包含下级部门" : "仅显示一级部门");
|
||||||
|
} catch (e: any) {
|
||||||
|
// 全局 alova 拦截器(alova-iview 的 onBizError/onHttpError)已统一 Message.error,此处只记日志避免二次弹窗;
|
||||||
|
// 本地字段只在写成功后改,失败即自然停在原值,无需回滚
|
||||||
|
console.warn(e?.message || "设置包含子组失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggle(dept: any) {
|
||||||
|
if (!dept?.id) return;
|
||||||
|
if (dept.status === 1) {
|
||||||
|
doEnable(dept);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MyConfirm({
|
||||||
|
title: "停用部门",
|
||||||
|
msg: `确定停用部门【${dept.name}】?`,
|
||||||
|
info: "停用只影响「选」(新建时选不到),不影响「看」和「办」(已有事项展示、在途流程照常解析)。",
|
||||||
|
async onOk() {
|
||||||
|
await doDisable(dept);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDisable(dept: any) {
|
||||||
|
try {
|
||||||
|
await Apis.SysGroup.disable({ pathParams: { id: dept.id } });
|
||||||
|
Message.success("已停用");
|
||||||
|
await syncTreeData();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(e?.message || "停用失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doEnable(dept: any) {
|
||||||
|
try {
|
||||||
|
await Apis.SysGroup.enable({ pathParams: { id: dept.id } });
|
||||||
|
Message.success("已启用");
|
||||||
|
await syncTreeData();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(e?.message || "启用失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 静默同步组树数据:只替换右侧数据源,**不重建左侧 zTree**。<br>
|
||||||
|
* 启停改的是部门自身状态,而树只渲染到学校层,部门节点根本不在树上——
|
||||||
|
* 走 refreshTree 会让整棵树重绘、展开态与滚动位置一起丢失。
|
||||||
|
*/
|
||||||
|
async function syncTreeData() {
|
||||||
|
const full: any[] = (await Apis.SysGroup.getTree()) || [];
|
||||||
|
fullTree.value = cloneDeep(full);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= 初始化 ================= */
|
||||||
|
watch(() => store.roleLoaded, (loaded) => {
|
||||||
|
if (!loaded || !isSchoolMode.value) return;
|
||||||
|
loadSchoolScope();
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
/** 学校管理员:GetTree 返回的就是各学校子树(多校 = 多根) */
|
||||||
|
async function loadSchoolScope() {
|
||||||
|
const full: any[] = (await Apis.SysGroup.getTree()) || [];
|
||||||
|
fullTree.value = cloneDeep(full);
|
||||||
|
const ids = schoolIds.value;
|
||||||
|
if (ids.length) {
|
||||||
|
currentSchoolId.value = ids[0];
|
||||||
|
} else if (full.length) {
|
||||||
|
currentSchoolId.value = full[0]?.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.dept-manage-page {
|
||||||
|
/* 骨架交由主题 .tree-wrapper(flex + gap 16 + padding 16)+ .main-container(白卡);
|
||||||
|
这里只声明两卡等高,间距/留白一律不覆盖 */
|
||||||
|
align-items: stretch;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
/* 树:外层不滚(滚动交给内部 zTree),否则内容未超高也会因 1~2px 溢出出现滚动条 */
|
||||||
|
.dept-manage-tree {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.tree-inner {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧白卡内部纵向排布:标题行 / 说明条 / 表格 */
|
||||||
|
.dept-manage-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-caption {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, #1e2635);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题行右侧:包含子组 + 来源切换,整体右对齐;「包含子组」放左边,切视图时 tab 位置不跳 */
|
||||||
|
.title-tail {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-select {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 来源切换:同页两个视图,不新增菜单项 */
|
||||||
|
.source-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
background: #f2f4f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-tab {
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-2, #5a6478);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-tab.is-active {
|
||||||
|
background: var(--panel, #fff);
|
||||||
|
color: var(--brand, #577afb);
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 1px 2px rgba(30, 38, 53, .08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 面板内顶部说明条的间距(外观完全交给 iView Alert 自身,不覆写配色) */
|
||||||
|
.usage-note {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空态 / 无权限等临时提示 */
|
||||||
|
.state-tip {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-loading {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 80px;
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 面板头的「包含子组」开关 */
|
||||||
|
.inc-ctrl {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-2, #5a6478);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 「包含子组」开关:面板头与 zTree 树上共用同一套样式。
|
||||||
|
树上的控件是运行时创建的 DOM,没有 scoped 标记,只能用 :deep 命中 */
|
||||||
|
:deep(.sw-mini) {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 28px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #d3d9e6;
|
||||||
|
transition: background .15s;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgba(30, 38, 53, .2);
|
||||||
|
transition: left .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-on {
|
||||||
|
background: var(--brand, #577afb);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
left: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= 部门表格(iView Table) ================= */
|
||||||
|
.row-sum {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格本体外观全部交给 iView:只补单元格内部的排版细节 */
|
||||||
|
.dept-itable {
|
||||||
|
:deep(.ivu-table-cell) {
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ivu-table-row.is-off td) {
|
||||||
|
background: #fcfcfd;
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ivu-table-row.is-off:hover td) {
|
||||||
|
background: #f8f9fb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmcell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmcell .tw {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 14px;
|
||||||
|
text-align: center;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #aeb6c2;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmcell .tw.blank {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmcell .nm {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmcell .nm.root {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, #1e2635);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmcell .cnt {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 5px;
|
||||||
|
line-height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f1f3f7;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmcell .cnt.grey {
|
||||||
|
background: #f6f7fa;
|
||||||
|
color: #a3adbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 人员列:姓名区可截断,「+N」固定不收缩(名字太长时也得看得出还有没列完的人) */
|
||||||
|
.mem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
color: var(--text-2, #5a6478);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mem .names {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mem .more {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 只有超过 10 人才可点(全部列完再弹窗没有增量信息) */
|
||||||
|
.mem-cell {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mem-cell:hover .names {
|
||||||
|
color: var(--brand, #577afb);
|
||||||
|
text-decoration-line: underline;
|
||||||
|
text-decoration-style: dashed;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mem-plain {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim {
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态开关:复用「包含子组」那套胶囊;表格里要跟文字垂直居中 */
|
||||||
|
.table-foot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 10px 2px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= 人员弹窗 ================= */
|
||||||
|
.mem-modal-body {
|
||||||
|
max-height: 380px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mem-empty {
|
||||||
|
padding: 26px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mem-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-bottom: 1px solid #f2f5f9;
|
||||||
|
font-size: 12.5px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mname {
|
||||||
|
color: var(--text, #1e2635);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macc {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-3, #98a1b2);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mem-pager {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -84,7 +84,7 @@ function onTreeData(tree: any[]) {
|
|||||||
|
|
||||||
async function loadUserGroups(userId: number) {
|
async function loadUserGroups(userId: number) {
|
||||||
try {
|
try {
|
||||||
const groupIds: number[] = await Apis.SysUser.getGroups({ pathParams: { userId }, cacheFor: null });
|
const groupIds: number[] = await Apis.SysUser.getUserGroups({ pathParams: { userId }, cacheFor: null });
|
||||||
if (treeData.value?.length) {
|
if (treeData.value?.length) {
|
||||||
initSelected(groupIds || []);
|
initSelected(groupIds || []);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="group-user-tree">
|
<div>
|
||||||
<ul class="opera-ul">
|
<ul class="opera-ul">
|
||||||
<li class="add-node-li" @click="showAddNode">
|
<li class="add-node-li" @click="showAddNode">
|
||||||
<Tooltip content="新增子组" placement="top" transfer>
|
<Tooltip content="新增子组" placement="top" transfer>
|
||||||
@@ -344,13 +344,6 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.group-user-tree {
|
|
||||||
width: 300px !important;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opera-ul {
|
.opera-ul {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
@@ -359,8 +352,8 @@ onMounted(async () => {
|
|||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
padding: 10px;
|
padding: 8px;
|
||||||
height: 38px;
|
height: 34px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
@@ -370,12 +363,12 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 18px;
|
width: 16px;
|
||||||
height: 18px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ivu-icon {
|
.ivu-icon {
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper tree-wrapper group-user-page">
|
<div class="page-wrapper tree-wrapper group-user-page">
|
||||||
<Group class="tree-container" v-model:curTreeNode="selectedNode" :treeApi="treeApi" @on-change="onTreeChange"
|
<Group class="tree-container group-user-tree" v-model:curTreeNode="selectedNode" :treeApi="treeApi"
|
||||||
@on-tree-data="onTreeData" />
|
@on-change="onTreeChange" @on-tree-data="onTreeData" />
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<Button type="primary" @click="showAddUser">
|
<Button type="primary" @click="showAddUser">
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
v-model="keyword" @on-search="onSearch" @on-clear="onClear" />
|
v-model="keyword" @on-search="onSearch" @on-clear="onClear" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight">
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns">
|
||||||
<template #default="{ checkList }">
|
<template #default="{ checkList }">
|
||||||
<Button type="primary" ghost :disabled="!checkList?.length" @click="openAdjust(checkList)">调整组</Button>
|
<Button type="primary" ghost :disabled="!checkList?.length" @click="openAdjust(checkList)">调整组</Button>
|
||||||
<Button type="warning" ghost class="ml-15" :disabled="!checkList?.length" @click="openBatchStatus(checkList)">设置状态</Button>
|
<Button type="warning" ghost class="ml-15" :disabled="!checkList?.length" @click="openBatchStatus(checkList)">设置状态</Button>
|
||||||
@@ -59,8 +59,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { Message } from "view-ui-plus";
|
import { Message, Tag, Badge } from "view-ui-plus";
|
||||||
import { useDefaultStore } from "@/store";
|
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
@@ -71,10 +70,11 @@ import EditUserContainer from './user/EditUserContainer.vue';
|
|||||||
import AdjustGroupContainer from './AdjustGroupContainer.vue';
|
import AdjustGroupContainer from './AdjustGroupContainer.vue';
|
||||||
import BatchImportUser from './user/BatchImportUser.vue';
|
import BatchImportUser from './user/BatchImportUser.vue';
|
||||||
import UserSort from './user/UserSort.vue';
|
import UserSort from './user/UserSort.vue';
|
||||||
|
import { actionColumn } from "@/utils/tableActions";
|
||||||
|
|
||||||
defineOptions({ name: "GroupUser" });
|
defineOptions({ name: "GroupUser" });
|
||||||
|
|
||||||
const { $utils, $conf } = useCurrentInstance();
|
const { $conf } = useCurrentInstance();
|
||||||
|
|
||||||
const treeApi = () => Apis.SysGroup.getTree();
|
const treeApi = () => Apis.SysGroup.getTree();
|
||||||
|
|
||||||
@@ -111,8 +111,8 @@ const groupOptions = computed(() => {
|
|||||||
const flat: { id: number; name: string }[] = [];
|
const flat: { id: number; name: string }[] = [];
|
||||||
const walk = (list: any[], prefix = '') => {
|
const walk = (list: any[], prefix = '') => {
|
||||||
list.forEach(n => {
|
list.forEach(n => {
|
||||||
flat.push({ id: n.id, name: prefix ? `${prefix} / ${n.name}` : n.name });
|
flat.push({ id: n.id, name: prefix ? `${prefix}/${n.name}` : n.name });
|
||||||
if (n.children?.length) walk(n.children, prefix ? `${prefix} / ${n.name}` : n.name);
|
if (n.children?.length) walk(n.children, prefix ? `${prefix}/${n.name}` : n.name);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
walk(treeData.value);
|
walk(treeData.value);
|
||||||
@@ -147,33 +147,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }))
|
|
||||||
|
|
||||||
/* ================= 用户身份(roles[])展示 ================= */
|
|
||||||
// 唯一真源:后端 SysRoleEnum(经 GetSysDict.sysrole 下发),杜绝前端硬编码漂移。
|
|
||||||
// roles 中仅存放 SysRoleEnum 的 key(1=老师 … 7=管理员),不再混用 UserTypeEnum。
|
|
||||||
const SYS_ROLE_MAP_FALLBACK: Record<number, string> = {
|
|
||||||
1: '老师', 2: '校领导', 3: '学生', 4: '家长',
|
|
||||||
5: '局领导', 6: '工作号', 7: '管理员',
|
|
||||||
};
|
|
||||||
const store = useDefaultStore();
|
|
||||||
const userTypeMap = computed<Record<number, string>>(() => {
|
|
||||||
const sysrole = (store.getSysDict as any)?.sysrole;
|
|
||||||
if (Array.isArray(sysrole) && sysrole.length) {
|
|
||||||
const m: Record<number, string> = {};
|
|
||||||
sysrole.forEach((it: any) => { m[Number(it.k)] = it.v; });
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
return SYS_ROLE_MAP_FALLBACK;
|
|
||||||
});
|
|
||||||
function describeRoles(roles: any): string {
|
|
||||||
if (!Array.isArray(roles) || roles.length === 0) return '-';
|
|
||||||
const names = roles
|
|
||||||
.map((r: any) => userTypeMap.value[Number(r)])
|
|
||||||
.filter((n: string) => !!n);
|
|
||||||
return names.length ? names.join('/') : '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================= 表格列 ================= */
|
/* ================= 表格列 ================= */
|
||||||
const columns = [
|
const columns = [
|
||||||
{ type: 'selection', width: 55, align: 'center' },
|
{ type: 'selection', width: 55, align: 'center' },
|
||||||
@@ -182,47 +155,38 @@ const columns = [
|
|||||||
{ title: '真实姓名', key: 'realName', minWidth: 120 },
|
{ title: '真实姓名', key: 'realName', minWidth: 120 },
|
||||||
{ title: '手机号', key: 'phone', minWidth: 140 },
|
{ title: '手机号', key: 'phone', minWidth: 140 },
|
||||||
{
|
{
|
||||||
title: '用户身份', key: 'roles', minWidth: 180,
|
title: '用户身份', key: 'identityNames', minWidth: 180,
|
||||||
render: (h: any, { row }: any) => h('span', describeRoles(row.roles)),
|
// 数据源:后端按页批量带出的身份名,且只取「当前所选组范围解析出的学校」下的身份行
|
||||||
|
// (与编辑弹窗「所在组」回显同口径,roles 列已停写不再直读旧列)
|
||||||
|
render: (h: any, { row }: any) => h('span', row.identityNames || '-'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态', key: 'disabled', width: 90, align: 'center',
|
title: '状态', key: 'disabled', width: 130, align: 'center',
|
||||||
render: (h: any, { row }: any) =>
|
render: (h: any, { row }: any) => {
|
||||||
h('Tag', { color: row.disabled ? 'default' : 'success' },
|
// Vue3 渲染函数须 import 真组件:h('Tag') 会建未知原生元素(无样式不渲染)
|
||||||
row.disabled ? '已禁用' : '启用'),
|
// 描边徽标挂主题令牌(.tag-outline,见 main.scss):预设色 border prop 渲染仍实心,弃用
|
||||||
|
if (row.disabled) return h(Tag, { class: 'tag-outline tag-outline-muted' }, () => '禁用');
|
||||||
|
if (row.groupStatus === 1) {
|
||||||
|
// 正常徽标 + 转出角标(view-ui-plus Badge status 模式:圆点 + 文本)
|
||||||
|
return h('div', { style: 'display:inline-flex;align-items:center;gap:6px' }, [
|
||||||
|
h(Tag, { class: 'tag-outline' }, () => '正常'),
|
||||||
|
h(Badge, { status: 'warning', text: '转出' }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return h(Tag, { class: 'tag-outline' }, () => '正常');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '排序号', key: 'sortNo', width: 90, align: 'center',
|
title: '排序号', key: 'sortNo', width: 90, align: 'center',
|
||||||
render: (h: any, { row }: any) => h('span', row.sortNo == null ? '-' : row.sortNo),
|
render: (h: any, { row }: any) => h('span', row.sortNo == null ? '-' : row.sortNo),
|
||||||
},
|
},
|
||||||
{
|
// 文字链形态:颜色走通道 .operating-link = var(--brand),随主题档位变化;
|
||||||
title: '操作', key: 'action', width: 220, align: 'center', fixed: 'right' as const,
|
// 排序仅「单组浏览」开放——withSub 是与行数据无关的全局模式开关,用 when 整组隐藏合规
|
||||||
render: (h: any, { row }: any) => {
|
actionColumn([
|
||||||
const opList: any[] = [
|
{ key: 'edit', label: '编辑', on: (r: any) => handleEdit(r) },
|
||||||
h('a', {
|
{ key: 'sort', label: '排序', on: (r: any) => openSort(r), when: () => dataParams.value.withSub === 0 },
|
||||||
style: { marginRight: '8px', color: '#577afb' },
|
{ key: 'del', label: '删除', on: (r: any) => handleDelete(r), danger: true },
|
||||||
onClick: () => handleEdit(row),
|
], { mode: 'text', width: 180, fixed: 'right' }),
|
||||||
}, '编辑'),
|
|
||||||
];
|
|
||||||
// 仅「单组浏览」(withSub=0)时开放排序:此时每个用户都直接属于当前组,排序号语义明确;
|
|
||||||
// 含子组搜索(withSub=1)下用户可能属于子组,排序号归属不清,故隐藏。
|
|
||||||
if (dataParams.value.withSub === 0) {
|
|
||||||
opList.push(
|
|
||||||
h('a', {
|
|
||||||
style: { marginRight: '8px', color: '#2d8cf0' },
|
|
||||||
onClick: () => openSort(row),
|
|
||||||
}, '排序'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
opList.push(
|
|
||||||
h('a', {
|
|
||||||
style: { color: '#ed4014' },
|
|
||||||
onClick: () => handleDelete(row),
|
|
||||||
}, '删除'),
|
|
||||||
);
|
|
||||||
return h('div', opList);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ================= 导出组用户 ================= */
|
/* ================= 导出组用户 ================= */
|
||||||
@@ -384,8 +348,22 @@ function onSortSaved() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.group-user-page .page-panel {
|
/* 左侧树容器改为 flex 纵向布局并收起自身滚动,把滚动条收敛到树节点区域
|
||||||
left: 348px !important;
|
(滚动体是 MyTree 内部的 .ztree,见 components/MyTree.vue),
|
||||||
|
避免树过长时把顶部 opera-ul 操作按钮条滚出屏幕;仅作用于本页,不影响全局 .tree-container */
|
||||||
|
.tree-wrapper.group-user-page .tree-container.group-user-tree {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.opera-ul {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-tree-search-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-status-body {
|
.batch-status-body {
|
||||||
|
|||||||
@@ -68,10 +68,12 @@ async function handleSubmit(formData: Record<string, any>) {
|
|||||||
// 新增失败(onError 已提示),不继续绑定角色
|
// 新增失败(onError 已提示),不继续绑定角色
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 新增成功后再绑定业务角色
|
// 新增成功后再绑定业务角色(组维度:绑定挂在创建分组上,即左侧当前选中组)
|
||||||
if (newId != null && appRolesToSave.length) {
|
if (newId != null && appRolesToSave.length) {
|
||||||
try {
|
try {
|
||||||
await Apis.AppRole.saveUserRoles({ data: { userId: newId, roleIds: appRolesToSave } });
|
await Apis.AppRole.saveUserRoles({
|
||||||
|
data: { userId: newId, groupId: currentGroupId.value ?? undefined, roleIds: appRolesToSave },
|
||||||
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// 全局拦截器已弹窗,这里仅记录日志避免二次弹窗
|
// 全局拦截器已弹窗,这里仅记录日志避免二次弹窗
|
||||||
console.warn('[AddUserContainer] 用户已创建,但角色属性保存失败:', e?.message || '未知错误');
|
console.warn('[AddUserContainer] 用户已创建,但角色属性保存失败:', e?.message || '未知错误');
|
||||||
@@ -82,10 +84,11 @@ async function handleSubmit(formData: Record<string, any>) {
|
|||||||
/**
|
/**
|
||||||
* 把表单字段映射成 SysUserRequest:
|
* 把表单字段映射成 SysUserRequest:
|
||||||
* - 只提交后端 SysUserRequest 里声明的字段
|
* - 只提交后端 SysUserRequest 里声明的字段
|
||||||
* - 角色仅来自"用户身份"勾选(userType),groupId 由当前所在组回写
|
* - 身份角色来自"用户身份"勾选(userType → roleIds);schoolId 不传,由后端按 groupId 解析落库
|
||||||
|
* - groupId 由当前所在组回写
|
||||||
*/
|
*/
|
||||||
function buildPayload(formData: Record<string, any>) {
|
function buildPayload(formData: Record<string, any>) {
|
||||||
const roles: number[] = (formData.userType || []).map((v: any) => Number(v));
|
const roleIds: number[] = (formData.userType || []).map((v: any) => Number(v));
|
||||||
return {
|
return {
|
||||||
account: formData.account,
|
account: formData.account,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
@@ -95,7 +98,7 @@ function buildPayload(formData: Record<string, any>) {
|
|||||||
phone: formData.phone || undefined,
|
phone: formData.phone || undefined,
|
||||||
gender: formData.gender,
|
gender: formData.gender,
|
||||||
groupId: currentGroupId.value,
|
groupId: currentGroupId.value,
|
||||||
roles,
|
roleIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,8 @@ const userGroups = ref<{ id: number; name: string }[]>([]);
|
|||||||
const userGroupIdsArr = ref<number[]>([]);
|
const userGroupIdsArr = ref<number[]>([]);
|
||||||
// 角色属性下拉:来自角色授权页的全量角色
|
// 角色属性下拉:来自角色授权页的全量角色
|
||||||
const roleOptions = ref<{ id: number; name: string }[]>([]);
|
const roleOptions = ref<{ id: number; name: string }[]>([]);
|
||||||
// 角色属性是否成功加载(控制编辑后是否回写 saveUserRoles,避免加载失败时误清空)
|
// 角色属性是否成功加载(控制编辑后是否回写 saveUserRoles,避免下拉失败时误清空)
|
||||||
const roleOptionsLoaded = ref(false);
|
const roleOptionsLoaded = ref(false);
|
||||||
// 已绑定角色是否成功加载(与 roleOptionsLoaded 同时为真才回写,防止误清空)
|
|
||||||
const userRolesLoaded = ref(false);
|
|
||||||
// 待保存的角色属性(用户多选结果)
|
// 待保存的角色属性(用户多选结果)
|
||||||
let appRolesToSave: number[] = [];
|
let appRolesToSave: number[] = [];
|
||||||
|
|
||||||
@@ -33,7 +31,7 @@ let onSuccessCallback: (() => void) | null = null;
|
|||||||
// useForm 托管提交——currentId 是 ref,闭包内 .value 在 sendForm 时取当前值
|
// useForm 托管提交——currentId 是 ref,闭包内 .value 在 sendForm 时取当前值
|
||||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
||||||
(dto) => Apis.SysUser.edit({ pathParams: { id: currentId.value }, data: dto }),
|
(dto) => Apis.SysUser.edit({ pathParams: { id: currentId.value }, data: dto }),
|
||||||
{ initialForm: { id: null, account: '', realName: '', password: '', confirmPassword: '', idcard: '', phone: '', gender: 0, userType: [] }, resetAfterSubmiting: true }
|
{ initialForm: { id: null, account: '', realName: '', password: '', confirmPassword: '', idcard: '', phone: '', gender: 0, userType: [], schoolId: null }, resetAfterSubmiting: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
onSuccess(() => {
|
onSuccess(() => {
|
||||||
@@ -62,16 +60,15 @@ async function open(row: any, groups: any[], curGroupId?: number | null, onSucce
|
|||||||
const groupList = groups || [];
|
const groupList = groups || [];
|
||||||
onSuccessCallback = onSuccess || null;
|
onSuccessCallback = onSuccess || null;
|
||||||
roleOptionsLoaded.value = false;
|
roleOptionsLoaded.value = false;
|
||||||
userRolesLoaded.value = false;
|
|
||||||
userGroups.value = [];
|
userGroups.value = [];
|
||||||
userGroupIdsArr.value = [];
|
userGroupIdsArr.value = [];
|
||||||
roleOptions.value = [];
|
roleOptions.value = [];
|
||||||
detailData.value = {};
|
detailData.value = {};
|
||||||
visible.value = true;
|
visible.value = true;
|
||||||
|
|
||||||
// 1) 用户所属组(权威来源 getGroups);失败或为空则用当前组兜底,绝不留空
|
// 1) 用户所属组(权威来源 getUserGroups);失败或为空则用当前组兜底,绝不留空
|
||||||
try {
|
try {
|
||||||
const groupIdsResp = await Apis.SysUser.getGroups({ pathParams: { userId: row.id } });
|
const groupIdsResp = await Apis.SysUser.getUserGroups({ pathParams: { userId: row.id }, cacheFor: null });
|
||||||
const ids: number[] = Array.isArray(groupIdsResp) ? groupIdsResp : [];
|
const ids: number[] = Array.isArray(groupIdsResp) ? groupIdsResp : [];
|
||||||
const groupMap = new Map<number, any>((groupList as any[]).map(g => [g.id, g]));
|
const groupMap = new Map<number, any>((groupList as any[]).map(g => [g.id, g]));
|
||||||
let groups_: { id: number; name: string }[] = ids.map(id => ({
|
let groups_: { id: number; name: string }[] = ids.map(id => ({
|
||||||
@@ -84,13 +81,13 @@ async function open(row: any, groups: any[], curGroupId?: number | null, onSucce
|
|||||||
userGroups.value = groups_;
|
userGroups.value = groups_;
|
||||||
userGroupIdsArr.value = groups_.map(g => g.id);
|
userGroupIdsArr.value = groups_.map(g => g.id);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// getGroups 不可用(如后端未部署该接口)时,至少展示当前点击的组,避免空白
|
// getUserGroups 不可用(如后端未部署该接口)时,至少展示当前点击的组,避免空白
|
||||||
if (curGroupId != null) {
|
if (curGroupId != null) {
|
||||||
const groupMap = new Map<number, any>((groupList as any[]).map(g => [g.id, g]));
|
const groupMap = new Map<number, any>((groupList as any[]).map(g => [g.id, g]));
|
||||||
userGroups.value = [{ id: curGroupId, name: groupMap.get(curGroupId)?.name ?? ('组#' + curGroupId) }];
|
userGroups.value = [{ id: curGroupId, name: groupMap.get(curGroupId)?.name ?? ('组#' + curGroupId) }];
|
||||||
userGroupIdsArr.value = [curGroupId];
|
userGroupIdsArr.value = [curGroupId];
|
||||||
}
|
}
|
||||||
console.warn('[EditUserContainer] getGroups 失败,已用当前组兜底:', e?.message || e);
|
console.warn('[EditUserContainer] getUserGroups 失败,已用当前组兜底:', e?.message || e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) 角色属性下拉(相互独立,失败不影响所在组)
|
// 2) 角色属性下拉(相互独立,失败不影响所在组)
|
||||||
@@ -104,32 +101,17 @@ async function open(row: any, groups: any[], curGroupId?: number | null, onSucce
|
|||||||
console.warn('[EditUserContainer] 加载角色列表失败:', e?.message || '未知错误');
|
console.warn('[EditUserContainer] 加载角色列表失败:', e?.message || '未知错误');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) 已绑定角色 -> appRoles 回填(相互独立)
|
// 3) 角色属性回显不再在此加载:UserForm 内部监听 userGroupId(初始选中即触发),
|
||||||
// 所在组(只读)默认选中「当前组」:优先用进入编辑时的左侧树/列表组(curGroupId),否则取用户所属组的第一个
|
// 按组调 GET UserRoles 回显该组角色,并对「该组回显未就绪」拦截提交,防止误清空。
|
||||||
|
// 所在组默认选中「当前组」:优先用进入编辑时的左侧树/列表组(curGroupId),否则取用户所属组的第一个
|
||||||
const defaultGroupId = (curGroupId != null && userGroupIdsArr.value.includes(curGroupId))
|
const defaultGroupId = (curGroupId != null && userGroupIdsArr.value.includes(curGroupId))
|
||||||
? curGroupId
|
? curGroupId
|
||||||
: (userGroupIdsArr.value[0] ?? null);
|
: (userGroupIdsArr.value[0] ?? null);
|
||||||
try {
|
detailData.value = buildInitForm(row, defaultGroupId);
|
||||||
const userRolesResp = await Apis.GroupAuthz.userRoles({ params: { userId: row.id } });
|
|
||||||
const userRoleIds: number[] = (Array.isArray(userRolesResp) ? userRolesResp : [])
|
|
||||||
.map((r: any) => r.id)
|
|
||||||
.filter((x: any): x is number => x != null);
|
|
||||||
userRolesLoaded.value = true;
|
|
||||||
detailData.value = buildInitForm(row, userRoleIds, defaultGroupId);
|
|
||||||
} catch (e: any) {
|
|
||||||
// 已绑定角色加载失败:仍回填其它字段,但标记未加载成功(提交时不回写,避免误清空)
|
|
||||||
detailData.value = buildInitForm(row, [], defaultGroupId);
|
|
||||||
// 全局拦截器已弹窗,这里仅记录日志避免二次弹窗
|
|
||||||
console.warn('[EditUserContainer] 加载用户已绑定角色失败:', e?.message || '未知错误');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 把行数据映射成表单回填数据 ---------- */
|
/* ---------- 把行数据映射成表单回填数据 ---------- */
|
||||||
function buildInitForm(row: any, userRoleIds: number[], defaultGroupId: number | null = null) {
|
function buildInitForm(row: any, defaultGroupId: number | null = null) {
|
||||||
const roles: number[] = Array.isArray(row.roles) ? row.roles : [];
|
|
||||||
// 用户身份(业务身份)= 1..7;角色属性已移除(超管手动入库、管理员走勾选)
|
|
||||||
const userType = roles.filter(r => r >= 1 && r <= 7);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
account: row.account || '',
|
account: row.account || '',
|
||||||
@@ -139,8 +121,10 @@ function buildInitForm(row: any, userRoleIds: number[], defaultGroupId: number |
|
|||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
gender: row.gender ?? 0,
|
gender: row.gender ?? 0,
|
||||||
userType,
|
// 用户身份/角色属性回显唯一来源是 GET UserIdentity / GET UserRoles(UserForm 内部按「所在组」加载):
|
||||||
appRoles: userRoleIds || [],
|
// roles 列已停写,row.roles 为冻结历史值,误用回显会把废弃值固化进权威表
|
||||||
|
userType: [],
|
||||||
|
appRoles: [],
|
||||||
userGroupId: defaultGroupId ?? null,
|
userGroupId: defaultGroupId ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -156,10 +140,17 @@ async function handleSubmit(formData: Record<string, any>) {
|
|||||||
// 编辑失败(onError 已提示),不继续回写角色
|
// 编辑失败(onError 已提示),不继续回写角色
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 编辑成功后再回写业务角色(全量覆盖);仅当角色列表与已绑定角色都成功加载才回写,防止误清空
|
// 编辑成功后再回写业务角色(按组覆盖:只覆盖当前所在组下的绑定,不影响其他组);
|
||||||
if (roleOptionsLoaded.value && userRolesLoaded.value) {
|
// 仅当角色下拉成功加载才回写,防止误清空(该组角色回显未就绪时 UserForm 已拦截提交)
|
||||||
|
if (roleOptionsLoaded.value) {
|
||||||
try {
|
try {
|
||||||
await Apis.AppRole.saveUserRoles({ data: { userId: currentId.value as number, roleIds: appRolesToSave } });
|
await Apis.AppRole.saveUserRoles({
|
||||||
|
data: {
|
||||||
|
userId: currentId.value as number,
|
||||||
|
groupId: (formData.userGroupId as number | null) ?? undefined,
|
||||||
|
roleIds: appRolesToSave,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// 全局拦截器已弹窗,这里仅记录日志避免二次弹窗
|
// 全局拦截器已弹窗,这里仅记录日志避免二次弹窗
|
||||||
console.warn('[EditUserContainer] 用户已更新,但角色属性保存失败:', e?.message || '未知错误');
|
console.warn('[EditUserContainer] 用户已更新,但角色属性保存失败:', e?.message || '未知错误');
|
||||||
@@ -172,9 +163,10 @@ async function handleSubmit(formData: Record<string, any>) {
|
|||||||
* - 只提交后端 SysUserRequest 里声明的字段
|
* - 只提交后端 SysUserRequest 里声明的字段
|
||||||
* - 编辑时不提交 groupId(后端 edit 会保留用户原分组,且 groupId 仅在 Create 分组校验)
|
* - 编辑时不提交 groupId(后端 edit 会保留用户原分组,且 groupId 仅在 Create 分组校验)
|
||||||
* - 编辑时密码留空表示不修改,所以密码字段不传
|
* - 编辑时密码留空表示不修改,所以密码字段不传
|
||||||
|
* - 身份随 Select 当前选中组提交 {schoolId, roleIds}(未修改也提交原值),由后端按校全量覆盖并对齐
|
||||||
*/
|
*/
|
||||||
function buildPayload(formData: Record<string, any>, id: number) {
|
function buildPayload(formData: Record<string, any>, id: number) {
|
||||||
const roles: number[] = (formData.userType || []).map((v: any) => Number(v));
|
const roleIds: number[] = (formData.userType || []).map((v: any) => Number(v));
|
||||||
|
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
id,
|
id,
|
||||||
@@ -183,7 +175,8 @@ function buildPayload(formData: Record<string, any>, id: number) {
|
|||||||
idcard: formData.idcard || undefined,
|
idcard: formData.idcard || undefined,
|
||||||
phone: formData.phone || undefined,
|
phone: formData.phone || undefined,
|
||||||
gender: formData.gender,
|
gender: formData.gender,
|
||||||
roles,
|
schoolId: formData.schoolId,
|
||||||
|
roleIds,
|
||||||
};
|
};
|
||||||
// 编辑时若填写了密码才提交(密码为空表示不修改)
|
// 编辑时若填写了密码才提交(密码为空表示不修改)
|
||||||
if (formData.password) {
|
if (formData.password) {
|
||||||
|
|||||||
@@ -57,19 +57,19 @@
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<!-- 第 5 行:所在组(只读)(仅编辑展示,普通单选、默认选中当前组;提交时不带 groupId,不改变所在组) -->
|
<!-- 第 5 行:所在组(仅编辑展示;切换只触发下方「用户身份/角色属性」回显,不改变实际所在组) -->
|
||||||
<Row v-if="isEdit">
|
<Row v-if="isEdit">
|
||||||
<Col span="24">
|
<Col span="24">
|
||||||
<FormItem label="所在组(只读):">
|
<FormItem label="所在组:">
|
||||||
<Select v-model="form.userGroupId" transfer clearable
|
<Select v-model="form.userGroupId" transfer clearable
|
||||||
placeholder="默认选中当前组(提交不改变所在组)">
|
placeholder="切换后回显该组的身份与角色属性(不改变实际所在组)">
|
||||||
<Option v-for="g in userGroups" :key="g.id" :value="g.id" :label="g.name" />
|
<Option v-for="g in userGroups" :key="g.id" :value="g.id" :label="g.name" />
|
||||||
</Select>
|
</Select>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<!-- 第 6 行:用户身份(checkbox 多选,整行) -->
|
<!-- 第 6 行:用户身份(checkbox 多选,整行;编辑时跟随所在组 Select 回显) -->
|
||||||
<Row>
|
<Row>
|
||||||
<Col span="24">
|
<Col span="24">
|
||||||
<FormItem label="用户身份:" prop="userType">
|
<FormItem label="用户身份:" prop="userType">
|
||||||
@@ -105,7 +105,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref, watch } from "vue";
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
import { useDefaultStore } from "@/store";
|
import { useDefaultStore } from "@/store";
|
||||||
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
|
|
||||||
/* ---------- Props / Emits ---------- */
|
/* ---------- Props / Emits ---------- */
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
@@ -145,10 +147,17 @@ const defaultForm = (): Record<string, any> => ({
|
|||||||
userType: [] as number[],
|
userType: [] as number[],
|
||||||
appRoles: [] as number[],
|
appRoles: [] as number[],
|
||||||
userGroupId: null as number | null,
|
userGroupId: null as number | null,
|
||||||
|
schoolId: null as number | null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = reactive(defaultForm());
|
const form = reactive(defaultForm());
|
||||||
|
|
||||||
|
// 编辑模式回显到的最新的身份基线(用于「非空清成空」二次确认;随所在组切换同步更新)
|
||||||
|
let baselineUserType: number[] = [];
|
||||||
|
|
||||||
|
// 编辑模式:当前所在组的角色属性是否已回显成功(未就绪时拦截提交,避免把上一组的角色写到新组)
|
||||||
|
let appRolesEchoed = false;
|
||||||
|
|
||||||
// 外部 initForm 变化时回填
|
// 外部 initForm 变化时回填
|
||||||
watch(() => props.initForm, (val) => {
|
watch(() => props.initForm, (val) => {
|
||||||
if (val && Object.keys(val).length > 0) {
|
if (val && Object.keys(val).length > 0) {
|
||||||
@@ -179,6 +188,42 @@ const userTypeOptions = computed(() => {
|
|||||||
return SYS_ROLE_FALLBACK;
|
return SYS_ROLE_FALLBACK;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ---------- 所在组切换:身份 + 角色属性 双回显(唯一来源 GET UserIdentity / GET UserRoles) ---------- */
|
||||||
|
// 所在组 Select 切换(含弹窗打开时的初始选中)→ 后端解析 schoolId 后回显 {schoolId, roleIds};
|
||||||
|
// 角色属性为组维度(一行 = 用户×组×角色),同步按所选组回显该组下的角色,互不影响其他组绑定。
|
||||||
|
// 同一学校多组由后端返回同一 schoolId 与角色并集;前端不感知解析逻辑。
|
||||||
|
watch(() => form.userGroupId, (gid: number | null) => {
|
||||||
|
if (!props.isEdit) return;
|
||||||
|
if (gid == null || !form.id) {
|
||||||
|
// 清空所在组:回显状态失效并拦截提交,避免把上一组的角色经「主组兜底」误存到别的组
|
||||||
|
appRolesEchoed = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 切组即置为未就绪:两组数据任一未回来前不允许提交
|
||||||
|
appRolesEchoed = false;
|
||||||
|
Apis.SysUser.userIdentity({ params: { userId: form.id, groupId: gid }, cacheFor: null })
|
||||||
|
.then((resp: any) => {
|
||||||
|
const roleIds = Array.isArray(resp?.roleIds) ? resp.roleIds.map((v: any) => Number(v)) : [];
|
||||||
|
form.schoolId = resp?.schoolId ?? null;
|
||||||
|
form.userType = roleIds;
|
||||||
|
baselineUserType = roleIds;
|
||||||
|
})
|
||||||
|
.catch((e: any) => {
|
||||||
|
// 全局拦截器已弹窗,这里仅记录日志避免二次弹窗;schoolId 保持 null 时提交会被后端拒绝,不会误清空
|
||||||
|
console.warn('[UserForm] 加载用户身份失败:', e?.message || '未知错误');
|
||||||
|
});
|
||||||
|
Apis.GroupAuthz.userRoles({ params: { userId: form.id, groupId: gid }, cacheFor: null })
|
||||||
|
.then((resp: any) => {
|
||||||
|
const ids = Array.isArray(resp) ? resp.map((r: any) => Number(r?.id)).filter((v: any) => v != null) : [];
|
||||||
|
form.appRoles = ids;
|
||||||
|
appRolesEchoed = true;
|
||||||
|
})
|
||||||
|
.catch((e: any) => {
|
||||||
|
// 回显失败保持未就绪:提交会被拦截,不会把上一组的角色误存到本组
|
||||||
|
console.warn('[UserForm] 加载该组角色属性失败:', e?.message || '未知错误');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/* ---------- 校验规则 ---------- */
|
/* ---------- 校验规则 ---------- */
|
||||||
// 自定义确认密码校验:新增必填且必须与密码一致;编辑时两者皆空表示不修改,任一非空则必须一致
|
// 自定义确认密码校验:新增必填且必须与密码一致;编辑时两者皆空表示不修改,任一非空则必须一致
|
||||||
const validateConfirmPassword = (rule: any, value: string, callback: any) => {
|
const validateConfirmPassword = (rule: any, value: string, callback: any) => {
|
||||||
@@ -223,6 +268,14 @@ const validateAccount = (rule: any, value: string, callback: any) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 用户身份校验拆分:新增强制 ≥1 必填;编辑允许为空(空数组提交 = 清空该校身份,
|
||||||
|
// 否则切到一个尚无身份数据的学校后连改手机号都会被 min:1 卡死)
|
||||||
|
const validateUserType = (rule: any, value: number[], callback: any) => {
|
||||||
|
if (props.isEdit) return callback();
|
||||||
|
if (!value || value.length === 0) return callback(new Error('请选择至少一个用户身份'));
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
// 密码红五星号仅新增显示;校验规则随 isEdit 变化
|
// 密码红五星号仅新增显示;校验规则随 isEdit 变化
|
||||||
const rules = computed(() => ({
|
const rules = computed(() => ({
|
||||||
account: [
|
account: [
|
||||||
@@ -239,7 +292,7 @@ const rules = computed(() => ({
|
|||||||
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号', trigger: 'blur' },
|
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号', trigger: 'blur' },
|
||||||
],
|
],
|
||||||
userType: [
|
userType: [
|
||||||
{ required: true, type: 'array' as const, min: 1, message: '请选择至少一个用户身份', trigger: 'change' },
|
{ required: !props.isEdit, validator: validateUserType, trigger: 'change' },
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -254,6 +307,24 @@ watch(() => form.password, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
|
// 编辑模式身份回显未就绪(schoolId 为空)时拦截:避免在身份数据未加载时提交造成误清空
|
||||||
|
if (props.isEdit && form.schoolId == null) {
|
||||||
|
Message.warning('用户身份数据加载中,请稍候再提交');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 编辑模式角色属性回显未就绪时拦截:避免把上一组的角色属性误存到当前组
|
||||||
|
if (props.isEdit && !appRolesEchoed) {
|
||||||
|
Message.warning('角色属性加载中,请稍候再提交');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 勾选由非空清成空:二次确认(空数组提交 = 清空该校身份)
|
||||||
|
if (props.isEdit && baselineUserType.length > 0 && form.userType.length === 0) {
|
||||||
|
const ok = await MyConfirm({
|
||||||
|
msg: '确定清空该用户在此学校的身份吗?',
|
||||||
|
info: '清空后该校用户将无任何身份',
|
||||||
|
});
|
||||||
|
if (!ok) return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await formRef.value?.validate();
|
await formRef.value?.validate();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -19,8 +19,13 @@
|
|||||||
<FormItem label="调用参数:">
|
<FormItem label="调用参数:">
|
||||||
<pre class="json-pre">{{ formatJson(row?.requestParams) }}</pre>
|
<pre class="json-pre">{{ formatJson(row?.requestParams) }}</pre>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="数据:">
|
<FormItem label="响应结果:">
|
||||||
<a v-if="row?.id" href="javascript:;" class="download-link" @click.prevent="handleDownload">下载</a>
|
<template v-if="row?.id">
|
||||||
|
<a href="javascript:;" class="download-link" @click.prevent="handleDownload">下载</a>
|
||||||
|
<a v-if="canPreview" href="javascript:;" class="download-link" style="margin-left: 12px"
|
||||||
|
@click.prevent="handlePreview">在线预览</a>
|
||||||
|
<span v-if="sizeText" class="sub-text">({{ sizeText }})</span>
|
||||||
|
</template>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="创建时间:">
|
<FormItem label="创建时间:">
|
||||||
@@ -32,10 +37,17 @@
|
|||||||
<Button type="primary" @click="show = false">确定</Button>
|
<Button type="primary" @click="show = false">确定</Button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal v-model="previewVisible" title="响应结果预览" width="760" :mask-closable="true">
|
||||||
|
<pre class="json-pre">{{ previewText }}</pre>
|
||||||
|
<template #footer>
|
||||||
|
<Button @click="previewVisible = false">关闭</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
||||||
import Utils from '@/utils';
|
import Utils from '@/utils';
|
||||||
|
|
||||||
@@ -55,8 +67,6 @@ const show = computed({
|
|||||||
set: val => emit('update:modelValue', val),
|
set: val => emit('update:modelValue', val),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { $conf } = useCurrentInstance();
|
|
||||||
|
|
||||||
const appText = computed(() => {
|
const appText = computed(() => {
|
||||||
if (!props.row?.callerAppName && !props.row?.callerAppKey) return '-';
|
if (!props.row?.callerAppName && !props.row?.callerAppKey) return '-';
|
||||||
const name = props.row.callerAppName || '-';
|
const name = props.row.callerAppName || '-';
|
||||||
@@ -86,10 +96,31 @@ function formatTime(val: any): string {
|
|||||||
return Utils.dateFormat(val, 'yyyy-MM-dd HH:mm:ss');
|
return Utils.dateFormat(val, 'yyyy-MM-dd HH:mm:ss');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { $conf } = useCurrentInstance();
|
||||||
|
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
const previewText = ref('');
|
||||||
|
const canPreview = computed(() => Utils.canPreview(props.row?.responseSize));
|
||||||
|
const sizeText = computed(() =>
|
||||||
|
props.row?.responseSize != null && props.row.responseSize > 0 ? Utils.getFileSize(props.row.responseSize) : ''
|
||||||
|
);
|
||||||
|
|
||||||
function handleDownload() {
|
function handleDownload() {
|
||||||
if (!props.row?.id) return;
|
if (!props.row?.id) return;
|
||||||
location.href = `${$conf.serviceHost}/api/oapilog/Download/${props.row.id}`;
|
location.href = `${$conf.serviceHost}/api/oapilog/Download/${props.row.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePreview() {
|
||||||
|
if (!props.row?.id) return;
|
||||||
|
previewText.value = '加载中…';
|
||||||
|
previewVisible.value = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${$conf.serviceHost}/api/oapilog/Download/${props.row.id}`, { credentials: 'include' });
|
||||||
|
previewText.value = formatJson(await res.text());
|
||||||
|
} catch (e: any) {
|
||||||
|
previewText.value = '预览加载失败:' + (e?.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -124,13 +155,33 @@ function handleDownload() {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.download-link {
|
.download-link {
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #3d5afe;
|
color: var(--brand-2);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sub-text {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border: 1px solid #e8eaec;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<Input v-model="dataParams.keyword" placeholder="接口名称 / 应用名" clearable suffix="ios-search"
|
<Input v-model="dataParams.keyword" placeholder="输入接口名/应用名/appKey搜索" clearable suffix="ios-search"
|
||||||
style="width: 220px" @on-search="handleSearch" @on-clear="handleSearch" />
|
style="width: 280px" @on-search="handleSearch" @on-clear="handleSearch" />
|
||||||
|
|
||||||
<Input v-model="dataParams.callerAppKey" placeholder="应用Key" clearable style="width: 130px"
|
|
||||||
@on-enter="handleSearch" @on-clear="handleSearch" />
|
|
||||||
|
|
||||||
<Select v-model="dataParams.success" class="ml-12" clearable placeholder="调用结果" style="width: 120px"
|
<Select v-model="dataParams.success" class="ml-12" clearable placeholder="调用结果" style="width: 120px"
|
||||||
@on-change="handleSearch">
|
@on-change="handleSearch">
|
||||||
@@ -18,7 +15,6 @@
|
|||||||
@on-change="handleSearch">
|
@on-change="handleSearch">
|
||||||
<Option :value="1">重要</Option>
|
<Option :value="1">重要</Option>
|
||||||
<Option :value="2">次要</Option>
|
<Option :value="2">次要</Option>
|
||||||
<Option :value="3">一般</Option>
|
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<DatePicker v-model="startDate" type="date" placeholder="开始时间" format="yyyy-MM-dd" :editable="false"
|
<DatePicker v-model="startDate" type="date" placeholder="开始时间" format="yyyy-MM-dd" :editable="false"
|
||||||
@@ -30,7 +26,7 @@
|
|||||||
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OapiLogDetailModal v-model="detailVisible" :row="currentRow" />
|
<OapiLogDetailModal v-model="detailVisible" :row="currentRow" />
|
||||||
@@ -39,20 +35,17 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
import OapiLogDetailModal from './OapiLogDetailModal.vue';
|
import OapiLogDetailModal from './OapiLogDetailModal.vue';
|
||||||
import Utils from '@/utils';
|
import Utils from '@/utils';
|
||||||
|
import { actionColumn } from '@/utils/tableActions';
|
||||||
|
|
||||||
defineOptions({ name: 'OapiLogList' });
|
defineOptions({ name: 'OapiLogList' });
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
|
|
||||||
interface DataParams {
|
interface DataParams {
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
callerAppKey?: number;
|
|
||||||
success?: string;
|
success?: string;
|
||||||
logLevel?: number;
|
logLevel?: number;
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
@@ -61,7 +54,6 @@ interface DataParams {
|
|||||||
|
|
||||||
const dataParams = ref<DataParams>({
|
const dataParams = ref<DataParams>({
|
||||||
keyword: undefined,
|
keyword: undefined,
|
||||||
callerAppKey: undefined,
|
|
||||||
success: undefined,
|
success: undefined,
|
||||||
logLevel: undefined,
|
logLevel: undefined,
|
||||||
startTime: undefined,
|
startTime: undefined,
|
||||||
@@ -93,7 +85,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
|||||||
return (Apis as any).SysOapiLog.oapiLogList({
|
return (Apis as any).SysOapiLog.oapiLogList({
|
||||||
params: clean({
|
params: clean({
|
||||||
keyword: dataParams.value.keyword?.trim() || undefined,
|
keyword: dataParams.value.keyword?.trim() || undefined,
|
||||||
callerAppKey: dataParams.value.callerAppKey,
|
|
||||||
success: dataParams.value.success,
|
success: dataParams.value.success,
|
||||||
logLevel: dataParams.value.logLevel,
|
logLevel: dataParams.value.logLevel,
|
||||||
startTime: dataParams.value.startTime,
|
startTime: dataParams.value.startTime,
|
||||||
@@ -105,8 +96,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
|
|
||||||
|
|
||||||
const logLevelMap: Record<number, { text: string; className: string }> = {
|
const logLevelMap: Record<number, { text: string; className: string }> = {
|
||||||
1: { text: '重要', className: 'text-error' },
|
1: { text: '重要', className: 'text-error' },
|
||||||
2: { text: '次要', className: 'text-warning' },
|
2: { text: '次要', className: 'text-warning' },
|
||||||
@@ -158,24 +147,23 @@ const columns = [
|
|||||||
align: 'right',
|
align: 'right',
|
||||||
render: (h: any, { row }: any) => h('span', row.costTime != null ? `${row.costTime} ms` : '-'),
|
render: (h: any, { row }: any) => h('span', row.costTime != null ? `${row.costTime} ms` : '-'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '响应',
|
||||||
|
key: 'responseSize',
|
||||||
|
width: 90,
|
||||||
|
align: 'right',
|
||||||
|
render: (h: any, { row }: any) => h('span', row.responseSize != null && row.responseSize > 0 ? Utils.getFileSize(row.responseSize) : '-'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '调用时间',
|
title: '调用时间',
|
||||||
key: 'createTime',
|
key: 'createTime',
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'),
|
render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'),
|
||||||
},
|
},
|
||||||
{
|
// 文字链形态:颜色走通道 .operating-link = var(--brand),随主题档位变化
|
||||||
title: '操作',
|
actionColumn([
|
||||||
key: 'action',
|
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
|
||||||
width: 100,
|
], { mode: 'text', width: 100, fixed: 'right' }),
|
||||||
align: 'center',
|
|
||||||
fixed: 'right' as const,
|
|
||||||
render: (h: any, { row }: any) =>
|
|
||||||
h('a', {
|
|
||||||
style: { color: '#577afb' },
|
|
||||||
onClick: () => openDetail(row),
|
|
||||||
}, '查看详情'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
|
|||||||
@@ -20,7 +20,12 @@
|
|||||||
<pre class="json-pre">{{ formatJson(row?.requestParams) }}</pre>
|
<pre class="json-pre">{{ formatJson(row?.requestParams) }}</pre>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="数据:">
|
<FormItem label="数据:">
|
||||||
<a v-if="row?.id" href="javascript:;" class="download-link" @click.prevent="handleDownload">下载</a>
|
<template v-if="row?.id">
|
||||||
|
<a href="javascript:;" class="download-link" @click.prevent="handleDownload">下载</a>
|
||||||
|
<a v-if="canPreview" href="javascript:;" class="download-link" style="margin-left: 12px"
|
||||||
|
@click.prevent="handlePreview">在线预览</a>
|
||||||
|
<span v-if="sizeText" class="sub-text">({{ sizeText }})</span>
|
||||||
|
</template>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="创建时间:">
|
<FormItem label="创建时间:">
|
||||||
@@ -32,10 +37,17 @@
|
|||||||
<Button type="primary" @click="show = false">确定</Button>
|
<Button type="primary" @click="show = false">确定</Button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal v-model="previewVisible" title="响应结果预览" width="760" :mask-closable="true">
|
||||||
|
<pre class="json-pre">{{ previewText }}</pre>
|
||||||
|
<template #footer>
|
||||||
|
<Button @click="previewVisible = false">关闭</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
||||||
import Utils from '@/utils';
|
import Utils from '@/utils';
|
||||||
|
|
||||||
@@ -83,6 +95,25 @@ function handleDownload() {
|
|||||||
if (!props.row?.id) return;
|
if (!props.row?.id) return;
|
||||||
location.href = `${$conf.serviceHost}/api/oplog/Download/${props.row.id}`;
|
location.href = `${$conf.serviceHost}/api/oplog/Download/${props.row.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
const previewText = ref('');
|
||||||
|
const canPreview = computed(() => Utils.canPreview(props.row?.responseSize));
|
||||||
|
const sizeText = computed(() =>
|
||||||
|
props.row?.responseSize != null && props.row.responseSize > 0 ? Utils.getFileSize(props.row.responseSize) : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handlePreview() {
|
||||||
|
if (!props.row?.id) return;
|
||||||
|
previewText.value = '加载中…';
|
||||||
|
previewVisible.value = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${$conf.serviceHost}/api/oplog/Download/${props.row.id}`, { credentials: 'include' });
|
||||||
|
previewText.value = formatJson(await res.text());
|
||||||
|
} catch (e: any) {
|
||||||
|
previewText.value = '预览加载失败:' + (e?.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -119,11 +150,25 @@ function handleDownload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.download-link {
|
.download-link {
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #3d5afe;
|
color: var(--brand-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.json-pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border: 1px solid #e8eaec;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<Input v-model="dataParams.keyword" placeholder="接口名称 / 用户名" clearable suffix="ios-search"
|
<Input v-model="dataParams.keyword" placeholder="接口名称 / 用户名" clearable suffix="ios-search"
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OperationLogDetailModal v-model="detailVisible" :row="currentRow" />
|
<OperationLogDetailModal v-model="detailVisible" :row="currentRow" />
|
||||||
@@ -36,15 +36,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
import OperationLogDetailModal from './OperationLogDetailModal.vue';
|
import OperationLogDetailModal from './OperationLogDetailModal.vue';
|
||||||
import Utils from '@/utils';
|
import Utils from '@/utils';
|
||||||
|
import { actionColumn } from '@/utils/tableActions';
|
||||||
|
|
||||||
defineOptions({ name: 'OperationLogList' });
|
defineOptions({ name: 'OperationLogList' });
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
|
|
||||||
interface DataParams {
|
interface DataParams {
|
||||||
@@ -99,8 +97,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
|
|
||||||
|
|
||||||
const logLevelMap: Record<number, { text: string; className: string }> = {
|
const logLevelMap: Record<number, { text: string; className: string }> = {
|
||||||
1: { text: '重要', className: 'text-error' },
|
1: { text: '重要', className: 'text-error' },
|
||||||
2: { text: '次要', className: 'text-warning' },
|
2: { text: '次要', className: 'text-warning' },
|
||||||
@@ -153,23 +149,22 @@ const columns = [
|
|||||||
render: (h: any, { row }: any) => h('span', row.costTime != null ? `${row.costTime} ms` : '-'),
|
render: (h: any, { row }: any) => h('span', row.costTime != null ? `${row.costTime} ms` : '-'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作时间ed',
|
title: '响应',
|
||||||
|
key: 'responseSize',
|
||||||
|
width: 90,
|
||||||
|
align: 'right',
|
||||||
|
render: (h: any, { row }: any) => h('span', row.responseSize != null && row.responseSize > 0 ? Utils.getFileSize(row.responseSize) : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作时间',
|
||||||
key: 'createTime',
|
key: 'createTime',
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'),
|
render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'),
|
||||||
},
|
},
|
||||||
{
|
// 文字链形态:颜色走通道 .operating-link = var(--brand),随主题档位变化
|
||||||
title: '操作',
|
actionColumn([
|
||||||
key: 'action',
|
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
|
||||||
width: 100,
|
], { mode: 'text', width: 100, fixed: 'right' }),
|
||||||
align: 'center',
|
|
||||||
fixed: 'right' as const,
|
|
||||||
render: (h: any, { row }: any) =>
|
|
||||||
h('a', {
|
|
||||||
style: { color: '#577afb' },
|
|
||||||
onClick: () => openDetail(row),
|
|
||||||
}, '查看详情'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
|
|||||||
+13
-30
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<Select v-model="dataParams.appKey" class="ml-12" clearable filterable placeholder="应用"
|
<Select v-model="dataParams.appKey" class="ml-12" clearable filterable placeholder="应用"
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PushLogDetailModal v-model="detailVisible" :row="currentRow" />
|
<PushLogDetailModal v-model="detailVisible" :row="currentRow" />
|
||||||
@@ -37,15 +37,13 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { Message } from 'view-ui-plus';
|
import { Message } from 'view-ui-plus';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
import Utils from '@/utils';
|
import Utils from '@/utils';
|
||||||
import PushLogDetailModal from './PushLogDetailModal.vue';
|
import PushLogDetailModal from './PushLogDetailModal.vue';
|
||||||
|
import { actionColumn } from '@/utils/tableActions';
|
||||||
|
|
||||||
defineOptions({ name: 'PushLog' });
|
defineOptions({ name: 'PushLog' });
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
|
|
||||||
const interfaceOptions = [
|
const interfaceOptions = [
|
||||||
@@ -113,8 +111,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ type: 'index', title: '序号', width: 70, align: 'center' },
|
{ type: 'index', title: '序号', width: 70, align: 'center' },
|
||||||
{ title: '应用', key: 'appName', ellipsis: true, tooltip: true },
|
{ title: '应用', key: 'appName', ellipsis: true, tooltip: true },
|
||||||
@@ -157,22 +153,17 @@ const columns = [
|
|||||||
render: (h: any, { row }: any) => h('span', row.costMs != null ? `${row.costMs}` : '-'),
|
render: (h: any, { row }: any) => h('span', row.costMs != null ? `${row.costMs}` : '-'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '请求体',
|
||||||
key: 'action',
|
key: 'payloadSize',
|
||||||
width: 150,
|
width: 100,
|
||||||
align: 'left',
|
align: 'right',
|
||||||
render: (h: any, { row }: any) => {
|
render: (h: any, { row }: any) => h('span', row.payloadSize != null && row.payloadSize > 0 ? Utils.getFileSize(row.payloadSize) : '-'),
|
||||||
const actions = [
|
|
||||||
h('a', { style: { color: '#577afb', marginRight: '12px' }, onClick: () => openDetail(row) }, '查看详情'),
|
|
||||||
];
|
|
||||||
if (row.success !== 1) {
|
|
||||||
actions.push(
|
|
||||||
h('a', { style: { color: '#ed4014' }, onClick: () => handleRetry(row) }, '重试')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return h('span', actions);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
// 重试按行数据(成功与否)变化 → disabledWhen 置灰保位(§5.2 按钮位置稳定性红线),禁止 when 隐藏
|
||||||
|
actionColumn([
|
||||||
|
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
|
||||||
|
{ key: 'retry', label: '重试', on: (r: any) => handleRetry(r), danger: true, disabledWhen: (r: any) => r.success === 1 },
|
||||||
|
], { mode: 'text', width: 150 }),
|
||||||
];
|
];
|
||||||
|
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
@@ -218,12 +209,4 @@ loadApps();
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-success {
|
|
||||||
color: #19be6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-error {
|
|
||||||
color: #ed4014;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -43,7 +43,12 @@
|
|||||||
<span>{{ formatTime(row?.createdAt) }}</span>
|
<span>{{ formatTime(row?.createdAt) }}</span>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="请求体:">
|
<FormItem label="请求体:">
|
||||||
<a v-if="row?.id" href="javascript:;" class="download-link" @click.prevent="handleDownload">下载请求体</a>
|
<template v-if="row?.id">
|
||||||
|
<a href="javascript:;" class="download-link" @click.prevent="handleDownload">下载请求体</a>
|
||||||
|
<a v-if="canPreview" href="javascript:;" class="download-link" style="margin-left: 12px"
|
||||||
|
@click.prevent="handlePreview">在线预览</a>
|
||||||
|
<span v-if="sizeText" class="sub-text">({{ sizeText }})</span>
|
||||||
|
</template>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -51,10 +56,17 @@
|
|||||||
<Button type="primary" @click="show = false">确定</Button>
|
<Button type="primary" @click="show = false">确定</Button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal v-model="previewVisible" title="请求体预览" width="760" :mask-closable="true">
|
||||||
|
<pre class="json-pre">{{ previewText }}</pre>
|
||||||
|
<template #footer>
|
||||||
|
<Button @click="previewVisible = false">关闭</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
||||||
import Utils from '@/utils';
|
import Utils from '@/utils';
|
||||||
|
|
||||||
@@ -87,11 +99,40 @@ function onVisibleChange(val: boolean) {
|
|||||||
|
|
||||||
const { $conf } = useCurrentInstance();
|
const { $conf } = useCurrentInstance();
|
||||||
|
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
const previewText = ref('');
|
||||||
|
|
||||||
|
const canPreview = computed(() => Utils.canPreview(props.row?.payloadSize));
|
||||||
|
const sizeText = computed(() =>
|
||||||
|
props.row?.payloadSize != null && props.row.payloadSize > 0 ? Utils.getFileSize(props.row.payloadSize) : ''
|
||||||
|
);
|
||||||
|
|
||||||
function handleDownload() {
|
function handleDownload() {
|
||||||
if (!props.row?.id) return;
|
if (!props.row?.id) return;
|
||||||
location.href = `${$conf.serviceHost}/api/push/Download/${props.row.id}`;
|
location.href = `${$conf.serviceHost}/api/push/Download/${props.row.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePreview() {
|
||||||
|
if (!props.row?.id) return;
|
||||||
|
previewText.value = '加载中…';
|
||||||
|
previewVisible.value = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${$conf.serviceHost}/api/push/Download/${props.row.id}`, { credentials: 'include' });
|
||||||
|
previewText.value = formatJson(await res.text());
|
||||||
|
} catch (e: any) {
|
||||||
|
previewText.value = '预览加载失败:' + (e?.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJson(val: string): string {
|
||||||
|
if (!val) return '-';
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(val), null, 2);
|
||||||
|
} catch {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(val: any): string {
|
function formatTime(val: any): string {
|
||||||
if (!val) return '-';
|
if (!val) return '-';
|
||||||
return Utils.dateFormat(val, 'yyyy-MM-dd HH:mm:ss');
|
return Utils.dateFormat(val, 'yyyy-MM-dd HH:mm:ss');
|
||||||
@@ -128,11 +169,25 @@ function formatTime(val: any): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.download-link {
|
.download-link {
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #3d5afe;
|
color: var(--brand-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.json-pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border: 1px solid #e8eaec;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="main-wrapper">
|
|
||||||
<div class="main-container">
|
|
||||||
<div class="test-card">
|
|
||||||
<h3 class="card-title">测试推送</h3>
|
|
||||||
<p class="card-desc">
|
|
||||||
手动触发一次推送(投递消息队列,由订阅方异步接收)。下方将展示本次请求预览(URL / 头 / 体),
|
|
||||||
实际响应结果与耗时请在「推送日志」中查看。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Form :label-width="100" class="test-form">
|
|
||||||
<FormItem label="订阅方应用" required>
|
|
||||||
<Select v-model="form.appId" filterable placeholder="选择要推送的订阅方应用" style="width: 360px">
|
|
||||||
<Option v-for="opt in appOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</Option>
|
|
||||||
</Select>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="订阅接口" required>
|
|
||||||
<Select v-model="form.interfacePath" filterable placeholder="选择接口" style="width: 360px"
|
|
||||||
@on-change="onInterfaceChange">
|
|
||||||
<Option v-for="opt in interfaceOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</Option>
|
|
||||||
</Select>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="目标ID" :required="!isSync">
|
|
||||||
<Input v-model="targetIdsText" :disabled="isSync" :placeholder="isSync ? '全量同步无需指定' : '可选,逗号分隔,如 10,11,12'"
|
|
||||||
style="width: 360px" />
|
|
||||||
</FormItem>
|
|
||||||
<FormItem>
|
|
||||||
<Button type="primary" :loading="sending" @click="handleSend">发送测试推送</Button>
|
|
||||||
</FormItem>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="preview" class="result-card">
|
|
||||||
<div class="result-head">
|
|
||||||
<h3 class="card-title">请求预览</h3>
|
|
||||||
<Button type="text" @click="goLog">查看推送日志</Button>
|
|
||||||
</div>
|
|
||||||
<Alert v-if="preview.batch && preview.batch > 1" type="info" show-icon>
|
|
||||||
本次将拆分为 {{ preview.batch }} 条消息依次发送
|
|
||||||
</Alert>
|
|
||||||
<Form :label-width="100" class="result-form">
|
|
||||||
<FormItem label="请求URL:">
|
|
||||||
<span class="break-all">{{ preview.url }}</span>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="请求方法:">
|
|
||||||
<span>{{ preview.method }}</span>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="请求头:">
|
|
||||||
<pre class="kv-pre">{{ formatHeaders(preview.headers) }}</pre>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem label="请求体:">
|
|
||||||
<pre class="json-pre">{{ formatJson(preview.body) }}</pre>
|
|
||||||
</FormItem>
|
|
||||||
</Form>
|
|
||||||
<Alert type="success" show-icon>{{ sendMessage }}</Alert>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { Message } from 'view-ui-plus';
|
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
|
||||||
|
|
||||||
defineOptions({ name: 'PushTest' });
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const interfaceOptions = [
|
|
||||||
{ value: 'group/SyncGroup', label: '组全量同步', sync: true },
|
|
||||||
{ value: 'user/SyncUser', label: '用户全量同步', sync: true },
|
|
||||||
{ value: 'group/AddGroup', label: '添加组', sync: false },
|
|
||||||
{ value: 'group/UpdateGroup', label: '更新组', sync: false },
|
|
||||||
{ value: 'group/DeleteGroup', label: '删除组', sync: false },
|
|
||||||
{ value: 'user/AddUser', label: '添加用户', sync: false },
|
|
||||||
{ value: 'user/UpdateUser', label: '更新用户', sync: false },
|
|
||||||
{ value: 'user/DeleteUser', label: '删除用户', sync: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const appOptions = ref<{ value: number; label: string }[]>([]);
|
|
||||||
const form = ref({ appId: undefined as number | undefined, interfacePath: '' });
|
|
||||||
const targetIdsText = ref('');
|
|
||||||
const sending = ref(false);
|
|
||||||
const preview = ref<Record<string, any> | null>(null);
|
|
||||||
const sendMessage = ref('');
|
|
||||||
|
|
||||||
const isSync = computed(() => {
|
|
||||||
const opt = interfaceOptions.find((o) => o.value === form.value.interfacePath);
|
|
||||||
return !!opt?.sync;
|
|
||||||
});
|
|
||||||
|
|
||||||
function onInterfaceChange() {
|
|
||||||
if (isSync.value) targetIdsText.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function goLog() {
|
|
||||||
router.push('/push-log');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHeaders(headers: Record<string, string> | undefined): string {
|
|
||||||
if (!headers) return '-';
|
|
||||||
return Object.entries(headers)
|
|
||||||
.map(([k, v]) => `${k}: ${v}`)
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatJson(val: any): string {
|
|
||||||
if (val == null) return '-';
|
|
||||||
if (typeof val === 'string') {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(JSON.parse(val), null, 2);
|
|
||||||
} catch {
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return JSON.stringify(val, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTargetIds(): number[] | null {
|
|
||||||
if (isSync.value) return null;
|
|
||||||
const txt = targetIdsText.value.trim();
|
|
||||||
if (!txt) return null;
|
|
||||||
const ids = txt
|
|
||||||
.split(/[,,\s]+/)
|
|
||||||
.map((s) => Number(s.trim()))
|
|
||||||
.filter((n) => Number.isFinite(n));
|
|
||||||
return ids.length ? ids : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSend() {
|
|
||||||
if (form.value.appId == null) {
|
|
||||||
Message.error('请选择订阅方应用');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!form.value.interfacePath) {
|
|
||||||
Message.error('请选择订阅接口');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sending.value = true;
|
|
||||||
preview.value = null;
|
|
||||||
sendMessage.value = '';
|
|
||||||
(Apis as any)
|
|
||||||
.PushSubscription.test({
|
|
||||||
data: {
|
|
||||||
appId: form.value.appId,
|
|
||||||
interfacePath: form.value.interfacePath,
|
|
||||||
targetIds: parseTargetIds(),
|
|
||||||
},
|
|
||||||
cacheFor: null,
|
|
||||||
})
|
|
||||||
.then((res: any) => {
|
|
||||||
preview.value = res?.preview || null;
|
|
||||||
sendMessage.value = res?.message || '已发送';
|
|
||||||
})
|
|
||||||
.catch((e: any) => console.warn(e?.message || '发送失败'))
|
|
||||||
.finally(() => (sending.value = false));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadApps() {
|
|
||||||
try {
|
|
||||||
const res = await (Apis as any).AppSettings.appList({
|
|
||||||
params: { pageIndex: 1, pageSize: 9999 },
|
|
||||||
cacheFor: null,
|
|
||||||
});
|
|
||||||
const list = (res && res.content) || [];
|
|
||||||
appOptions.value = list.map((a: any) => ({ value: a.id, label: `${a.name}(${a.appKey})` }));
|
|
||||||
} catch (e: any) {
|
|
||||||
appOptions.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadApps();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.main-wrapper {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-container {
|
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-card,
|
|
||||||
.result-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
margin: 0 0 6px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-desc {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
color: #808695;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-form {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.break-all {
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-pre,
|
|
||||||
.json-pre {
|
|
||||||
margin: 0;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: #f7f8fa;
|
|
||||||
border: 1px solid #e8eaec;
|
|
||||||
border-radius: 4px;
|
|
||||||
max-height: 280px;
|
|
||||||
overflow: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-pre {
|
|
||||||
max-height: 140px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -26,10 +26,16 @@
|
|||||||
:type="collapsedMap[String(item.cat.id)] ? 'ios-arrow-forward' : 'ios-arrow-down'" />
|
:type="collapsedMap[String(item.cat.id)] ? 'ios-arrow-forward' : 'ios-arrow-down'" />
|
||||||
<span class="cat-name" :title="item.cat.name">{{ item.cat.name }}</span>
|
<span class="cat-name" :title="item.cat.name">{{ item.cat.name }}</span>
|
||||||
<span class="cat-count">{{ item.treeData.length }}个应用</span>
|
<span class="cat-count">{{ item.treeData.length }}个应用</span>
|
||||||
|
<span class="cat-spacer"></span>
|
||||||
|
<!-- 开关外层 @click.stop:否则点击会冒泡到卡片头部触发折叠 -->
|
||||||
|
<span class="expand-all" @click.stop>
|
||||||
|
<span class="expand-all-label">全部展开:</span>
|
||||||
|
<Switch size="small" v-model="expandAllMap[String(item.cat.id)]" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="!collapsedMap[String(item.cat.id)]" class="cat-card-body">
|
<div v-show="!collapsedMap[String(item.cat.id)]" class="cat-card-body">
|
||||||
<MyTree :treeData="item.treeData" :setting="treeSetting" :autoClick="false"
|
<MyTree :treeData="item.treeData" :setting="treeSetting" :autoClick="false" :isCheck="true"
|
||||||
@on-tree-check="onTreeCheck" />
|
:expandAll="!!expandAllMap[String(item.cat.id)]" @on-tree-check="onTreeCheck" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,6 +53,7 @@ import { computed, ref, shallowRef, reactive, watch } from "vue";
|
|||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import Apis from "@/api";
|
import Apis from "@/api";
|
||||||
import MyTree from "@/components/MyTree.vue";
|
import MyTree from "@/components/MyTree.vue";
|
||||||
|
import { syncAppModulesFromTree, walkModules } from "@/utils/treeCascade";
|
||||||
|
|
||||||
const props = defineProps<{ roleId: number | null; roleName?: string }>();
|
const props = defineProps<{ roleId: number | null; roleName?: string }>();
|
||||||
|
|
||||||
@@ -57,7 +64,7 @@ type AppModuleVo = {
|
|||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
children?: AppModuleVo[];
|
children?: AppModuleVo[];
|
||||||
};
|
};
|
||||||
type AppItem = { id?: number; name?: string; sortNo?: number; modules?: AppModuleVo[] };
|
type AppItem = { id?: number; name?: string; sortNo?: number; checked?: boolean; modules?: AppModuleVo[] };
|
||||||
type CategoryItem = { id?: number; name?: string; apps?: AppItem[] };
|
type CategoryItem = { id?: number; name?: string; apps?: AppItem[] };
|
||||||
type AuthzTreeNode = {
|
type AuthzTreeNode = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -67,12 +74,12 @@ type AuthzTreeNode = {
|
|||||||
nodeType: "app" | "module";
|
nodeType: "app" | "module";
|
||||||
rawId: number;
|
rawId: number;
|
||||||
appId: number;
|
appId: number;
|
||||||
|
isParent?: boolean;
|
||||||
children?: AuthzTreeNode[];
|
children?: AuthzTreeNode[];
|
||||||
};
|
};
|
||||||
type CategoryTreeItem = { cat: CategoryItem; treeData: AuthzTreeNode[]; weight: number };
|
type CategoryTreeItem = { cat: CategoryItem; treeData: AuthzTreeNode[]; weight: number };
|
||||||
|
|
||||||
const COLUMN_COUNT = 3;
|
const COLUMN_COUNT = 3;
|
||||||
const jq = () => (window as any).$;
|
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const categoryList = shallowRef<CategoryItem[]>([]);
|
const categoryList = shallowRef<CategoryItem[]>([]);
|
||||||
@@ -81,13 +88,17 @@ const searchKeyword = ref("");
|
|||||||
const appliedKeyword = ref("");
|
const appliedKeyword = ref("");
|
||||||
const collapsedMap = reactive<Record<string, boolean>>({});
|
const collapsedMap = reactive<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
/** 各应用类型卡片的「全部展开」开关(默认关闭):状态随卡片独立保存,数据重载后仍生效 */
|
||||||
|
const expandAllMap = reactive<Record<string, boolean>>({});
|
||||||
|
|
||||||
const treeSetting = {
|
const treeSetting = {
|
||||||
view: { dblClickExpand: false, showIcon: false },
|
view: { dblClickExpand: false, showIcon: false },
|
||||||
check: {
|
check: {
|
||||||
enable: true,
|
enable: true,
|
||||||
chkStyle: "checkbox",
|
chkStyle: "checkbox",
|
||||||
// 不自动级联:模块逐条独立授权(角色无"子组继承"概念)
|
// 原生级联即可满足需求:
|
||||||
chkboxType: { Y: "", N: "" },
|
// Y="ps" 勾选子->父/祖先全部勾选、勾选父->子孙全部勾选;N="s" 取消父->子孙全部取消、取消子->父/祖先不受影响
|
||||||
|
chkboxType: { Y: "ps", N: "s" },
|
||||||
},
|
},
|
||||||
callback: {},
|
callback: {},
|
||||||
};
|
};
|
||||||
@@ -107,37 +118,48 @@ function countTotalModules(nodes: AppModuleVo[]): number {
|
|||||||
}
|
}
|
||||||
function isAppChecked(app: AppItem): boolean {
|
function isAppChecked(app: AppItem): boolean {
|
||||||
const modules = app.modules || [];
|
const modules = app.modules || [];
|
||||||
if (modules.length === 0) return false;
|
// 无子模块应用(如「一周安排」):勾选态来自后端返回的整应用授权记录
|
||||||
return modules.every((m) => isModuleAllChecked(m));
|
if (modules.length === 0) return !!app.checked;
|
||||||
|
// 父级展示口径:只要有一个子节点被勾选就算选中(不采用 zTree 半选,见样式里 true_part 的覆写)
|
||||||
|
return modules.some((m) => isModuleAnyChecked(m));
|
||||||
}
|
}
|
||||||
function isModuleAllChecked(node: AppModuleVo): boolean {
|
|
||||||
if (!node.checked) return false;
|
/** 自身或任一子孙被勾选 */
|
||||||
return (node.children || []).every((c) => isModuleAllChecked(c));
|
function isModuleAnyChecked(node: AppModuleVo): boolean {
|
||||||
|
if (node.checked) return true;
|
||||||
|
return (node.children || []).some((c) => isModuleAnyChecked(c));
|
||||||
}
|
}
|
||||||
|
// 空 children 不挂键:zTree 只要见到 children 是数组就强制 isParent=true 而渲染 +/−(叶子也会出现展开图标)
|
||||||
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
|
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
|
||||||
return {
|
const kids = (m.children || []).map((c) => buildModuleNode(c, appId));
|
||||||
|
const node: AuthzTreeNode = {
|
||||||
id: `mod_${m.id}`,
|
id: `mod_${m.id}`,
|
||||||
name: String(m.name || ""),
|
name: String(m.name || ""),
|
||||||
open: true,
|
open: true,
|
||||||
checked: !!m.checked,
|
checked: isModuleAnyChecked(m),
|
||||||
nodeType: "module",
|
nodeType: "module",
|
||||||
rawId: Number(m.id),
|
rawId: Number(m.id),
|
||||||
appId,
|
appId,
|
||||||
children: (m.children || []).map((c) => buildModuleNode(c, appId)),
|
isParent: kids.length > 0,
|
||||||
};
|
};
|
||||||
|
if (kids.length > 0) node.children = kids;
|
||||||
|
return node;
|
||||||
}
|
}
|
||||||
function buildAppNode(app: AppItem): AuthzTreeNode {
|
function buildAppNode(app: AppItem): AuthzTreeNode {
|
||||||
const appId = Number(app.id);
|
const appId = Number(app.id);
|
||||||
return {
|
const kids = (app.modules || []).map((m) => buildModuleNode(m, appId));
|
||||||
|
const node: AuthzTreeNode = {
|
||||||
id: `app_${appId}`,
|
id: `app_${appId}`,
|
||||||
name: String(app.name || ""),
|
name: String(app.name || ""),
|
||||||
open: true,
|
open: false,
|
||||||
checked: isAppChecked(app),
|
checked: isAppChecked(app),
|
||||||
nodeType: "app",
|
nodeType: "app",
|
||||||
rawId: appId,
|
rawId: appId,
|
||||||
appId,
|
appId,
|
||||||
children: (app.modules || []).map((m) => buildModuleNode(m, appId)),
|
isParent: kids.length > 0,
|
||||||
};
|
};
|
||||||
|
if (kids.length > 0) node.children = kids;
|
||||||
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- 数据加载 ----------------
|
// ---------------- 数据加载 ----------------
|
||||||
@@ -228,57 +250,20 @@ function findLocalApp(appId: number): AppItem | null {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
function walkModules(nodes: AppModuleVo[], fn: (n: AppModuleVo) => void) {
|
// walkModules / 本地勾选态同步(syncAppModulesFromTree)统一在 utils/treeCascade.ts
|
||||||
for (const node of nodes) {
|
|
||||||
fn(node);
|
|
||||||
walkModules(node.children || [], fn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function setLocalAppChecked(appId: number, checked: boolean) {
|
|
||||||
const app = findLocalApp(appId);
|
|
||||||
if (!app) return;
|
|
||||||
walkModules(app.modules || [], (n) => { n.checked = checked; });
|
|
||||||
}
|
|
||||||
function setLocalModuleState(appId: number, moduleId: number, patch: Partial<AppModuleVo>) {
|
|
||||||
const app = findLocalApp(appId);
|
|
||||||
if (!app) return;
|
|
||||||
walkModules(app.modules || [], (n) => { if (Number(n.id) === moduleId) Object.assign(n, patch); });
|
|
||||||
}
|
|
||||||
function getTreeObjByNode(node: any) {
|
|
||||||
const $ = jq();
|
|
||||||
const treeId = String(node?.tId || "").replace(/_\d+$/, "");
|
|
||||||
return $?.fn?.zTree?.getZTreeObj(treeId) || null;
|
|
||||||
}
|
|
||||||
function syncAppNodeState(treeObj: any, appId: number) {
|
|
||||||
if (!treeObj) return;
|
|
||||||
const app = findLocalApp(appId);
|
|
||||||
if (!app) return;
|
|
||||||
const appNode = treeObj.getNodesByFilter((n: any) => n.id === `app_${appId}`, true);
|
|
||||||
if (!appNode) return;
|
|
||||||
appNode.checked = isAppChecked(app);
|
|
||||||
treeObj.checkNode(appNode, appNode.checked, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------- 授权操作(勾选即保存,全量覆盖 + 防抖) ----------------
|
// ---------------- 授权操作(级联由 zTree 原生 chkboxType 完成,勾选即保存,全量覆盖 + 防抖) ----------------
|
||||||
function onTreeCheck(node: any) {
|
function onTreeCheck(node: any) {
|
||||||
const roleId = props.roleId;
|
const roleId = props.roleId;
|
||||||
if (!roleId || !node) return;
|
if (!roleId || !node) return;
|
||||||
const checked = !!node.checked;
|
// zTree 原生级联已生效,把该应用最终勾选态同步回本地数据(saveNow 的提交依据)
|
||||||
const isApp = node.nodeType === "app";
|
const app = findLocalApp(node.appId);
|
||||||
const treeObj = getTreeObjByNode(node);
|
if (!app) return;
|
||||||
|
if ((app.modules || []).length === 0) {
|
||||||
if (isApp) {
|
// 无子模块应用:没有模块明细可同步,直接记整应用勾选态
|
||||||
setLocalAppChecked(node.appId, checked);
|
app.checked = !!node.checked;
|
||||||
if (treeObj) {
|
|
||||||
const affected = treeObj.transformToArray([node]) || [];
|
|
||||||
affected.forEach((n: any) => {
|
|
||||||
if (n !== node) treeObj.checkNode(n, checked, false);
|
|
||||||
n.checked = checked;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setLocalModuleState(node.appId, node.rawId, { checked });
|
syncAppModulesFromTree(app, node);
|
||||||
syncAppNodeState(treeObj, node.appId);
|
|
||||||
}
|
}
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
}
|
}
|
||||||
@@ -289,7 +274,14 @@ const saveNow = async () => {
|
|||||||
const moduleIds: number[] = [];
|
const moduleIds: number[] = [];
|
||||||
for (const cat of categoryList.value || []) {
|
for (const cat of categoryList.value || []) {
|
||||||
for (const app of cat.apps || []) {
|
for (const app of cat.apps || []) {
|
||||||
walkModules(app.modules || [], (n) => { if (n.checked) moduleIds.push(Number(n.id)); });
|
const mods = app.modules || [];
|
||||||
|
if (mods.length === 0) {
|
||||||
|
// 无子模块应用:用 -appId 哨兵表示「整应用授权」(后端 AppLevelModuleId 约定),
|
||||||
|
// 否则这类应用永远收集不到 id、保存时被整表覆盖清掉。
|
||||||
|
if (app.checked) moduleIds.push(-Number(app.id));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
walkModules(mods, (n) => { if (n.checked) moduleIds.push(Number(n.id)); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -323,7 +315,7 @@ watch(
|
|||||||
.role-authz-tree {
|
.role-authz-tree {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 320px;
|
min-height: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -367,7 +359,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.role-name {
|
.role-name {
|
||||||
color: #2d8cf0;
|
color: var(--brand);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,6 +410,9 @@ watch(
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
|
/* 列宽有限:分类名优先让位(省略号),保证右侧「全部展开」不被挤出可视区 */
|
||||||
|
flex: 0 999 auto;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -427,20 +422,48 @@ watch(
|
|||||||
color: #999;
|
color: #999;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
flex: 1;
|
flex: 0 10 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 弹性空隙:把「全部展开」整组推到卡片头右端 */
|
||||||
|
.cat-spacer {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.cat-card-body {
|
.cat-card-body {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.expand-all {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-all-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-tip {
|
.empty-tip {
|
||||||
color: #999;
|
color: #999;
|
||||||
padding: 48px 0;
|
padding: 48px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 父级勾选样式(2026-10-20):把 zTree 半选状态位 true_part 的图标换成 true_full 的蓝底勾,
|
||||||
|
让「只要有一个子节点被选中」的父节点统一呈现为选中态(与组授权页口径一致)。 */
|
||||||
|
:deep(.ztree li span.button.chk.checkbox_true_part),
|
||||||
|
:deep(.ztree li span.button.chk.checkbox_true_part_focus) {
|
||||||
|
background-position: -26px -5px;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.ztree li a) {
|
:deep(.ztree li a) {
|
||||||
max-width: calc(100% - 30px);
|
max-width: calc(100% - 30px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper role-authz-wrapper">
|
<div class="page-wrapper tree-wrapper role-authz-wrapper">
|
||||||
<!-- 左:角色列表(可滚动,应对大量角色;顶部 2 个筛选) -->
|
<!-- 左:角色列表(可滚动,应对大量角色;顶部 2 个筛选) -->
|
||||||
<div class="role-list-panel">
|
<div class="tree-container role-list-panel">
|
||||||
<div class="role-filter">
|
<div class="role-filter">
|
||||||
<Select v-model="filterAppId" placeholder="按绑定应用筛选" filterable clearable
|
<Select v-model="filterAppId" placeholder="按绑定应用筛选" filterable clearable
|
||||||
@on-change="onFilterChange">
|
@on-change="onFilterChange">
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
:class="['role-item', selectedRoleId === role.id ? 'active' : '']" @click="selectRole(role)">
|
:class="['role-item', selectedRoleId === role.id ? 'active' : '']" @click="selectRole(role)">
|
||||||
<div class="role-name">{{ role.name }}</div>
|
<div class="role-name">{{ role.name }}</div>
|
||||||
<div class="role-app">
|
<div class="role-app">
|
||||||
<Tag v-if="role.allAppFlag" color="blue" size="small">全应用</Tag>
|
<Tag v-if="role.allAppFlag" class="tag-brand" border size="small">全应用</Tag>
|
||||||
<span v-else>{{ role.appName || "-" }}</span>
|
<span v-else>{{ role.appName || "-" }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,12 +31,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右:选中角色的授权面板(应用授权 / 用户管理) -->
|
<!-- 右:选中角色的授权面板(应用授权 / 用户管理) -->
|
||||||
<div class="authz-panel">
|
<div class="main-container authz-panel">
|
||||||
<div v-if="selectedRole" class="authz-inner">
|
<div v-if="selectedRole" class="authz-inner">
|
||||||
<div class="authz-bar">
|
<div class="authz-bar">
|
||||||
<span class="authz-title">当前角色:<b>{{ selectedRole.name }}</b></span>
|
<span class="authz-title">当前角色:<b>{{ selectedRole.name }}</b></span>
|
||||||
<span class="authz-app">
|
<span class="authz-app">
|
||||||
<Tag v-if="selectedRole.allAppFlag" color="blue">全应用</Tag>
|
<Tag v-if="selectedRole.allAppFlag" class="tag-brand" border>全应用</Tag>
|
||||||
<span v-else>{{ selectedRole.appName || "-" }}</span>
|
<span v-else>{{ selectedRole.appName || "-" }}</span>
|
||||||
</span>
|
</span>
|
||||||
<Tag v-if="selectedRole.disabled" color="red">已禁用</Tag>
|
<Tag v-if="selectedRole.disabled" color="red">已禁用</Tag>
|
||||||
@@ -152,19 +152,12 @@ function goManage() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.role-authz-wrapper {
|
/* 通道 tree-wrapper 骨架:外边界 16 / 两卡间隙 16 / 等高 stretch,高度对齐列表页 */
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
background: #f5f7fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-list-panel {
|
.role-list-panel {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
flex-shrink: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #ffffff;
|
|
||||||
border-right: 1px solid #ebedf0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,12 +189,12 @@ function goManage() {
|
|||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #b8c6ff;
|
border-color: var(--brand-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
border-color: #577afb;
|
border-color: var(--brand);
|
||||||
background: #f3f6ff;
|
background: var(--brand-soft);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,18 +228,21 @@ function goManage() {
|
|||||||
border-top: 1px solid #eeeeee;
|
border-top: 1px solid #eeeeee;
|
||||||
|
|
||||||
.ivu-btn-text {
|
.ivu-btn-text {
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.authz-panel {
|
.authz-panel {
|
||||||
flex: 1;
|
display: flex;
|
||||||
min-width: 0;
|
flex-direction: column;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
padding: 0 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.authz-inner {
|
.authz-inner {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,10 +251,18 @@ function goManage() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 0 4px;
|
padding: 8px 0 4px;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authz-title b {
|
.authz-title b {
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题色描边徽标(iView color 预设是写死蓝,改挂通道令牌随档变化) */
|
||||||
|
.tag-brand {
|
||||||
|
color: var(--brand) !important;
|
||||||
|
border: 1px solid var(--brand) !important;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authz-app {
|
.authz-app {
|
||||||
@@ -268,9 +272,13 @@ function goManage() {
|
|||||||
|
|
||||||
.authz-tabs {
|
.authz-tabs {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authz-tab-content {
|
.authz-tab-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -158,10 +158,10 @@ function goAuthz() {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #577afb;
|
color: var(--brand);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #6c91f0;
|
color: var(--brand-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
roleId: number;
|
roleId: number;
|
||||||
existingUserIds: number[];
|
/** 已有「用户×组」绑定明细,用于按 (用户, 当前选中组) 去重/禁用 */
|
||||||
|
existingBindings: { id: number; groupId: number }[];
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -60,7 +61,10 @@ const saving = ref(false);
|
|||||||
// 组织树数据源(与组用户管理一致)
|
// 组织树数据源(与组用户管理一致)
|
||||||
const treeApi = () => Apis.SysGroup.getTree();
|
const treeApi = () => Apis.SysGroup.getTree();
|
||||||
|
|
||||||
const existingSet = computed(() => new Set(props.existingUserIds ?? []));
|
// 已有绑定集合:键为 `${userId}:${groupId}`,同一用户可在不同组下分别绑定
|
||||||
|
const existingKeys = computed(() =>
|
||||||
|
new Set((props.existingBindings ?? []).map(b => `${b.id}:${b.groupId}`))
|
||||||
|
);
|
||||||
|
|
||||||
const filteredCandidates = computed(() => {
|
const filteredCandidates = computed(() => {
|
||||||
const k = kw.value.trim().toLowerCase();
|
const k = kw.value.trim().toLowerCase();
|
||||||
@@ -73,7 +77,7 @@ const filteredCandidates = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function isExisting(u: any) {
|
function isExisting(u: any) {
|
||||||
return existingSet.value.has(u.id);
|
return groupId.value != null && existingKeys.value.has(`${u.id}:${groupId.value}`);
|
||||||
}
|
}
|
||||||
function isChecked(u: any) {
|
function isChecked(u: any) {
|
||||||
return isExisting(u) || selectedIds.value.has(u.id);
|
return isExisting(u) || selectedIds.value.has(u.id);
|
||||||
@@ -88,6 +92,8 @@ function toggle(u: any, v: boolean) {
|
|||||||
|
|
||||||
async function onTreeChange(node: any) {
|
async function onTreeChange(node: any) {
|
||||||
groupId.value = node?.id ?? null;
|
groupId.value = node?.id ?? null;
|
||||||
|
// 绑定记「勾选时所在组」:切换组即清空勾选,避免把 A 组下勾的人误绑到 B 组
|
||||||
|
selectedIds.value = new Set();
|
||||||
if (!groupId.value) {
|
if (!groupId.value) {
|
||||||
candidates.value = [];
|
candidates.value = [];
|
||||||
return;
|
return;
|
||||||
@@ -113,12 +119,25 @@ function onVisibleChange(v: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (!groupId.value) {
|
||||||
|
Message.warning("请先选择左侧组织节点");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedIds.value.size) {
|
||||||
|
Message.warning("请先勾选要添加的用户");
|
||||||
|
return;
|
||||||
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
// 全量覆盖:现有绑定 ∪ 本次新选
|
// 增量添加「用户×组」绑定(组 = 左侧树当前选中组);后端对已存在的 (userId, groupId) 幂等跳过
|
||||||
const all = new Set<number>([...existingSet.value, ...selectedIds.value]);
|
|
||||||
await Apis.AppRole.saveRoleUsers(
|
await Apis.AppRole.saveRoleUsers(
|
||||||
{ data: { roleId: props.roleId, userIds: [...all] }, cacheFor: null }
|
{
|
||||||
|
data: {
|
||||||
|
roleId: props.roleId,
|
||||||
|
bindings: [...selectedIds.value].map(id => ({ userId: id, groupId: groupId.value as number })),
|
||||||
|
},
|
||||||
|
cacheFor: null,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
Message.success("添加成功");
|
Message.success("添加成功");
|
||||||
emit("success");
|
emit("success");
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="role-user-manage">
|
<div class="role-user-manage">
|
||||||
<div class="rum-toolbar">
|
<div class="rum-toolbar">
|
||||||
<span class="rum-count">共 {{ userList.length }} 位用户</span>
|
<span class="rum-count">共 {{ userList.length }} 条绑定</span>
|
||||||
<Button type="primary" icon="md-person-add" :disabled="!roleId" @click="openBind">添加用户</Button>
|
<Button type="primary" icon="md-person-add" :disabled="!roleId" @click="openBind">添加用户</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table :columns="columns" :data="userList" :loading="loading" size="small" border></Table>
|
<Table :columns="columns" :data="userList" :loading="loading" size="small" border></Table>
|
||||||
|
|
||||||
<RoleUserBindModal v-model:visible="bindVisible" :role-id="roleId" :existing-user-ids="existingIds"
|
<RoleUserBindModal v-model:visible="bindVisible" :role-id="roleId" :existing-bindings="existingBindings"
|
||||||
@success="onBindSuccess" />
|
@success="onBindSuccess" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -16,11 +16,9 @@
|
|||||||
import { ref, computed, watch } from "vue";
|
import { ref, computed, watch } from "vue";
|
||||||
import { Message } from "view-ui-plus";
|
import { Message } from "view-ui-plus";
|
||||||
import Apis from "@/api";
|
import Apis from "@/api";
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
import RoleUserBindModal from "./RoleUserBindModal.vue";
|
import RoleUserBindModal from "./RoleUserBindModal.vue";
|
||||||
|
import { actionColumn } from "@/utils/tableActions";
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
roleId: number;
|
roleId: number;
|
||||||
@@ -31,16 +29,23 @@ const userList = ref<any[]>([]);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const bindVisible = ref(false);
|
const bindVisible = ref(false);
|
||||||
|
|
||||||
// 当前角色已绑定的用户 id 集合(传给弹窗做去重/禁用)
|
// 当前角色已绑定的「用户×组」明细(传给弹窗按 (用户, 组) 去重/禁用)
|
||||||
const existingIds = computed(() => userList.value.map((u: any) => u.id));
|
const existingBindings = computed(() =>
|
||||||
|
userList.value
|
||||||
|
.filter((u: any) => u.id != null && u.groupId != null)
|
||||||
|
.map((u: any) => ({ id: u.id as number, groupId: u.groupId as number }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 部门列为查询时按 groupId 拼接的组路径(非超管按数据范围裁剪,学校管理员只看到本校路径)
|
||||||
|
// 账号/姓名用定宽、部门用 minWidth 吃剩余宽度,把空间留给组路径
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: "账号", key: "account", minWidth: 140 },
|
{ title: "账号", key: "account", width: 110 },
|
||||||
{ title: "姓名", key: "realName", minWidth: 110 },
|
{ title: "姓名", key: "realName", width: 90 },
|
||||||
{
|
{ title: "部门", key: "groupPath", minWidth: 200 },
|
||||||
title: "操作", key: "action", width: 90, align: "center",
|
// UserBase 恒文字链(工程风格显式声明):单删除动作保持红色文字链,列宽 80 不变
|
||||||
render: (h: any, { row }: any) => $utils.operatingTextHandle(h, "删除", "text-error", () => removeUser(row)),
|
actionColumn([
|
||||||
},
|
{ key: "del", label: "删除", icon: "icon-ashbin", on: (r: any) => removeUser(r), danger: true },
|
||||||
|
], { mode: "text", width: 80 }),
|
||||||
];
|
];
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
@@ -61,13 +66,13 @@ async function loadUsers() {
|
|||||||
|
|
||||||
function removeUser(row: any) {
|
function removeUser(row: any) {
|
||||||
MyConfirm({
|
MyConfirm({
|
||||||
title: "移除用户",
|
title: "移除绑定",
|
||||||
msg: `确定将用户【${row.realName || row.account}】从此角色移除?`,
|
msg: `确定将用户【${row.realName || row.account}】在部门【${row.groupPath || '未知'}】下的该角色移除?`,
|
||||||
info: "移除后该用户不再拥有此角色所授权的权限。",
|
info: "移除后该用户在该组下不再拥有此角色所授权的权限;其在其他组下的绑定不受影响。",
|
||||||
async onOk() {
|
async onOk() {
|
||||||
try {
|
try {
|
||||||
await Apis.AppRole.removeRoleUser(
|
await Apis.AppRole.removeRoleUser(
|
||||||
{ params: { roleId: props.roleId, userId: row.id }, cacheFor: null }
|
{ params: { roleId: props.roleId, userId: row.id, groupId: row.groupId }, cacheFor: null }
|
||||||
);
|
);
|
||||||
Message.success("已移除");
|
Message.success("已移除");
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
|
|||||||
Vendored
+3
@@ -1,5 +1,8 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// import.meta.env 类型声明:VITE_* 仅供本地开发(.env.dev,vite dev --mode dev)读取;
|
||||||
|
// build(production mode)无 .env/.env.production,VITE_* 恒为 undefined,不会进构建产物。
|
||||||
|
// 线上环境差异由 env-config 运行时配置承担(见 host.config.ts)。
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_BASE_HOST: string;
|
readonly VITE_BASE_HOST: string;
|
||||||
readonly VITE_SSO_HOST: string;
|
readonly VITE_SSO_HOST: string;
|
||||||
|
|||||||
+34
-13
@@ -11,10 +11,14 @@ import presetUno from "@unocss/preset-uno";
|
|||||||
|
|
||||||
import presetRemToPx from "@unocss/preset-rem-to-px";
|
import presetRemToPx from "@unocss/preset-rem-to-px";
|
||||||
|
|
||||||
|
// 构建输出:兼容既有 CI 的 mode 子目录(build:prod → dist/prod、build:ld → dist/ld、build:base → dist/base);
|
||||||
|
// 新命令 `vite build`(默认 production mode)→ dist/。.env.base/ld/prod 均已删除,
|
||||||
|
// 各 mode 产物内容完全相同(VITE_* 恒 undefined),不存在按环境烧值问题。
|
||||||
|
// dev 读 .env.dev(import.meta.env.VITE_*);build 时 .env/.env.production 不存在 → VITE_* 全 undefined,
|
||||||
|
// host.config.ts 自动跳过 → 线上仅走 env-config 接口。注意:勿新建 .env.production / .env。
|
||||||
export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
||||||
// 构建输出目录:base -> dist/base, ld -> dist/ld, prod -> dist/prod, 默认 dist/base
|
const subDir = mode === "ld" || mode === "prod" ? mode : mode === "base" ? mode : "";
|
||||||
const subDir = mode === "ld" || mode === "prod" ? mode : "base";
|
const outDir = command === "build" && subDir ? `dist/${subDir}` : "dist";
|
||||||
const outDir = command === "build" ? `dist/${subDir}` : "dist";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -95,15 +99,32 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
|||||||
server: {
|
server: {
|
||||||
host: "localhost",
|
host: "localhost",
|
||||||
port: 8130,
|
port: 8130,
|
||||||
// proxy: {
|
proxy: {
|
||||||
// // //api是自行设置的请求前缀,任何请求路径以/api开头的请求将被代理到对应的target目标
|
// 主题通道本地联调(唯一必需项):dev 下 index.html 的 loader 走同源相对(见其注释的优先级四档),
|
||||||
// // "/api": {
|
// manifest/CSS 经此代理到中央通道;changeOrigin 顺带把 Host 改写成目标域,Caddy 按 Host 匹配站点。
|
||||||
// // target: "http://localhost:8064/",
|
// 联调本地通道源:YschTheme 侧 npm run serve:static(默认 9527),再用
|
||||||
// // // target: "http://debugold.hongyucloud.com/ChildOptCourse",
|
// YH_THEME_PROXY_TARGET=http://localhost:9527 覆盖本 target。prod 同源用不到这段。
|
||||||
// // changeOrigin: true, //需要代理跨域
|
"/static": {
|
||||||
// // rewrite: (path: any) => path.replace(/^\/api/, "/api/"), //路径重写,把'/api'替换为''
|
target: process.env.YH_THEME_PROXY_TARGET || "http://base.yunhaixingtong.com",
|
||||||
// // },
|
changeOrigin: true,
|
||||||
// },
|
},
|
||||||
|
// 后端接口同源代理:alova 的 baseURL 在非 mock 时取 .env 的 VITE_SERVICE_HOST,
|
||||||
|
// 想"只换后端不改 .env"就把 VITE_SERVICE_HOST 留空 → 请求落同源 /api、/mobiapi → 由此处代理决定去向。
|
||||||
|
// 本地后端(默认):npm run dev
|
||||||
|
// 线上后端: YH_API_PROXY_TARGET=http://base.yunhaixingtong.com/userbase npm run dev
|
||||||
|
// (changeOrigin 已改写 Host,Caddy 按 Host 匹配;但 CAS 登录 cookie 属
|
||||||
|
// yunhaixingtong 域,localhost 侧带不上 → 未登录接口会被 302 到 SSO,
|
||||||
|
// 需要真数据时优先配合 VITE_IS_MOCK=false + 本地后端,或先在同浏览器登录线上)
|
||||||
|
// mock 模式(VITE_IS_MOCK=true)下已注册的接口由 vite-plugin-mock 直接应答,未注册的仍透传到本 target。
|
||||||
|
"/api": {
|
||||||
|
target: process.env.YH_API_PROXY_TARGET || "http://localhost:8060",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
"/mobiapi": {
|
||||||
|
target: process.env.YH_API_PROXY_TARGET || "http://localhost:8060",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// css预处理器
|
// css预处理器
|
||||||
css: {
|
css: {
|
||||||
@@ -116,7 +137,7 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
|||||||
base: "./",
|
base: "./",
|
||||||
build: {
|
build: {
|
||||||
target: "modules", //设置最终构建的浏览器兼容目标 //es2015(编译成es5) | modules
|
target: "modules", //设置最终构建的浏览器兼容目标 //es2015(编译成es5) | modules
|
||||||
outDir, // 构建输出目录,根据环境变量区分 base/ld
|
outDir, // 构建输出目录:兼容 CI 的 dist/{prod|ld|base} 子目录(见文件头注释)
|
||||||
assetsDir: "assets", // 静态资源得存放路径文件名 assets
|
assetsDir: "assets", // 静态资源得存放路径文件名 assets
|
||||||
sourcemap: false, //构建后是否生成 source map 文件
|
sourcemap: false, //构建后是否生成 source map 文件
|
||||||
minify: "esbuild", // 项目压缩 :boolean | 'terser' | 'esbuild'
|
minify: "esbuild", // 项目压缩 :boolean | 'terser' | 'esbuild'
|
||||||
|
|||||||
Reference in New Issue
Block a user