Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
679a15d1e1 | ||
|
|
f9ea615e97 | ||
|
|
b1b16332e4 | ||
|
|
c7404b9d5e | ||
|
|
7e37f2ada6 | ||
|
|
9200fa2135 | ||
|
|
790f0e3db5 | ||
|
|
428ee56bd3 | ||
|
|
886cce1b9e | ||
|
|
73066b5ab3 | ||
|
|
9c04b5e9fc | ||
|
|
f5c1289681 | ||
|
|
049965f51b | ||
|
|
35529fc671 | ||
|
|
eb0ab76c83 | ||
|
|
bf6439888e | ||
|
|
a096948371 | ||
|
|
5247515a85 | ||
|
|
02f1fd2b14 | ||
|
|
8fd5a21fcc | ||
|
|
58a606764b | ||
|
|
4b73cc7b29 | ||
|
|
89e7c08b2f | ||
|
|
2321571060 | ||
|
|
0fa4b0f38a | ||
|
|
b96bbc9a62 | ||
|
|
85619785f6 | ||
|
|
4077ef64d8 | ||
|
|
82f4c623af | ||
|
|
648c32c29e | ||
|
|
bf2d800c63 | ||
|
|
4adf38af77 | ||
|
|
020911419e | ||
|
|
06ee85cf8d | ||
|
|
eea881b616 |
@@ -0,0 +1,9 @@
|
|||||||
|
# 开发环境 (dev):仅 vite dev 加载(--mode dev)。值可自由指本地或线上地址。
|
||||||
|
# build 产物不读此文件(build 默认 production mode,只认 .env / .env.production,两者均不存在),
|
||||||
|
# 线上环境差异全部由 projman 的 env-config-{目标}.json 承担。
|
||||||
|
VITE_BASE_HOST=http://base.bestsch.com
|
||||||
|
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
||||||
|
VITE_SERVICE_HOST=http://localhost:8060
|
||||||
|
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
|
||||||
|
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
|
||||||
|
VITE_IS_MOCK=false
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
name: ci-userbase-service
|
|
||||||
|
|
||||||
# 后端标准化 CI:maven 构建 jar -> docker build -> 推 Nexus docker-hosted
|
|
||||||
# 触发:push 到 main(仅 service 或本文件变更)/ 手动 Run workflow
|
|
||||||
# 依赖仓库 Actions Secrets:NEXUS_USER / NEXUS_PASSWORD(ci-docker 账号,仅 docker-hosted 推送权)
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'service/**'
|
|
||||||
- '.gitea/workflows/ci.yml'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: 'image tag (default = short sha)'
|
|
||||||
required: false
|
|
||||||
default: 'auto'
|
|
||||||
deploy:
|
|
||||||
description: 'deploy to 160 after build (auto / skip)'
|
|
||||||
required: false
|
|
||||||
default: 'auto'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# docker:24 自带 docker CLI,复用宿主 docker daemon。
|
|
||||||
# 注意:act_runner v0.6.1 默认已把宿主 docker.sock 挂入每个 job 容器,
|
|
||||||
# 切勿在此再声明 volumes: docker.sock(会与默认挂载重复,报
|
|
||||||
# "Duplicate mount point" 导致容器创建失败、全部 step cancelled)。
|
|
||||||
container:
|
|
||||||
image: docker:24
|
|
||||||
steps:
|
|
||||||
# docker:24 基于 alpine,默认无 git;先装 git 再手动 clone 本仓库
|
|
||||||
- name: Install git (alpine)
|
|
||||||
run: |
|
|
||||||
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
|
||||||
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
|
||||||
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
|
|
||||||
|
|
||||||
- name: Compute image tag
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
if [ "${{ inputs.tag }}" = "auto" ] || [ -z "${{ inputs.tag }}" ]; then
|
|
||||||
echo "tag=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build jar with Maven
|
|
||||||
run: |
|
|
||||||
# 注意:job 容器通过 docker.sock 调的是【宿主 docker daemon】,
|
|
||||||
# 因此 docker run -v <path> 里的路径会被宿主按【宿主路径】解析;
|
|
||||||
# 而 act 工作区是宿主上的 docker volume,容器内 $PWD 对宿主不可见,
|
|
||||||
# 直接 -v $PWD:/build 会绑成空目录导致找不到 pom.xml。
|
|
||||||
# 故改为:起 maven 容器 -> docker cp 源码入容器 -> exec 构建 -> cp 产物回工作区。
|
|
||||||
# mavenbuild 加入 infra-infra-net,用容器名 nexus 直连 Nexus(8081 maven-public)。
|
|
||||||
# 之前用 --add-host=nexus.yunhaixingtong.com:host-gateway 经 Caddy:80 反代,但该路径在
|
|
||||||
# CI 容器内不可达,maven 回退到 /opt/docker/ubs-m2 里 08-05 的旧 yh-auth 缓存(缺
|
|
||||||
# CasSsoAutoConfiguration),镜像启动报 serverName 崩溃。现改用 settings.xml mirror 把
|
|
||||||
# pom 的 HTTP 仓库重定向到 http://nexus:8081/repository/maven-public/(已验证 -7 可达)。
|
|
||||||
# 先清理上次失败残留的同名容器(set -e 下末尾的 rm 不会执行)
|
|
||||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
|
||||||
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
|
||||||
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
|
||||||
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
|
||||||
BUILD_CONTAINER="mavenbuild-${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" \
|
|
||||||
--network infra-infra-net \
|
|
||||||
-v /opt/docker/ubs-m2:/root/.m2 \
|
|
||||||
-w /build maven:3.6.3-jdk-8 sleep 3600
|
|
||||||
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
|
||||||
# 写 settings.xml:新增 profile 把 nexus:8081 maven-public 作为额外仓库(infra-infra-net 内容器名直连,已验证 -7 可达)。
|
|
||||||
# 不用 mirror(external:http:*) 是因为它对 BOM import 解析未生效;profile 方式更可靠。
|
|
||||||
# pom 原有的 nexus.yunhaixingtong.com(Caddy:80) 在 CI 容器内不可解析 → 快速失败,maven 转而用本仓库。
|
|
||||||
docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
|
|
||||||
# 仅清旧 yh-auth 缓存(含 08-05 local install 的 maven-metadata-local.xml,避免本地安装优先于远端 -7);
|
|
||||||
# BOM 等其它 yh.platform 模块保留缓存(Nexus 上它们仍是 08-05 的 -1,缓存与远端一致)。
|
|
||||||
docker exec "$BUILD_CONTAINER" rm -rf /root/.m2/repository/com/yunhai/platform/yh-auth-spring-boot-starter
|
|
||||||
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/service && mvn -U -B -DskipTests clean package'
|
|
||||||
mkdir -p "${PWD}/service/target"
|
|
||||||
docker cp "$BUILD_CONTAINER":/build/service/target/userbase.jar "${PWD}/service/target/userbase.jar"
|
|
||||||
docker rm -f "$BUILD_CONTAINER"
|
|
||||||
|
|
||||||
- name: Login to Nexus docker registry
|
|
||||||
run: echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u ${{ secrets.NEXUS_USER }} --password-stdin
|
|
||||||
|
|
||||||
- name: Build image
|
|
||||||
run: docker build -t nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }} -f service/docker/Dockerfile service
|
|
||||||
|
|
||||||
- name: Push image
|
|
||||||
run: docker push nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }}
|
|
||||||
|
|
||||||
- name: Report build
|
|
||||||
run: echo "pushed nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }}"
|
|
||||||
|
|
||||||
# —— 部署阶段(仅在 push 或手动 dispatch deploy=auto 时执行)——
|
|
||||||
# 运行在 160 的 act_runner 上,复用宿主 docker daemon(docker.sock 由 act_runner 自动挂载),
|
|
||||||
# 因此 docker pull / run 直接作用于 160 宿主,等价于在 160 上执行部署。
|
|
||||||
- name: Deploy (pull + rollout with rollback)
|
|
||||||
if: github.event_name == 'push' || inputs.deploy == 'auto'
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
IMG=nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }}
|
|
||||||
echo "deploying $IMG"
|
|
||||||
echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u ${{ secrets.NEXUS_USER }} --password-stdin
|
|
||||||
docker pull "$IMG"
|
|
||||||
# 回滚锚:保留当前 userbase-service:latest(已知良好镜像),再额外打一个时间点标签
|
|
||||||
RB=userbase-service:rb-$(date +%s)
|
|
||||||
docker tag userbase-service:latest "$RB" 2>/dev/null || true
|
|
||||||
docker stop userbase-service 2>/dev/null; docker rm -f userbase-service 2>/dev/null
|
|
||||||
# 注意(SCA 2.2.4 坑): 命名空间必须用标准属性名 SPRING_CLOUD_NACOS_CONFIG_NAMESPACE,
|
|
||||||
# 用 NACOS_NAMESPACE 这个 env 名不会被 bootstrap 的 ${NACOS_NAMESPACE:dev} 解析到(会落 public);
|
|
||||||
# 凭据必须放顶层 SPRING_CLOUD_NACOS_USERNAME/PASSWORD, 放在 spring.cloud.nacos.config.* 下不生效(403)。
|
|
||||||
docker run -d --name userbase-service --restart unless-stopped --network infra-infra-net -p 8060:8060 \
|
|
||||||
-e NACOS_ADDR=nacos:8848 \
|
|
||||||
-e SPRING_CLOUD_NACOS_CONFIG_NAMESPACE=6196f0de-a28d-4894-8f10-e2982b57c6bc \
|
|
||||||
-e SPRING_CLOUD_NACOS_USERNAME=yh-reader \
|
|
||||||
-e SPRING_CLOUD_NACOS_PASSWORD=123456 \
|
|
||||||
"$IMG"
|
|
||||||
# 健康检查:Spring 起来后 /userbase/ 返回 404(根路径无映射控制器),000 表示未起来
|
|
||||||
OK=0
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
sleep 5
|
|
||||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8060/userbase/ 2>/dev/null || true)
|
|
||||||
if [ "$CODE" != "000" ]; then OK=1; break; fi
|
|
||||||
done
|
|
||||||
if [ "$OK" = "0" ]; then
|
|
||||||
echo "!! UNHEALTHY -> rollback to userbase-service:latest"
|
|
||||||
docker stop userbase-service 2>/dev/null; docker rm -f userbase-service 2>/dev/null
|
|
||||||
docker run -d --name userbase-service --restart unless-stopped --network infra-infra-net -p 8060:8060 userbase-service:latest
|
|
||||||
echo "rolled back"; exit 7
|
|
||||||
fi
|
|
||||||
echo "DEPLOY OK: $IMG"
|
|
||||||
docker tag "$IMG" userbase-service:latest
|
|
||||||
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | grep userbase || true
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
name: deploy-userbase-front
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'front/**'
|
|
||||||
- '.gitea/workflows/deploy-front.yml'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# node:20 自带 npm 与 git;通过已配置的 registry-mirrors 拉取
|
|
||||||
# /data/www/userbase 与 docker.sock 由 act_runner 全局 options 统一挂载(见 act-runner config)
|
|
||||||
container:
|
|
||||||
image: node:20
|
|
||||||
# 注意:本环境 act_runner v0.6.1 既不生效 container.options 的额外 --volume,
|
|
||||||
# 也不生效 workflow 的 container.volumes(自定义卷被静默忽略),
|
|
||||||
# 但它会默认把宿主 docker.sock 挂入每个 job 容器。
|
|
||||||
# 因此部署改用 scripts/deploy-via-docker.js:通过该 docker.sock 启动一次性
|
|
||||||
# helper 容器,把构建产物拷到宿主 /data/www/userbase/front。
|
|
||||||
steps:
|
|
||||||
# 内网 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
|
|
||||||
|
|
||||||
- name: Use npm mirror (npmmirror)
|
|
||||||
working-directory: front
|
|
||||||
run: npm config set registry https://registry.npmmirror.com
|
|
||||||
|
|
||||||
# node-sass(6.x) 无 Node20 预编译二进制,源码编译必失败;项目实际用 dart-sass(sass)。
|
|
||||||
# 本地 lockfile 暂不便修改,这里在 runner 内把它从 lockfile 剔除后再 npm ci(不改仓库文件)。
|
|
||||||
- name: Strip node-sass from lockfile (CI-only)
|
|
||||||
run: |
|
|
||||||
node -e "const fs=require('fs');const p='front/package-lock.json';const d=JSON.parse(fs.readFileSync(p,'utf8'));const pk=d.packages||{};for(const k of Object.keys(pk)){if(k==='node_modules/node-sass'||k.startsWith('node_modules/node-sass/'))delete pk[k];}if(d.dependencies&&d.dependencies.node-sass)delete d.dependencies.node-sass;fs.writeFileSync(p,JSON.stringify(d,null,2));"
|
|
||||||
echo "node-sass stripped from lockfile"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
working-directory: front
|
|
||||||
run: npm ci --no-audit --no-fund
|
|
||||||
|
|
||||||
- name: Build (prod -> dist/prod)
|
|
||||||
working-directory: front
|
|
||||||
run: npm run build:prod
|
|
||||||
|
|
||||||
- name: Deploy to static dir (/data/www/userbase/front)
|
|
||||||
# act_runner 不挂载自定义卷,改用宿主 docker.sock 启动一次性 helper 容器拷贝产物。
|
|
||||||
run: node scripts/deploy-via-docker.js
|
|
||||||
|
|
||||||
- name: Done
|
|
||||||
run: 'echo "deploy step finished; inspect host /data/www/userbase/_debug.txt for the full report"'
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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: |
|
||||||
|
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||||
|
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||||
|
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
|
||||||
|
# 构建容器名必须全局唯一: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}"
|
||||||
|
# 版本号:short sha + 时间戳(可改为 git tag 优先,见下)
|
||||||
|
VERSION="$(echo ${GITHUB_SHA} | cut -c1-7)-$(date +%Y%m%d-%H%M)"
|
||||||
|
echo "Deploying version: $VERSION"
|
||||||
|
|
||||||
|
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||||
|
docker run -d --name "$BUILD_CONTAINER" \
|
||||||
|
-e VERSION="$VERSION" \
|
||||||
|
-v /data/www/userbase:/data/www/userbase \
|
||||||
|
-w /build node:20 sleep 3600
|
||||||
|
|
||||||
|
# 源码拷入构建容器(绕过 act 工作区非宿主路径的坑,与 service 的 mavenbuild 一致)
|
||||||
|
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||||
|
|
||||||
|
docker exec "$BUILD_CONTAINER" 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
|
||||||
|
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
|
||||||
|
npm config set registry https://registry.npmmirror.com
|
||||||
|
npm ci
|
||||||
|
npm run build:prod
|
||||||
|
'
|
||||||
|
|
||||||
|
# 部署:首次把真实目录 front 迁入 releases/initial 并建软链;之后均为软链替换
|
||||||
|
docker exec "$BUILD_CONTAINER" 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 "$BUILD_CONTAINER"
|
||||||
|
echo "DEPLOY OK: $VERSION"
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
name: diag-userbase
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
dummy:
|
|
||||||
description: 'noop'
|
|
||||||
required: false
|
|
||||||
default: 'x'
|
|
||||||
jobs:
|
|
||||||
diag:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: docker:24
|
|
||||||
steps:
|
|
||||||
- name: inspect running container config + embedded run-app.sh
|
|
||||||
run: |
|
|
||||||
echo "===== container env (NACOS?) ====="
|
|
||||||
docker inspect userbase-service --format '{{json .Config.Env}}' 2>&1 || echo "NO CONTAINER"
|
|
||||||
echo
|
|
||||||
echo "===== -D patch present in RUNNING container's run-app.sh? ====="
|
|
||||||
docker exec userbase-service grep -E 'Dyh.sso|client-host-url|server-url-prefix' /app/run-app.sh 2>&1 || echo "NO -D patch found in running container"
|
|
||||||
echo
|
|
||||||
echo "===== running image id ====="
|
|
||||||
docker inspect userbase-service --format '{{.Image}}' 2>&1 || true
|
|
||||||
echo
|
|
||||||
echo "===== all userbase-service images (id / tag / age) ====="
|
|
||||||
docker images --filter reference='*/userbase-service*' --format '{{.ID}} {{.Tag}} {{.CreatedSince}}' 2>&1
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
name: example-docker-push
|
|
||||||
|
|
||||||
# 示例:演示 act_runner 的 docker.sock 能力(构建并推送镜像到 Nexus docker-hosted)
|
|
||||||
# 需要仓库 Settings -> Actions Secrets 配置:
|
|
||||||
# NEXUS_USER = admin
|
|
||||||
# NEXUS_PASSWORD = <nexus admin password>
|
|
||||||
# 手动触发(Actions 页面 -> Run workflow),不会自动跑。
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: 'image tag to push'
|
|
||||||
required: false
|
|
||||||
default: 'demo'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
docker-build-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# docker:24 自带 docker CLI,复用宿主 docker daemon
|
|
||||||
# docker.sock 显式挂入 job 容器(act_runner 会校验 valid_volumes 白名单)
|
|
||||||
container:
|
|
||||||
image: docker:24
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
steps:
|
|
||||||
# docker:24 基于 alpine,默认无 git;先装 git 再手动 clone 本仓库
|
|
||||||
- name: Install git (alpine)
|
|
||||||
run: |
|
|
||||||
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
|
||||||
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
|
||||||
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
|
|
||||||
|
|
||||||
- name: Login to Nexus docker registry
|
|
||||||
run: echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u ${{ secrets.NEXUS_USER }}
|
|
||||||
|
|
||||||
- name: Build image (front nginx)
|
|
||||||
run: docker build -t nexus:8083/docker-hosted/userbase-front:${{ inputs.tag }} -f front/docker/Dockerfile front
|
|
||||||
|
|
||||||
- name: Push image
|
|
||||||
run: docker push nexus:8083/docker-hosted/userbase-front:${{ inputs.tag }}
|
|
||||||
@@ -1,32 +1,30 @@
|
|||||||
# ---- dependencies ----
|
# WorkBuddy local state
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# ---- build output (CI builds, do not commit) ----
|
|
||||||
front/dist/
|
|
||||||
front/dist_ra/
|
|
||||||
|
|
||||||
# ---- java build ----
|
|
||||||
service/target/
|
|
||||||
|
|
||||||
# ---- local tooling / IDE ----
|
|
||||||
.local-context/
|
|
||||||
.workbuddy/
|
.workbuddy/
|
||||||
|
|
||||||
# ---- env secrets (keep *.example) ----
|
# Logs
|
||||||
.env
|
logs
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# ---- logs ----
|
|
||||||
*.log
|
*.log
|
||||||
deploy/*_run*.log
|
npm-debug.log*
|
||||||
deploy/verify_run*.log
|
yarn-debug.log*
|
||||||
deploy/push_run.log
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
# ---- OS ----
|
node_modules
|
||||||
Thumbs.db
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
yarn.lock
|
||||||
|
.npmrc
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
*.suo
|
||||||
# ---- IDE ----
|
*.ntvs*
|
||||||
.idea/
|
*.njsproj
|
||||||
# ---- vite temp cache ----
|
*.sln
|
||||||
|
*.sw?
|
||||||
*.timestamp-*.mjs
|
*.timestamp-*.mjs
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// 纠正当前 api:gen 产物中命名空间的大小写错误
|
||||||
|
// 映射依据:后端 service 下真实的 *Controller 类名 -> PascalCase 命名空间
|
||||||
|
const fs = require('fs');
|
||||||
|
const map = {
|
||||||
|
Approle: 'AppRole',
|
||||||
|
Appsettings: 'AppSettings',
|
||||||
|
Groupauthz: 'GroupAuthz',
|
||||||
|
Pushsubscription: 'PushSubscription',
|
||||||
|
Signkeyinternal: 'SignKeyInternal',
|
||||||
|
Sysgroup: 'SysGroup',
|
||||||
|
Sysuser: 'SysUser',
|
||||||
|
};
|
||||||
|
const targets = ['src/api/apiDefinitions.ts', 'src/api/globals.d.ts'];
|
||||||
|
for (const f of targets) {
|
||||||
|
let s = fs.readFileSync(f, 'utf8');
|
||||||
|
for (const [wrong, correct] of Object.entries(map)) {
|
||||||
|
// 带引号 key:'Sysgroup. -> 'SysGroup.
|
||||||
|
s = s.split(`'${wrong}.`).join(`'${correct}.`);
|
||||||
|
// 裸引用(globals.d.ts 里的 interface/属性声明):Sysgroup: -> SysGroup: / Sysgroup. -> SysGroup.
|
||||||
|
s = s.split(`${wrong}:`).join(`${correct}:`);
|
||||||
|
s = s.split(`${wrong}.`).join(`${correct}.`);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(f, s);
|
||||||
|
console.log('fixed', f);
|
||||||
|
}
|
||||||
|
console.log('done');
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { GeneratorConfig } from "@alova/wormhole";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
generator: [
|
||||||
|
{
|
||||||
|
input: "http://localhost:8060/v3/api-docs",
|
||||||
|
// 文档平台类型
|
||||||
|
platform: "swagger",
|
||||||
|
|
||||||
|
// 生成代码的输出目录
|
||||||
|
output: "src/api",
|
||||||
|
// 响应数据的媒体类型
|
||||||
|
responseMediaType: "application/json",
|
||||||
|
// 请求体数据的媒体类型
|
||||||
|
bodyMediaType: "application/json",
|
||||||
|
// 生成的 API 版本
|
||||||
|
version: 1,
|
||||||
|
// 生成代码类型
|
||||||
|
type: "typescript",
|
||||||
|
// 全局 API 名称
|
||||||
|
global: "Apis",
|
||||||
|
splitImplementation: true,
|
||||||
|
implementation: "inline",
|
||||||
|
// API 处理函数
|
||||||
|
handleApi: apiDescriptor => {
|
||||||
|
// 过滤掉已废弃的 API
|
||||||
|
if (apiDescriptor.deprecated) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// 优先使用后端显式声明的 operationId(稳定、可读、无重名后缀),
|
||||||
|
// 仅在 operationId 缺失时回退到 summary。切勿用 summary 覆盖 operationId,
|
||||||
|
// 否则中文/长文本 summary 会变成方法名导致运行时查不到。
|
||||||
|
const newOpId = apiDescriptor.operationId ?? apiDescriptor.summary?.trim();
|
||||||
|
const url = apiDescriptor.url.replace(/^\/{platform}\//, "/api/");
|
||||||
|
// springdoc 默认把 Controller 类名转为 kebab tag(如 base-controller),
|
||||||
|
// 而业务代码统一用 Pascal 无 Controller 的命名空间(如 Base)调用。
|
||||||
|
// 这里剥掉 -controller 后缀并转为 PascalCase,使 wormhole 生成的
|
||||||
|
// API 路径(Base.getUserInfo 等)与 Apis.Base.xxx 调用风格一致。
|
||||||
|
// 由于转换结果是合法 JS 标识符,wormhole 会原样保留、不做 camelCase 改写。
|
||||||
|
// springdoc v3 的 tag 有两种形态,都要能正确转为 PascalCase 且无 Controller 后缀:
|
||||||
|
// - Pascal+Controller:SysGroupController -> SysGroup
|
||||||
|
// - kebab+controller:sys-group-controller -> SysGroup
|
||||||
|
// 关键:剥掉尾部 Controller 后,要在 camelCase 边界补空格再逐段首字母大写,
|
||||||
|
// 否则 SysGroup -> Sysgroup(后面全被 toLowerCase 压成小写)会让运行时查不到。
|
||||||
|
const toNamespace = (tag: string): string => {
|
||||||
|
const stripped = tag.replace(/controller$/i, "");
|
||||||
|
const withSpaces = stripped.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
|
||||||
|
const parts = withSpaces.split(/[^a-zA-Z0-9]+/).filter(Boolean);
|
||||||
|
return parts
|
||||||
|
.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
|
||||||
|
.join("");
|
||||||
|
};
|
||||||
|
if (Array.isArray(apiDescriptor.tags)) {
|
||||||
|
apiDescriptor.tags = apiDescriptor.tags.map(toNamespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...apiDescriptor,
|
||||||
|
operationId: newOpId, // 关键:改掉它
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as GeneratorConfig;
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# 本地构建推送 / 远端部署 用的环境变量示例
|
|
||||||
# 用法:复制为 .env 或直接 export 后执行脚本
|
|
||||||
|
|
||||||
# ---- ACR 阿里云镜像仓库 ----
|
|
||||||
ACR_REGISTRY=registry.cn-hangzhou.aliyuncs.com
|
|
||||||
ACR_NAMESPACE=damaomi_docker
|
|
||||||
ACR_REPO=yunhai
|
|
||||||
ACR_USERNAME=your-acr-username
|
|
||||||
ACR_PASSWORD=your-acr-password
|
|
||||||
|
|
||||||
# ---- 镜像 tag ----
|
|
||||||
APP_VERSION=userbase-v1.0
|
|
||||||
FRONT_VERSION=userbase-front-v1.0
|
|
||||||
|
|
||||||
# ---- 部署到远端 ----
|
|
||||||
DEPLOY_HOST=192.168.10.157
|
|
||||||
DEPLOY_PORT=22
|
|
||||||
DEPLOY_USER=yunhai
|
|
||||||
REMOTE_DIR=/opt/docker/userbase
|
|
||||||
SSHPASS=your-ssh-password
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
docker run --rm --network infra-infra-net --entrypoint java registry.cn-hangzhou.aliyuncs.com/damaomi_docker/yunhai:userbase-v1.0 -jar /app/userbase.jar --spring.profiles.active=prod --debug > /tmp/ub_debug.log 2>&1
|
|
||||||
echo "EXIT=$?"
|
|
||||||
echo "=== conditions / binding matches ==="
|
|
||||||
grep -nE "YhAuthAutoConfiguration|CasSsoConfiguration|Failed to bind|Binding to|YhSsoProperties|Exception starting filter|serverUrlPrefix|NoSuchBean" /tmp/ub_debug.log | head -60
|
|
||||||
echo "=== tail (conditions report) ==="
|
|
||||||
tail -50 /tmp/ub_debug.log
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
EOF
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
set -e
|
|
||||||
echo "== wait for health (up to 180s) =="
|
|
||||||
for i in $(seq 1 36); do
|
|
||||||
st=$(docker inspect -f '{{.State.Health.Status}}' userbase-app 2>/dev/null || echo unknown)
|
|
||||||
echo " [$i] health=$st"
|
|
||||||
if [ "$st" = "healthy" ]; then break; fi
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
echo "== container status =="
|
|
||||||
docker ps --filter name=userbase --format "table {{.Names}}\t{{.Status}}"
|
|
||||||
echo "== nacos reachability (host) =="
|
|
||||||
curl -s -m 5 -o /dev/null -w "host nacos http=%{http_code}\n" "http://nacos.yunhaixingtong.com:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant=" || echo "host cannot reach nacos"
|
|
||||||
echo "== nacos reachability (container) =="
|
|
||||||
docker exec userbase-app sh -c "curl -s -m 5 -o /dev/null -w 'container nacos http=%{http_code}\n' 'http://nacos.yunhaixingtong.com:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant='" || echo "container cannot reach nacos"
|
|
||||||
echo "== app logs: nacos / config / errors (tail) =="
|
|
||||||
docker logs --tail 200 userbase-app 2>&1 | grep -iE "Loading nacos data|userbase.yaml|locate Nacos|NacosConfig|get config from nacos|ERROR|Exception|connect.*refused|UnknownHost|403|401|拒绝|超时|Started " | tail -40 || true
|
|
||||||
echo "== done =="
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# 本地构建 UserBase 前后端镜像并推送到阿里云 ACR
|
|
||||||
# 用法: ./build-push.sh [app_tag] [front_tag]
|
|
||||||
# 前置环境变量: ACR_NAMESPACE, ACR_USERNAME, ACR_PASSWORD
|
|
||||||
# 前置产物: service/target/userbase.jar、front/dist/prod/
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
APP_VERSION="${1:-${APP_VERSION:-userbase-v1.0}}"
|
|
||||||
FRONT_VERSION="${2:-${FRONT_VERSION:-userbase-front-v1.0}}"
|
|
||||||
REGISTRY="${ACR_REGISTRY:-registry.cn-hangzhou.aliyuncs.com}"
|
|
||||||
NAMESPACE="${ACR_NAMESPACE:?请设置 ACR_NAMESPACE}"
|
|
||||||
REPO="${ACR_REPO:-yunhai}"
|
|
||||||
|
|
||||||
APP_IMG="$REGISTRY/$NAMESPACE/$REPO:$APP_VERSION"
|
|
||||||
FRONT_IMG="$REGISTRY/$NAMESPACE/$REPO:$FRONT_VERSION"
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
|
|
||||||
[ -f "$ROOT/service/target/userbase.jar" ] || { echo "缺少 service/target/userbase.jar,请先 mvn package"; exit 1; }
|
|
||||||
[ -f "$ROOT/front/dist/prod/index.html" ] || { echo "缺少 front/dist/prod,请先 npm run build:prod"; exit 1; }
|
|
||||||
|
|
||||||
echo "== login $REGISTRY =="
|
|
||||||
echo "${ACR_PASSWORD:?请设置 ACR_PASSWORD}" | docker login --username="${ACR_USERNAME:?请设置 ACR_USERNAME}" --password-stdin "$REGISTRY"
|
|
||||||
|
|
||||||
echo "== build $APP_IMG =="
|
|
||||||
docker build -f "$ROOT/service/docker/Dockerfile" -t "$APP_IMG" "$ROOT/service"
|
|
||||||
|
|
||||||
echo "== build $FRONT_IMG =="
|
|
||||||
docker build -f "$ROOT/front/docker/Dockerfile" -t "$FRONT_IMG" "$ROOT/front"
|
|
||||||
|
|
||||||
echo "== push =="
|
|
||||||
docker push "$APP_IMG"
|
|
||||||
docker push "$FRONT_IMG"
|
|
||||||
|
|
||||||
echo "PUSHED $APP_IMG"
|
|
||||||
echo "PUSHED $FRONT_IMG"
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
MC=mysql8
|
|
||||||
echo "MYSQL_CONTAINER=$MC"
|
|
||||||
|
|
||||||
docker exec userbase-app cat /app/config/application-prod.yml > /tmp/cfg.yml
|
|
||||||
PW=$(awk '/username: root/{f=1} f&&/^ *password:/{sub(/^ *password: */,""); gsub(/\r/,""); print; exit}' /tmp/cfg.yml)
|
|
||||||
echo "PW_LEN=${#PW}"
|
|
||||||
|
|
||||||
printf '[client]\nuser=root\npassword=%s\n' "$PW" > /tmp/my.cnf
|
|
||||||
docker cp /tmp/my.cnf "$MC":/tmp/my.cnf
|
|
||||||
|
|
||||||
echo "== check sys_user.type column (via TCP 127.0.0.1) =="
|
|
||||||
docker exec "$MC" sh -c "mysql --defaults-extra-file=/tmp/my.cnf -h 127.0.0.1 -N -e \"SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='bsch_userbase' AND TABLE_NAME='sys_user' AND COLUMN_NAME='type';\""
|
|
||||||
echo "QUERY_EXIT=$?"
|
|
||||||
|
|
||||||
echo "== also confirm DB reachable & table exists =="
|
|
||||||
docker exec "$MC" sh -c "mysql --defaults-extra-file=/tmp/my.cnf -h 127.0.0.1 -N -e \"SHOW TABLES FROM bsch_userbase LIKE 'sys_user';\""
|
|
||||||
|
|
||||||
docker exec "$MC" rm -f /tmp/my.cnf
|
|
||||||
rm -f /tmp/my.cnf /tmp/cfg.yml
|
|
||||||
echo "CLEANED"
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# 一键部署:上传编排文件 -> SSH 到服务器拉取镜像并启动
|
|
||||||
# 用法: ./deploy.sh [app_tag] [front_tag]
|
|
||||||
# 前置环境变量: DEPLOY_HOST, SSHPASS(可选 ACR_NAMESPACE / DEPLOY_USER / REMOTE_DIR)
|
|
||||||
# 注:服务器需已 docker login registry.cn-hangzhou.aliyuncs.com
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
APP_VERSION="${1:-${APP_VERSION:-userbase-v1.0}}"
|
|
||||||
FRONT_VERSION="${2:-${FRONT_VERSION:-userbase-front-v1.0}}"
|
|
||||||
HOST="${DEPLOY_HOST:?请设置 DEPLOY_HOST}"
|
|
||||||
PORT="${DEPLOY_PORT:-22}"
|
|
||||||
USER="${DEPLOY_USER:-yunhai}"
|
|
||||||
REMOTE_DIR="${REMOTE_DIR:-/opt/docker/userbase}"
|
|
||||||
NAMESPACE="${ACR_NAMESPACE:-damaomi_docker}"
|
|
||||||
REGISTRY="${ACR_REGISTRY:-registry.cn-hangzhou.aliyuncs.com}"
|
|
||||||
PY="${PYTHON_BIN:-python3}"
|
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
echo "== 1/2 上传 compose 到 $HOST:$REMOTE_DIR =="
|
|
||||||
"$PY" upload.py "$HOST" "$PORT" "$USER" \
|
|
||||||
docker-compose.userbase.yml "$REMOTE_DIR/docker-compose.userbase.yml"
|
|
||||||
|
|
||||||
echo "== 2/2 远端拉取镜像并启动 =="
|
|
||||||
"$PY" remote_run.py "$HOST" "$PORT" "$USER" <<EOF
|
|
||||||
set -e
|
|
||||||
mkdir -p "$REMOTE_DIR/upload-data"
|
|
||||||
cd "$REMOTE_DIR"
|
|
||||||
cat > .env <<ENV
|
|
||||||
ACR_REGISTRY=$REGISTRY
|
|
||||||
ACR_NAMESPACE=$NAMESPACE
|
|
||||||
APP_VERSION=$APP_VERSION
|
|
||||||
FRONT_VERSION=$FRONT_VERSION
|
|
||||||
ENV
|
|
||||||
docker compose -f docker-compose.userbase.yml pull
|
|
||||||
docker compose -f docker-compose.userbase.yml up -d
|
|
||||||
docker image prune -f
|
|
||||||
docker ps --filter name=userbase --format "table {{.Names}}\t{{.Status}}"
|
|
||||||
echo DEPLOY_OK
|
|
||||||
EOF
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
set -u
|
|
||||||
CP=/opt/docker/infra/Caddyfile
|
|
||||||
echo "===== 1. Caddyfile /userbase blocks (line numbers) ====="
|
|
||||||
grep -n "userbase" "$CP"
|
|
||||||
echo
|
|
||||||
echo "===== 2. Full /userbase route region (lines 40-80) ====="
|
|
||||||
sed -n '40,80p' "$CP"
|
|
||||||
echo
|
|
||||||
echo "===== 3. Caddy mounts in container ====="
|
|
||||||
docker inspect caddy --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'
|
|
||||||
echo
|
|
||||||
echo "===== 4. Running caddy admin config: does it contain 'userbase'? ====="
|
|
||||||
docker exec caddy curl -s localhost:2019/config/ | grep -i "userbase" | head -n 20
|
|
||||||
echo "--- end grep ---"
|
|
||||||
echo
|
|
||||||
echo "===== 5. Caddy container status ====="
|
|
||||||
docker ps --filter name=caddy --format '{{.Names}} {{.Status}}'
|
|
||||||
echo
|
|
||||||
echo "===== 6. Direct test via 127.0.0.1 with Host header ====="
|
|
||||||
for p in /userbase/ /userbase/index.html /userbase/static/js/ /userbase/api/x; do
|
|
||||||
code=$(curl -s -o /tmp/ub_body -w "%{http_code}" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1$p")
|
|
||||||
echo "$p -> $code | $(head -c 80 /tmp/ub_body | tr -d '\n')"
|
|
||||||
done
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
set -u
|
|
||||||
echo "===== 1. In-container view of /etc/caddy/Caddyfile: userbase count ====="
|
|
||||||
docker exec caddy grep -c "userbase" /etc/caddy/Caddyfile
|
|
||||||
echo
|
|
||||||
echo "===== 2. Container start command ====="
|
|
||||||
docker inspect caddy --format 'CMD={{.Config.Cmd}} ENTRY={{.Config.Entrypoint}}'
|
|
||||||
echo
|
|
||||||
echo "===== 3. Admin API reachable? top-level keys ====="
|
|
||||||
docker exec caddy curl -s localhost:2019/
|
|
||||||
echo
|
|
||||||
echo "===== 4. Running HTTP servers + route matchers in config ====="
|
|
||||||
docker exec caddy curl -s localhost:2019/config/ > /tmp/running.json
|
|
||||||
echo "bytes: $(wc -c < /tmp/running.json)"
|
|
||||||
echo "occurrences:"
|
|
||||||
for s in "userbase" "Welcome to Base Platform" "handle_path" "reverse_proxy" "www.yunhaixingtong.com"; do
|
|
||||||
echo " $s : $(grep -o "$s" /tmp/running.json | wc -l)"
|
|
||||||
done
|
|
||||||
echo
|
|
||||||
echo "===== 5. Do a manual POST /load from inside container and show result ====="
|
|
||||||
docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile 2>&1
|
|
||||||
echo "exit=$?"
|
|
||||||
sleep 1
|
|
||||||
echo "--- after reload, recount ---"
|
|
||||||
docker exec caddy curl -s localhost:2019/config/ | grep -c "userbase"
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# UserBase 编排:后端(Spring Boot 8060) + 前端(nginx 80)
|
|
||||||
# 两者都挂在 infra-infra-net,由 caddy 按服务名反代到 www.yunhaixingtong.com/userbase
|
|
||||||
# 启动:cd /opt/docker/userbase && docker compose -f docker-compose.userbase.yml up -d
|
|
||||||
services:
|
|
||||||
userbase-app:
|
|
||||||
image: ${ACR_REGISTRY:-registry.cn-hangzhou.aliyuncs.com}/${ACR_NAMESPACE:-damaomi_docker}/yunhai:${APP_VERSION:-userbase-v1.0}
|
|
||||||
container_name: userbase-app
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- infra-infra-net
|
|
||||||
# SSO 回调后容器内要做 CAS serviceValidate 回调 sso.yunhaixingtong.com,
|
|
||||||
# 该虚拟主机由宿主机 caddy 按 vhost 路由,但容器内无 DNS 记录会 UnknownHostException。
|
|
||||||
# host-gateway 指向宿主机网关(caddy 监听 :80/:443 全接口),无需硬编码 IP;
|
|
||||||
# 旧版 Docker(<20.10)不支持 host-gateway,可改为宿主机桥接网关 IP,如 172.17.0.1。
|
|
||||||
extra_hosts:
|
|
||||||
- "sso.yunhaixingtong.com:host-gateway"
|
|
||||||
environment:
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
# 配置中心:由 bootstrap.yml 中 spring.cloud.nacos.config 在 bootstrap 阶段自动拉取
|
|
||||||
# 容器内通过 infra-infra-net 解析内网别名 nacos:8848(宿主机 shell 无法直接解析),
|
|
||||||
# 公网域名 nacos.yunhaixingtong.com 仅用于控制台管理,生产容器走内网。
|
|
||||||
NACOS_ADDR: nacos:8848
|
|
||||||
# Nacos 已开启鉴权,config 客户端需携带账号(与旧 run-app.sh 的 nacos/nacos 一致)
|
|
||||||
SPRING_CLOUD_NACOS_USERNAME: nacos
|
|
||||||
SPRING_CLOUD_NACOS_PASSWORD: nacos
|
|
||||||
SPRING_CLOUD_NACOS_CONFIG_USERNAME: nacos
|
|
||||||
SPRING_CLOUD_NACOS_CONFIG_PASSWORD: nacos
|
|
||||||
JAVA_OPTS: "-Xms512m -Xmx1024m"
|
|
||||||
volumes:
|
|
||||||
- ./upload-data:/data/upload
|
|
||||||
expose:
|
|
||||||
- "8060"
|
|
||||||
healthcheck:
|
|
||||||
# 只要能返回 HTTP 状态码就认为进程已起(业务接口普遍需要鉴权,不能用 -f)
|
|
||||||
test: ["CMD-SHELL", "curl -s -o /dev/null -w '%{http_code}' http://localhost:8060/userbase/ | grep -qE '^[234]'"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
start_period: 90s
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '1.5'
|
|
||||||
memory: 1536M
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
userbase-front:
|
|
||||||
image: ${ACR_REGISTRY:-registry.cn-hangzhou.aliyuncs.com}/${ACR_NAMESPACE:-damaomi_docker}/yunhai:${FRONT_VERSION:-userbase-front-v1.0}
|
|
||||||
container_name: userbase-front
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- infra-infra-net
|
|
||||||
environment:
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
expose:
|
|
||||||
- "80"
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '0.5'
|
|
||||||
memory: 256M
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
networks:
|
|
||||||
infra-infra-net:
|
|
||||||
external: true
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
echo "== all containers (name + image) =="
|
|
||||||
docker ps -a --format '{{.Names}}\t{{.Image}}'
|
|
||||||
echo "== which containers are on infra-infra-net =="
|
|
||||||
docker network inspect infra-infra-net --format '{{range .Containers}}{{.Name}} ({{.IPv4Address}}){{"\n"}}{{end}}' 2>/dev/null || echo "no such network"
|
|
||||||
echo "== resolve mysql8 from userbase-app =="
|
|
||||||
docker exec userbase-app getent hosts mysql8 2>/dev/null || echo "getent failed"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
set -u
|
|
||||||
echo "===== A. Reload caddy from inside the container (reads mounted /etc/caddy/Caddyfile) ====="
|
|
||||||
docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|
|
||||||
echo "reload exit: $?"
|
|
||||||
sleep 2
|
|
||||||
echo
|
|
||||||
echo "===== B. Verify running config now contains userbase ====="
|
|
||||||
docker exec caddy curl -s localhost:2019/config/ | grep -c "userbase"
|
|
||||||
echo "(count above; 0 means still missing)"
|
|
||||||
echo
|
|
||||||
echo "===== C. Test routes via 127.0.0.1 with Host header ====="
|
|
||||||
for p in /userbase/ /userbase/index.html /userbase/static/js/ /userbase/api/x /userbase/login; do
|
|
||||||
code=$(curl -s -o /tmp/ub_body -w "%{http_code}" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1$p")
|
|
||||||
echo "$p -> $code | $(head -c 90 /tmp/ub_body | tr -d '\n')"
|
|
||||||
done
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
set -u
|
|
||||||
echo "===== host file (has userbase) ====="
|
|
||||||
grep -c "userbase" /opt/docker/infra/Caddyfile
|
|
||||||
echo "===== container file BEFORE restart (expect 0) ====="
|
|
||||||
docker exec caddy grep -c "userbase" /etc/caddy/Caddyfile
|
|
||||||
echo
|
|
||||||
echo "===== restart caddy to re-bind the edited file ====="
|
|
||||||
docker restart caddy
|
|
||||||
echo "restart exit: $?"
|
|
||||||
sleep 6
|
|
||||||
echo
|
|
||||||
echo "===== container file AFTER restart (expect >0) ====="
|
|
||||||
docker exec caddy grep -c "userbase" /etc/caddy/Caddyfile
|
|
||||||
echo
|
|
||||||
echo "===== test routes ====="
|
|
||||||
for p in /userbase/ /userbase/index.html /userbase/static/js/ /userbase/api/x /userbase/login; do
|
|
||||||
code=$(curl -s -o /tmp/ub_body -w "%{http_code}" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1$p")
|
|
||||||
echo "$p -> $code | $(head -c 100 /tmp/ub_body | tr -d '\n')"
|
|
||||||
done
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
set -u
|
|
||||||
CP=/opt/docker/infra/Caddyfile
|
|
||||||
echo "===== before: line 70 ====="
|
|
||||||
sed -n '70p' "$CP"
|
|
||||||
echo "===== apply wildcard matcher (inode will change; restart re-binds) ====="
|
|
||||||
sed -i 's#handle_path /userbase/ {#handle_path /userbase* {#' "$CP"
|
|
||||||
echo "after:"; sed -n '70p' "$CP"
|
|
||||||
echo
|
|
||||||
echo "===== restart caddy to re-bind edited file ====="
|
|
||||||
docker restart caddy
|
|
||||||
echo "restart exit: $?"
|
|
||||||
sleep 6
|
|
||||||
echo "===== container sees fix? ====="
|
|
||||||
docker exec caddy grep -n "handle_path /userbase" /etc/caddy/Caddyfile
|
|
||||||
echo
|
|
||||||
echo "===== extract a real JS asset from SPA and test it ====="
|
|
||||||
html=$(curl -s -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/)
|
|
||||||
echo "$html" | grep -oE 'src="[^"]+\.js"' | head -3
|
|
||||||
asset=$(echo "$html" | grep -oE '/[A-Za-z0-9_./-]+\.js' | head -1)
|
|
||||||
echo "first asset path: $asset"
|
|
||||||
if [ -n "$asset" ]; then
|
|
||||||
code=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1/userbase${asset}")
|
|
||||||
echo "/userbase${asset} -> $code"
|
|
||||||
fi
|
|
||||||
echo
|
|
||||||
echo "===== route tests ====="
|
|
||||||
for p in /userbase/ /userbase/index.html "/userbase${asset:-/static/js/}"; do
|
|
||||||
code=$(curl -s -o /tmp/b -w "%{http_code}" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1$p")
|
|
||||||
echo "$p -> $code | $(head -c 70 /tmp/b|tr -d '\n')"
|
|
||||||
done
|
|
||||||
echo
|
|
||||||
echo "===== backend test ====="
|
|
||||||
curl -s -o /dev/null -w "/userbase/api/x -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/api/x
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# 探测内网 nacos:8848 是否可达、public 空间下 userbase.yaml 是否存在、是否需要鉴权
|
|
||||||
echo "== resolve nacos =="
|
|
||||||
getent hosts nacos || echo "cannot resolve nacos"
|
|
||||||
echo "== fetch userbase.yaml (public, no auth) =="
|
|
||||||
curl -s -m 8 -o /tmp/nc.txt -w "http=%{http_code}\n" "http://nacos:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant="
|
|
||||||
echo "--- body (first 200 chars) ---"
|
|
||||||
head -c 200 /tmp/nc.txt; echo
|
|
||||||
if grep -q "unknown user" /tmp/nc.txt || grep -q "403" /tmp/nc.txt || [ "$(head -c 1 /tmp/nc.txt)" = "" ]; then
|
|
||||||
echo "== needs auth, try login nacos/nacos =="
|
|
||||||
TOKEN=$(curl -s -m 8 -X POST "http://nacos:8848/nacos/v1/auth/login?username=nacos&password=nacos" | sed -n 's/.*"accessToken":"\([^"]*\)".*/\1/p')
|
|
||||||
echo "token_len=${#TOKEN}"
|
|
||||||
if [ -n "$TOKEN" ]; then
|
|
||||||
curl -s -m 8 -o /tmp/nc2.txt -w "http=%{http_code}\n" "http://nacos:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant=&accessToken=$TOKEN"
|
|
||||||
echo "--- authed body (first 200 chars) ---"
|
|
||||||
head -c 200 /tmp/nc2.txt; echo
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo "== done =="
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
echo "== containers on host =="
|
|
||||||
docker ps --format '{{.Names}}\t{{.Networks}}' | head -40
|
|
||||||
echo "== infra-infra-net members =="
|
|
||||||
docker network inspect infra-infra-net --format '{{range .Containers}}{{.Name}} {{end}}' 2>/dev/null
|
|
||||||
echo "== resolve nacos from inside userbase-app =="
|
|
||||||
docker exec userbase-app sh -c "getent hosts nacos || echo 'no nacos in container DNS'"
|
|
||||||
echo "== curl nacos:8848 from inside userbase-app (no auth) =="
|
|
||||||
docker exec userbase-app sh -c "curl -s -m 8 -o /tmp/nc.txt -w 'http=%{http_code}\n' 'http://nacos:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant=' ; head -c 200 /tmp/nc.txt; echo" || echo "curl failed"
|
|
||||||
echo "== done =="
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# 重新部署 userbase-app:拉取新镜像、重建、等待健康,并核对是否真正从 Nacos 拉到了配置
|
|
||||||
echo "== login ACR =="
|
|
||||||
echo '59qtJRqlT7' | docker login --username=15121037482 --password-stdin registry.cn-hangzhou.aliyuncs.com
|
|
||||||
|
|
||||||
cd /opt/docker/userbase
|
|
||||||
echo "== pull userbase-app =="
|
|
||||||
docker compose -f docker-compose.userbase.yml pull userbase-app
|
|
||||||
echo "== up -d userbase-app =="
|
|
||||||
docker compose -f docker-compose.userbase.yml up -d userbase-app
|
|
||||||
|
|
||||||
echo "== wait for health (up to 150s) =="
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
st=$(docker inspect -f '{{.State.Health.Status}}' userbase-app 2>/dev/null || echo unknown)
|
|
||||||
echo " [$i] health=$st"
|
|
||||||
if [ "$st" = "healthy" ]; then break; fi
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "== nacos reachability (host) =="
|
|
||||||
curl -s -m 5 -o /dev/null -w "host nacos http=%{http_code}\n" "http://nacos.yunhaixingtong.com:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant=" || echo "host cannot reach nacos"
|
|
||||||
|
|
||||||
echo "== nacos reachability (container) =="
|
|
||||||
docker exec userbase-app sh -c "curl -s -m 5 -o /dev/null -w 'container nacos http=%{http_code}\n' 'http://nacos.yunhaixingtong.com:8848/nacos/v1/cs/configs?dataId=userbase.yaml&group=USERBASE&tenant='" || echo "container cannot reach nacos"
|
|
||||||
|
|
||||||
echo "== app logs: nacos / config / errors =="
|
|
||||||
docker logs --tail 150 userbase-app 2>&1 | grep -iE "Loading nacos data|userbase.yaml|locate Nacos|NacosConfig|get config from nacos|ERROR|Exception|connect.*refused|UnknownHost|403|401|拒绝|超时" | tail -50 || true
|
|
||||||
echo "== done =="
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# 通过 SSH 在远端以 sudo 执行脚本(从 stdin 读取)
|
|
||||||
# 用法: python3 remote_run.py <host> <port> <user> < script.sh
|
|
||||||
# 依赖环境变量 SSHPASS
|
|
||||||
import os, sys, paramiko
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
host = sys.argv[1]
|
|
||||||
port = int(sys.argv[2]) if len(sys.argv) > 2 else 22
|
|
||||||
user = sys.argv[3] if len(sys.argv) > 3 else "root"
|
|
||||||
pw = os.environ.get("SSHPASS")
|
|
||||||
if not pw:
|
|
||||||
sys.stderr.write("ERROR: 请先设置环境变量 SSHPASS\n")
|
|
||||||
sys.exit(1)
|
|
||||||
script = sys.stdin.read()
|
|
||||||
c = paramiko.SSHClient()
|
|
||||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
c.connect(host, port, user, password=pw, timeout=30)
|
|
||||||
stdin, stdout, stderr = c.exec_command("sudo -S bash -s", timeout=1800)
|
|
||||||
stdin.write(pw + "\n")
|
|
||||||
stdin.write(script)
|
|
||||||
stdin.flush()
|
|
||||||
stdin.channel.shutdown_write()
|
|
||||||
sys.stdout.write(stdout.read().decode(errors="replace"))
|
|
||||||
err = stderr.read().decode(errors="replace")
|
|
||||||
if err.strip():
|
|
||||||
sys.stderr.write("STDERR:\n" + err)
|
|
||||||
code = stdout.channel.recv_exit_status()
|
|
||||||
c.close()
|
|
||||||
sys.exit(code)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# 线上冒烟:经 Caddy 访问 UserBase,确认 Nacos 配置生效、前后端可达
|
|
||||||
echo "== SPA 首页 =="
|
|
||||||
curl -s -m 8 -o /dev/null -w "/userbase/ -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/
|
|
||||||
echo "== 前端静态资源 =="
|
|
||||||
curl -s -m 8 -o /dev/null -w "/userbase/index.html -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/index.html
|
|
||||||
echo "== 后端 API 可达性(CAS 重定向即代表到达后端) =="
|
|
||||||
curl -s -m 8 -o /dev/null -w "/userbase/api/x -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/api/x
|
|
||||||
echo "== 容器健康 =="
|
|
||||||
docker inspect -f 'userbase-app health={{.State.Health.Status}}' userbase-app
|
|
||||||
echo "== 确认应用真正连的是 nacos 下发的 mysql8 数据源(日志里看 datasource url) =="
|
|
||||||
docker logs --tail 200 userbase-app 2>&1 | grep -iE "jdbc:mysql://mysql8|HikariPool|Started UserBaseApplication" | tail -5 || true
|
|
||||||
echo "== done =="
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# 通过 SFTP 上传文件到远端(参数: host port user local remote [local remote ...])
|
|
||||||
# 依赖环境变量 SSHPASS
|
|
||||||
import os, sys, paramiko
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
host = sys.argv[1]
|
|
||||||
port = int(sys.argv[2]) if len(sys.argv) > 2 else 22
|
|
||||||
user = sys.argv[3] if len(sys.argv) > 3 else "root"
|
|
||||||
pw = os.environ.get("SSHPASS")
|
|
||||||
if not pw:
|
|
||||||
sys.stderr.write("ERROR: 请先设置环境变量 SSHPASS\n")
|
|
||||||
sys.exit(1)
|
|
||||||
t = paramiko.Transport((host, port))
|
|
||||||
t.connect(username=user, password=pw)
|
|
||||||
sftp = paramiko.SFTPClient.from_transport(t)
|
|
||||||
args = sys.argv[4:]
|
|
||||||
for i in range(0, len(args), 2):
|
|
||||||
local, remote = args[i], args[i + 1]
|
|
||||||
cur = ""
|
|
||||||
for p in [p for p in os.path.dirname(remote).split("/") if p]:
|
|
||||||
cur += "/" + p
|
|
||||||
try:
|
|
||||||
sftp.stat(cur)
|
|
||||||
except IOError:
|
|
||||||
try:
|
|
||||||
sftp.mkdir(cur)
|
|
||||||
except IOError:
|
|
||||||
pass
|
|
||||||
sftp.put(local, remote)
|
|
||||||
print(f"PUT {local} -> {remote}")
|
|
||||||
t.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
# ============================================================
|
|
||||||
# 请在 Nacos 控制台新增此配置(public 命名空间)
|
|
||||||
# Data ID : userbase.yaml
|
|
||||||
# Group : USERBASE
|
|
||||||
# Format : YAML
|
|
||||||
# 说明:
|
|
||||||
# - 本文件即应用自身配置。Nacos 解析规则为 ${spring.application.name}.${file-extension},
|
|
||||||
# 因 bootstrap.yml 中 name=userbase、file-extension=yaml,故 Data ID 必须是 userbase.yaml。
|
|
||||||
# - public 空间下的 common.properties 会作为共享配置优先加载(低优先级),
|
|
||||||
# 本文件(高优先级)中的同名字段会覆盖 common.properties。
|
|
||||||
# - 若 common.properties 已包含 datasource / redis 等公共项,下面这些行可按需删减;
|
|
||||||
# 保留也无害(应用配置优先级更高,仍以本文件为准)。
|
|
||||||
# - 生产启动时 profile=prod,Nacos 还会额外尝试 userbase-prod.yaml(可选,不存在也无妨)。
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
server:
|
|
||||||
port: 8060
|
|
||||||
servlet:
|
|
||||||
# 对外经 caddy 暴露为 http://www.yunhaixingtong.com/userbase
|
|
||||||
context-path: /userbase
|
|
||||||
|
|
||||||
spring:
|
|
||||||
mvc:
|
|
||||||
date-format: yyyy-MM-dd HH:mm:ss
|
|
||||||
servlet:
|
|
||||||
multipart:
|
|
||||||
max-file-size: 50MB
|
|
||||||
max-request-size: 200MB
|
|
||||||
jackson:
|
|
||||||
time-zone: GMT+8
|
|
||||||
date-format: yyyy-MM-dd HH:mm:ss
|
|
||||||
|
|
||||||
redis:
|
|
||||||
host: redis-server
|
|
||||||
port: 6379
|
|
||||||
database: 0
|
|
||||||
password: 'wv&WcDYAmLtqTIGM'
|
|
||||||
timeout: 2000ms
|
|
||||||
data:
|
|
||||||
redis:
|
|
||||||
repositories:
|
|
||||||
enabled: false
|
|
||||||
|
|
||||||
datasource:
|
|
||||||
url: jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
|
||||||
username: root
|
|
||||||
password: 'CVf^x$sW*b6CGHxh'
|
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
|
||||||
min-idle: 10
|
|
||||||
max-active: 30
|
|
||||||
|
|
||||||
jpa:
|
|
||||||
database: MySQL
|
|
||||||
show-sql: false
|
|
||||||
hibernate:
|
|
||||||
ddl-auto: update
|
|
||||||
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
|
|
||||||
properties:
|
|
||||||
hibernate.order_by.default_null_ordering: last
|
|
||||||
hibernate:
|
|
||||||
dialect: org.hibernate.dialect.MySQL5InnoDBDialect
|
|
||||||
open-in-view: true
|
|
||||||
|
|
||||||
rabbitmq:
|
|
||||||
host: rabbitmq
|
|
||||||
port: 5672
|
|
||||||
username: admin
|
|
||||||
password: 'JdU29I#Q2E4yXoUl'
|
|
||||||
listener:
|
|
||||||
direct:
|
|
||||||
auto-startup: false
|
|
||||||
simple:
|
|
||||||
auto-startup: false
|
|
||||||
|
|
||||||
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
|
|
||||||
yh:
|
|
||||||
sso:
|
|
||||||
server-url-prefix: http://sso.yunhaixingtong.com
|
|
||||||
client-host-url: http://www.yunhaixingtong.com/userbase
|
|
||||||
allow-headers: all
|
|
||||||
allow-origins: all
|
|
||||||
url-pattern: /api/*
|
|
||||||
mobi-api-pattern: /mobiapi/*
|
|
||||||
app-auth:
|
|
||||||
enabled: true
|
|
||||||
# 本应用自身身份(客户端签名用,若 UserBase 需调用其他带签名服务)
|
|
||||||
app-key: 100616
|
|
||||||
app-secret: 1febb9502d2744b6a235d3e4cfde4a2d
|
|
||||||
# 受信应用(服务端验签用):appKey -> appSecret
|
|
||||||
trusted-apps:
|
|
||||||
100616: 1febb9502d2744b6a235d3e4cfde4a2d
|
|
||||||
|
|
||||||
ADMIN_TAG_ID: 290
|
|
||||||
|
|
||||||
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
|
|
||||||
FILE_PATH: /data/upload
|
|
||||||
FILE_WEB_PATH: http://www.yunhaixingtong.com/userbase/upload/
|
|
||||||
ZIYUAN_WEB_PATH_HTTP: http://base.bestsch.com/ziyuanku/file/
|
|
||||||
UPLOAD_WEB_PATH_HTTP: http://www.yunhaixingtong.com/userbase/upload/file/
|
|
||||||
ORC_SERVICE_URL: http://oa.bestsch.com/OrcService/api/Ocr/Upload
|
|
||||||
|
|
||||||
logging:
|
|
||||||
level:
|
|
||||||
root: INFO
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
echo "== app startup DDL/schema hints =="
|
|
||||||
docker logs userbase-app 2>&1 | grep -iE "alter table|update schema|create table|ddl|column|sys_user" | head -30 || echo "(no ddl lines captured)"
|
|
||||||
|
|
||||||
echo "== datasource config from container =="
|
|
||||||
docker exec userbase-app cat /app/config/application-prod.yml 2>/dev/null | grep -iE "url:|username:|password:|jdbc" | sed -E 's/(password:).*/\1 ****/' || echo "(no config)"
|
|
||||||
|
|
||||||
echo "== mysql client available? =="
|
|
||||||
which mysql || echo "NO_MYSQL_CLI"
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
set -u
|
|
||||||
echo "===== 1. Main app JS chunk from SPA ====="
|
|
||||||
html=$(curl -s -H "Host: www.yunhaixingtong.com" http://127.0.0.1/userbase/)
|
|
||||||
appjs=$(echo "$html" | grep -oE '/static/js/[A-Za-z0-9_./-]+\.js' | head -1)
|
|
||||||
echo "app js: $appjs"
|
|
||||||
curl -s -o /dev/null -w " /userbase${appjs} -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" "http://127.0.0.1/userbase${appjs}"
|
|
||||||
echo
|
|
||||||
echo "===== 2. Ensure other sites not regressed (sso / b) ====="
|
|
||||||
curl -s -o /dev/null -w " sso host / -> %{http_code}\n" -H "Host: sso.yunhaixingtong.com" http://127.0.0.1/
|
|
||||||
curl -s -o /dev/null -w " b host / -> %{http_code}\n" -H "Host: www.yunhaixingtong.com" http://127.0.0.1/b/
|
|
||||||
echo
|
|
||||||
echo "===== 3. Caddy listening ports on host ====="
|
|
||||||
ss -ltnp 2>/dev/null | grep -E ':80 |:443 ' | head
|
|
||||||
echo
|
|
||||||
echo "===== 4. Containers status ====="
|
|
||||||
docker ps --format '{{.Names}} {{.Status}}' | grep -E 'caddy|userbase'
|
|
||||||
echo
|
|
||||||
echo "===== 5. userbase-front health (serve index directly) ====="
|
|
||||||
docker exec userbase-front wget -qO- http://localhost/ 2>/dev/null | head -c 60; echo
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
# 开发环境 (dev)
|
|
||||||
VITE_BASE_HOST=http://base.bestsch.com
|
|
||||||
VITE_SSO_HOST=http://sso.bestsch.com
|
|
||||||
VITE_SERVICE_HOST=http://localhost:8060
|
|
||||||
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
|
|
||||||
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
|
|
||||||
VITE_IS_MOCK=false
|
|
||||||
@@ -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 @@
|
|||||||
# 正式环境 - 云海星通(部署在 www.yunhaixingtong.com/userbase)
|
|
||||||
VITE_BASE_HOST=http://www.yunhaixingtong.com
|
|
||||||
VITE_SSO_HOST=http://sso.yunhaixingtong.com
|
|
||||||
VITE_SERVICE_HOST=http://www.yunhaixingtong.com/userbase
|
|
||||||
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
|
|
||||||
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
|
|
||||||
VITE_IS_MOCK=false
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
yarn.lock
|
|
||||||
.npmrc
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import type { GeneratorConfig } from "@alova/wormhole";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
generator: [
|
|
||||||
{
|
|
||||||
input: "http://localhost:8060/v2/api-docs",
|
|
||||||
// 文档平台类型
|
|
||||||
platform: "swagger",
|
|
||||||
|
|
||||||
// 生成代码的输出目录
|
|
||||||
output: "src/api",
|
|
||||||
// 响应数据的媒体类型
|
|
||||||
responseMediaType: "application/json",
|
|
||||||
// 请求体数据的媒体类型
|
|
||||||
bodyMediaType: "application/json",
|
|
||||||
// 生成的 API 版本
|
|
||||||
version: 1,
|
|
||||||
// 生成代码类型
|
|
||||||
type: "typescript",
|
|
||||||
// 全局 API 名称
|
|
||||||
global: "Apis",
|
|
||||||
splitImplementation: true,
|
|
||||||
implementation: "inline",
|
|
||||||
// API 处理函数
|
|
||||||
handleApi: apiDescriptor => {
|
|
||||||
// 过滤掉已废弃的 API
|
|
||||||
if (apiDescriptor.deprecated) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const newOpId = apiDescriptor.summary?.trim() || apiDescriptor.operationId;
|
|
||||||
const url = apiDescriptor.url.replace(/^\/{platform}\//, "/api/");
|
|
||||||
|
|
||||||
return {
|
|
||||||
...apiDescriptor,
|
|
||||||
operationId: newOpId, // 关键:改掉它
|
|
||||||
url: apiDescriptor.url.replace(/^\/{platform}\//, "/api/"),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as GeneratorConfig;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
//铃铛debug
|
|
||||||
window.conf = {
|
|
||||||
baseHost: "http://basedebug.bestsch.com",
|
|
||||||
ssoHost: "http://ssodebug.bestsch.com",
|
|
||||||
serviceHost: "http://basedebug.bestsch.com/QualityEvaluate",
|
|
||||||
};
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="../../favicon.ico" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title><%- title %></title>
|
|
||||||
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
|
|
||||||
<script src="./libs/jquery.min.js"></script>
|
|
||||||
<script src="./libs/lodash.min.js"></script>
|
|
||||||
<script src="./libs/ztree/jquery.ztree.all.min.js"></script>
|
|
||||||
<script src=" https://base.bestsch.com/BschHome/base/js/bschload.min.js?v=2025011001"></script>
|
|
||||||
<%- injectScript %>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { BaseConf } from "@/types/Basics";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通过 Vite 环境变量管理配置,不再依赖 window.conf / config.js
|
|
||||||
* 环境变量定义在项目根目录的 .env.dev / .env.base / .env.ld 中
|
|
||||||
*/
|
|
||||||
const env = import.meta.env;
|
|
||||||
|
|
||||||
let conf: BaseConf = {
|
|
||||||
baseHost: env.VITE_BASE_HOST,
|
|
||||||
ssoHost: env.VITE_SSO_HOST,
|
|
||||||
serviceHost: env.VITE_SERVICE_HOST,
|
|
||||||
relayConf: {
|
|
||||||
ziyuankuHost: env.VITE_ZIYUANKU_HOST,
|
|
||||||
zhuanxiangHost: env.VITE_ZHUANXIANG_HOST,
|
|
||||||
},
|
|
||||||
isMock: env.VITE_IS_MOCK === "true",
|
|
||||||
isDebug: env.DEV,
|
|
||||||
};
|
|
||||||
|
|
||||||
// HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https://
|
|
||||||
var ishttps = "https:" == document.location.protocol ? true : false;
|
|
||||||
if (ishttps) {
|
|
||||||
Object.keys(conf).forEach(element => {
|
|
||||||
if (typeof conf[element] === "string" && conf[element].indexOf("http://") > -1) {
|
|
||||||
conf[element] = conf[element].replace("http://", "https://");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// relayConf 内部也需要处理
|
|
||||||
if (conf.relayConf) {
|
|
||||||
Object.keys(conf.relayConf).forEach(key => {
|
|
||||||
const val = (conf.relayConf as any)[key];
|
|
||||||
if (typeof val === "string" && val.indexOf("http://") > -1) {
|
|
||||||
(conf.relayConf as any)[key] = val.replace("http://", "https://");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default conf;
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import type { IModule } from "@/store";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 左侧菜单数据(系统管理功能分组)
|
|
||||||
* ----------------------------------------------------------------
|
|
||||||
* 当前直接 Promise.resolve 返回写死数据;后续接入真实接口时,
|
|
||||||
* 只需把 `return Promise.resolve(menu)` 替换为
|
|
||||||
* `return alovaInstance.Request(Apis.xxx.getMenu())`
|
|
||||||
* 即可无缝对接,调用方(store.fetchModules)无需任何改动。
|
|
||||||
*
|
|
||||||
* 分组结构:
|
|
||||||
* 1. 组,用户管理 —— 顶层独立选项卡(单页)
|
|
||||||
* 2. 应用管理 —— 顶层独立选项卡(= 应用列表主页 /app-manage/list);
|
|
||||||
* 页内按钮跳转「应用类别管理」子页(/app-manage/category,不在菜单里,
|
|
||||||
* 由 routes.ts 的 meta.parentId=1102 关联,面包屑「应用管理>应用类别管理」)
|
|
||||||
* 3. 权限管理 —— 父目录:应用开通 / 组授权 / 权限一览
|
|
||||||
* 4. 日志管理 —— 父目录:接口日志 / 系统日志
|
|
||||||
*
|
|
||||||
* 每个节点带唯一 id;子节点的 pid 由 layout.vue 的 flaten() 扁平化时
|
|
||||||
* 依据父节点 id 自动推导,用于「高亮自身 + 面包屑展示父级」联动。
|
|
||||||
* 菜单项不再渲染 icon。
|
|
||||||
*/
|
|
||||||
export function getMenuList(): Promise<IModule[]> {
|
|
||||||
const menu: IModule[] = [
|
|
||||||
// 1. 组,用户管理(顶层独立选项卡)
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
modId: 1101,
|
|
||||||
name: "组,用户管理",
|
|
||||||
page: "/group-user",
|
|
||||||
},
|
|
||||||
// 2. 应用管理(顶层独立选项卡,= 应用列表主页;页内按钮跳转应用类别管理子页)
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
modId: 1102,
|
|
||||||
name: "应用管理",
|
|
||||||
page: "/app-manage/list",
|
|
||||||
},
|
|
||||||
// 3. 权限管理(父目录)
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
modId: 1108,
|
|
||||||
name: "权限管理",
|
|
||||||
page: "",
|
|
||||||
childState: true,
|
|
||||||
children: [
|
|
||||||
{ id: 41, modId: 1103, name: "应用开通", page: "/app-open" },
|
|
||||||
{ id: 42, modId: 1104, name: "组授权", page: "/group-auth" },
|
|
||||||
{ id: 44, modId: 1110, name: "角色授权", page: "/role-authz/list" },
|
|
||||||
{ id: 43, modId: 1105, name: "权限一览", page: "/permission-view" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// 4. 日志管理(父目录)
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
modId: 1109,
|
|
||||||
name: "日志管理",
|
|
||||||
page: "",
|
|
||||||
childState: true,
|
|
||||||
children: [
|
|
||||||
{ id: 51, modId: 1106, name: "接口日志", page: "/api-log" },
|
|
||||||
{ id: 52, modId: 1107, name: "系统日志", page: "/sys-log" },
|
|
||||||
{ id: 53, modId: 1111, name: "操作日志", page: "/oplog" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return Promise.resolve(menu);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { createRouter, createWebHashHistory } from "vue-router";
|
|
||||||
import routes from "./routes";
|
|
||||||
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHashHistory(),
|
|
||||||
routes,
|
|
||||||
});
|
|
||||||
export default router;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="page-wrapper">
|
|
||||||
<div class="main-container">
|
|
||||||
<Card :bordered="false" dis-hover>
|
|
||||||
<p slot="title">接口日志</p>
|
|
||||||
<Alert type="info" show-icon>功能开发中,敬请期待...</Alert>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { useDefaultStore } from "@/store";
|
|
||||||
const store = useDefaultStore();
|
|
||||||
</script>
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Modal v-model="show" :mask-closable="false" width="600" :title="titleComp" @on-visible-change="visibleChange">
|
|
||||||
<Form ref="formRef" class="mr-15" :model="form" :rules="rules" :label-width="100">
|
|
||||||
<FormItem :label="bookInfo.name + '名称:'" prop="name">
|
|
||||||
<Input v-model="form.name" :placeholder="`请输入${bookInfo.name}名称`" />
|
|
||||||
</FormItem>
|
|
||||||
<Row class="mb-20">
|
|
||||||
<Col span="12">
|
|
||||||
<FormItem label="年级" prop="grade">
|
|
||||||
<Select v-model="form.grade" transfer clearable placeholder="选择年级">
|
|
||||||
<Option v-for="item in dictData.grade" :value="item.id" :key="item.id">{{ item.name }}
|
|
||||||
</Option>
|
|
||||||
</Select>
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
<Col span="12">
|
|
||||||
<FormItem label="学期:" prop="semester">
|
|
||||||
<Select v-model="form.semester" transfer placeholder="选择学期">
|
|
||||||
<Option :value="0">上学期</Option>
|
|
||||||
<Option :value="1">下学期</Option>
|
|
||||||
</Select>
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Row class="mb-20">
|
|
||||||
<Col span="12">
|
|
||||||
<FormItem label="学科:" prop="subject">
|
|
||||||
<Select v-model="form.subject" transfer clearable placeholder="选择科目">
|
|
||||||
<Option v-for="item in dictData.subject" :value="item.code" :key="item.code">{{ item.name }}
|
|
||||||
</Option>
|
|
||||||
</Select>
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
<Col span="12">
|
|
||||||
<FormItem label="出版社:" prop="press">
|
|
||||||
<Input v-model="form.press" placeholder="输入出版社" />
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Row class="mb-20">
|
|
||||||
<Col span="12">
|
|
||||||
<FormItem label="出版年份:" prop="outTime">
|
|
||||||
<DatePicker v-model="form.outTime" type="year" placeholder="请选择年份" format="yyyy" transfer
|
|
||||||
:editable="false" :clearable="true" @on-change="v => form.outTime = v"></DatePicker>
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Form>
|
|
||||||
<template #footer>
|
|
||||||
<Button type="primary" :loading="loading" @click="handleSubmit">提交</Button>
|
|
||||||
<Button @click="handleClose">取消</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { reactive, watch, computed, nextTick } from 'vue'
|
|
||||||
import { BooxType, BooxTypeDict } from '@/types/index'
|
|
||||||
import useBookInfo from '../useBookInfo'
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: Boolean,
|
|
||||||
id: { type: [String, Number], required: true },// 0 新增 / 其它 编辑
|
|
||||||
bookType: { type: Number, default: 1, required: true }//教材|教辅
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'success'])
|
|
||||||
|
|
||||||
const show = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: val => emit('update:modelValue', val)
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: dictData } = useRequest(Apis.Base.getSystemDict({
|
|
||||||
cacheFor: 1000 * 60 * 60
|
|
||||||
}), {
|
|
||||||
initialData: {
|
|
||||||
grade: [],
|
|
||||||
press: [],
|
|
||||||
subject: [],
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const isAdd = computed(() => props.id == 0)
|
|
||||||
|
|
||||||
const formRef = ref()
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
const { bookInfo } = useBookInfo()
|
|
||||||
|
|
||||||
const rules = {
|
|
||||||
name: [{ required: true, message: '请输入名称' }],
|
|
||||||
subject: [{ required: true, message: '请选择学科' }],
|
|
||||||
grade: [{ required: true, message: '请选择年级' }],
|
|
||||||
press: [{ required: false, message: '请输入出版社' }],
|
|
||||||
semester: [{ required: true, message: '请选择学期' }],
|
|
||||||
outTime: [{ required: false, message: '请选择年份' }],
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// 打开弹窗时拉取详情(编辑模式)
|
|
||||||
watch(() => props.modelValue, async val => {
|
|
||||||
if (!val) return
|
|
||||||
if (isAdd.value) {
|
|
||||||
// 新增重置空表单
|
|
||||||
nextTick(() => formRef.value?.resetFields())
|
|
||||||
Object.assign(form.value, { ...form.value, bookType: props.bookType })
|
|
||||||
} else {
|
|
||||||
const resp = await Apis.Knowledge.getTextbookById({ params: { id: props.id } })
|
|
||||||
Object.assign(form.value, resp)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const titleComp = computed(() => {
|
|
||||||
return (isAdd.value ? '新增' : '编辑') + bookInfo.value.name
|
|
||||||
})
|
|
||||||
|
|
||||||
function visibleChange(v: any) {
|
|
||||||
if (!v) setTimeout(() => {
|
|
||||||
formRef.value?.resetFields()
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
let validate = await formRef.value.validate()
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
if (!validate) throw '请填写红色星号内容'
|
|
||||||
await sendForm()
|
|
||||||
handleClose()
|
|
||||||
emit('success')
|
|
||||||
} catch (error) {
|
|
||||||
Message.info(error)
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const {
|
|
||||||
form,
|
|
||||||
send: sendForm,
|
|
||||||
} = useForm((dto) => Apis.Knowledge.saveTextbook({ data: dto }), {
|
|
||||||
initialForm: {
|
|
||||||
id: null,
|
|
||||||
name: '',
|
|
||||||
subject: '',
|
|
||||||
grade: '',
|
|
||||||
press: '',
|
|
||||||
semester: '',
|
|
||||||
outTime: ''
|
|
||||||
},
|
|
||||||
resetAfterSubmiting: false
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="main-wrapper">
|
|
||||||
<div class="main-container">
|
|
||||||
<div class="fix mb-12">
|
|
||||||
<Button type="primary" @click="curEditId = 0, eidtModelShow = true" class="fl">
|
|
||||||
添加{{ bookInfo.name }}
|
|
||||||
</Button>
|
|
||||||
<Button type="success" @click="uploadType = 2, uploadModelShow = true" class="fr ml-12">
|
|
||||||
导入{{ bookInfo.name }}和目录树
|
|
||||||
</Button>
|
|
||||||
<Button type="success" @click="uploadType = 1, uploadModelShow = true" class="fr ml-12">
|
|
||||||
导入{{ bookInfo.name }}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="search-wrap fix">
|
|
||||||
<Select class="search-wrap_sel" v-model="dataParams.subject" clearable placeholder="选择科目">
|
|
||||||
<Option v-for="item in dictData.subject" :value="item.code" :key="item.code">{{ item.name }}
|
|
||||||
</Option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select class="search-wrap_sel ml-12" v-model="dataParams.grade" clearable placeholder="选择年级">
|
|
||||||
<Option v-for="item in dictData.grade" :value="item.id" :key="item.id">{{ item.name }}
|
|
||||||
</Option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select class="search-wrap_sel ml-12" v-model="dataParams.press" clearable placeholder="选择出版社">
|
|
||||||
<Option v-for="item in pressList" :value="item.v" :key="item.k">{{ item.v }}
|
|
||||||
</Option>
|
|
||||||
</Select>
|
|
||||||
<Select class="search-wrap_sel ml-12" v-model="dataParams.semester" clearable placeholder="选择学期">
|
|
||||||
<Option :value="0">上学期</Option>
|
|
||||||
<Option :value="1">下学期</Option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Input class="fr ml-12" v-model="dataParams.name" placeholder="输入关键字筛选" suffix="ios-search" clearable
|
|
||||||
style="width:200px" />
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<TablePageAlova ref="tableRef" :dataApi="tableApi" :columns="columns" :dataParams="dataParams"
|
|
||||||
:tableHeight="tableHeight">
|
|
||||||
</TablePageAlova>
|
|
||||||
<Drawer width="400" :title="drawerTitle" :closable="false" v-model="showDrawer">
|
|
||||||
<MyTree :treeData="knowTreeData" :setting="treeSetting2" :autoClick="false" />
|
|
||||||
</Drawer>
|
|
||||||
</div>
|
|
||||||
<div class="modal-wrap">
|
|
||||||
<EditModel v-model="eidtModelShow" :id="curEditId" :bookType="bookInfo.type" @success="reloadTable" />
|
|
||||||
|
|
||||||
<Modal v-model="uploadModelShow" :mask-closable="false" width="600" :title="modalTitle">
|
|
||||||
<BschImport ref="importRef" :tempFileUrl="tempFileUrl" :uploadUrl="uploadUrl" :uploadData="uploadData"
|
|
||||||
@on-success="reloadTable" />
|
|
||||||
<template #footer>
|
|
||||||
<Button @click="uploadModelShow = false">关闭</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
<Modal v-model="knowledgeModelShow" :mask-closable="false" width="900" title="目录树管理">
|
|
||||||
<Knowledge :id="curKnowId" :name="curKnowName" v-if="knowledgeModelShow" />
|
|
||||||
<template #footer>
|
|
||||||
<Button @click="knowledgeModelShow = false">关闭</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- BschImport -->
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { } from "vue";
|
|
||||||
import TablePageAlova from '@/components/TablePageAlova.vue'
|
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
|
||||||
import Knowledge from './Knowledge.vue'
|
|
||||||
import { useDefaultStore } from "@/store";
|
|
||||||
import EditModel from './EditModel.vue'
|
|
||||||
import MyTree from '@/components/MyTree.vue'
|
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
|
||||||
import BschImport from '@/components/BschImport.vue'
|
|
||||||
import useBookInfo from "../useBookInfo";
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { OperatorEnum } from "@/types";
|
|
||||||
|
|
||||||
const store = useDefaultStore();
|
|
||||||
const { $conf, $bus, $utils } = useCurrentInstance();
|
|
||||||
const { bookInfo } = useBookInfo()
|
|
||||||
const eidtModelShow = ref(false);
|
|
||||||
const knowledgeModelShow = ref(false);
|
|
||||||
const uploadType = ref(0);
|
|
||||||
const uploadModelShow = ref(false);
|
|
||||||
const curEditId = ref(0)
|
|
||||||
const tableRef = useTemplateRef('tableRef')
|
|
||||||
|
|
||||||
const curKnowId = ref(0)
|
|
||||||
const curKnowName = ref('')
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const dataParams = ref({
|
|
||||||
pageSize: '',
|
|
||||||
pageIndex: '',
|
|
||||||
grade: null,
|
|
||||||
semester: null,
|
|
||||||
subject: null,
|
|
||||||
press: null,
|
|
||||||
name: null,
|
|
||||||
bookType: bookInfo.value.type,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
const treeSetting2 = ref({
|
|
||||||
view: {
|
|
||||||
dblClickExpand: false,
|
|
||||||
showIcon: false,
|
|
||||||
},
|
|
||||||
edit: {
|
|
||||||
enable: true,
|
|
||||||
showRemoveBtn: false,
|
|
||||||
showRenameBtn: false,
|
|
||||||
drag: false,
|
|
||||||
},
|
|
||||||
check: {
|
|
||||||
enable: false,
|
|
||||||
},
|
|
||||||
callback: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
const tableHeight = ref($utils.useTableHeight({ isOperat: true, isPage: true, isSearch: true }));
|
|
||||||
|
|
||||||
const { data: dictData } = useRequest(Apis.Base.getSystemDict({
|
|
||||||
cacheFor: 1000 * 60 * 60
|
|
||||||
}), {
|
|
||||||
initialData: {
|
|
||||||
grade: [],
|
|
||||||
textPress: [],
|
|
||||||
teachingPress: [],
|
|
||||||
subject: [],
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: knowTreeData, send: sendKnowledgTree } =
|
|
||||||
useRequest((textbookId: number) => Apis.Knowledge.getTreeByTextbookId({
|
|
||||||
params: { textbookId },
|
|
||||||
transform: (res: any) => {
|
|
||||||
let arr = res || [];
|
|
||||||
return arr.map((item: any) => {
|
|
||||||
item.open = true;
|
|
||||||
return item;
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}), {
|
|
||||||
initialData: [],
|
|
||||||
immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const showDrawer = ref(false);
|
|
||||||
const drawerTitle = ref('');
|
|
||||||
const columns = ref([
|
|
||||||
{ type: 'index', title: '序号', width: 80 },
|
|
||||||
{ title: bookInfo.value.name + '名称', key: 'name' },
|
|
||||||
{ title: '年级', key: 'gradeName' },
|
|
||||||
{
|
|
||||||
title: '学期',
|
|
||||||
key: 'semester',
|
|
||||||
render: (h: any, params: any) => {
|
|
||||||
return h('span', params.row.semester == 0 ? '上学期' : '下学期');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ title: '科目', key: 'subjectName' },
|
|
||||||
{ title: '出版社', key: 'press' },
|
|
||||||
{ title: '出版年份', key: 'outTime' },
|
|
||||||
// {
|
|
||||||
// title: '封面',
|
|
||||||
// key: 'coverImage',
|
|
||||||
// width: 120,
|
|
||||||
// render: (row: any, { row: r }: any) => {
|
|
||||||
// const src = r.coverImage || new URL('@/assets/image/default-bg1.png', import.meta.url).href
|
|
||||||
// return h('img', {
|
|
||||||
// src,
|
|
||||||
// style: {
|
|
||||||
// width: '20px',
|
|
||||||
// height: '30px',
|
|
||||||
// objectFit: 'cover',
|
|
||||||
// borderRadius: '4px'
|
|
||||||
// },
|
|
||||||
// alt: '封面'
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
width: 240,
|
|
||||||
align: 'center',
|
|
||||||
render: (h: any, params: any) => {
|
|
||||||
const disabled = params.row.disabled;
|
|
||||||
return [
|
|
||||||
|
|
||||||
|
|
||||||
$utils.operatingTextHandle(h, '目录树管理', '', () => {
|
|
||||||
// router.push({
|
|
||||||
// path: '/' + bookInfo.value.path + '/manage/knowledge',
|
|
||||||
// query: { id: params.row.id, name: params.row.name },
|
|
||||||
// });
|
|
||||||
curKnowId.value = params.row.id;
|
|
||||||
curKnowName.value = params.row.name;
|
|
||||||
|
|
||||||
knowledgeModelShow.value = true;
|
|
||||||
|
|
||||||
}, disabled),
|
|
||||||
$utils.operatingTextHandle(h, '查看', '', () => {
|
|
||||||
drawerTitle.value = params.row.name;
|
|
||||||
showDrawer.value = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
sendKnowledgTree(params.row.id);
|
|
||||||
}, 100);
|
|
||||||
}),
|
|
||||||
$utils.operatingTextHandle(h, '编辑', '', () => {
|
|
||||||
curEditId.value = params.row.id;
|
|
||||||
eidtModelShow.value = true;
|
|
||||||
}, disabled),
|
|
||||||
$utils.operatingTextHandle(h, '删除', 'text-error', () => {
|
|
||||||
removeSave(params.row.id)
|
|
||||||
}, disabled),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
const modalTitle = computed(() => {
|
|
||||||
return '导入' + bookInfo.value.name + (uploadType.value === 1 ? '' : '和知识树');
|
|
||||||
})
|
|
||||||
const tempFileUrl = computed(() => {
|
|
||||||
const method = uploadType.value == 1 ? 'getTextbookTemplate' : 'getTextbookKnowledgeTemplate';
|
|
||||||
let url = $conf.serviceHost + `/api/knowledge/${method}?bookType=` + bookInfo.value.type;
|
|
||||||
return url;
|
|
||||||
})
|
|
||||||
const uploadUrl = computed(() => {
|
|
||||||
const method = uploadType.value == 1 ? '/api/knowledge/importTextbook' : '/api/knowledge/importTextbookInfo';
|
|
||||||
return $conf.serviceHost + method;
|
|
||||||
})
|
|
||||||
const uploadData = computed(() => {
|
|
||||||
return { bookType: bookInfo.value.type };
|
|
||||||
})
|
|
||||||
|
|
||||||
const pressList = computed(() => {
|
|
||||||
return bookInfo.value.type == 1 ? dictData.value.textPress : dictData.value.teachingPress
|
|
||||||
})
|
|
||||||
|
|
||||||
const clean = (obj: Record<string, any>) =>
|
|
||||||
Object.fromEntries(
|
|
||||||
Object.entries(obj).filter(([, v]) => v != null && v !== '')
|
|
||||||
)
|
|
||||||
|
|
||||||
const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
|
||||||
return Apis.Knowledge.searchTextbooks({
|
|
||||||
params: clean({ ...dataParams.value, ...params }),
|
|
||||||
cacheFor: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
onBeforeMount(() => {
|
|
||||||
$bus.emit('resetLayout', { identity: bookInfo.value.identity, modId: (bookInfo.value.baseModId + 4) });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* removeSave
|
|
||||||
*/
|
|
||||||
async function removeSave(id: number) {
|
|
||||||
await MyConfirm({
|
|
||||||
msg: "确定删除该教材吗?",
|
|
||||||
info: "关联目录树也将会被删除,删除后不可恢复!",
|
|
||||||
async onOk() {
|
|
||||||
try {
|
|
||||||
await Apis.Knowledge.deleteTextbook({ params: { id } });
|
|
||||||
Message.success("操作成功!");
|
|
||||||
tableRef.value?.reloadData(OperatorEnum.del);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
Message.error(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function reloadTable() {
|
|
||||||
if (!curEditId.value) tableRef.value?.reloadData(OperatorEnum.add);
|
|
||||||
else tableRef.value?.reloadData(OperatorEnum.edit);
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="main-wrapper" style="min-height: 400px;">
|
|
||||||
<div class="main-container">
|
|
||||||
<div class="search-wrap ">
|
|
||||||
<h5 class="title mt-4 fl">{{ title }}</h5>
|
|
||||||
<Button type="success" class="fr" @click="uploadModelShow = true">导入目录树</Button>
|
|
||||||
</div>
|
|
||||||
<Table row-key="id" :columns="columns" :loading="tableLoading" :indent-size="20" :data="tableDataComp">
|
|
||||||
<template #loading>
|
|
||||||
<div class="table-spin-load">加载中</div>
|
|
||||||
</template>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
<div class="modal-wrap">
|
|
||||||
<Modal v-model="editNodeModal.modal" :title="editNodeModal.title" width="450px" :mask-closable="false">
|
|
||||||
<Form ref="refFormData" :label-width="100" :model="editNodeModal" class="myform mb-20 mr-20">
|
|
||||||
<Form-Item label="节点名称:" prop="name"
|
|
||||||
:rules="{ required: true, message: '请输入节点名称', trigger: 'blur' }">
|
|
||||||
<Input ref="refNodeNameInput" maxlength="30" v-model="editNodeModal.name" />
|
|
||||||
</Form-Item>
|
|
||||||
</Form>
|
|
||||||
<template #footer>
|
|
||||||
<Button type="primary" :loading="editNodeModal.loading" @click="saveEditNode">确认</Button>
|
|
||||||
<Button :loading="editNodeModal.loading" @click="editNodeModal.modal = false">取消</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<!-- 排序弹窗 -->
|
|
||||||
<Modal v-model="sortModal.modal" title="子节点排序" width="450px" :mask-closable="false">
|
|
||||||
<div class="sort-wrap">
|
|
||||||
<draggable v-model="sortModal.list" item-key="id" handle=".drag-handle" animation="150">
|
|
||||||
<template #item="{ element }">
|
|
||||||
<li class="sort-item">
|
|
||||||
<span class="drag-handle">≡</span>
|
|
||||||
<span class="name">{{ element.name }}</span>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
</draggable>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<Button type="primary" :loading="sortModal.loading" @click="saveSort">保存排序</Button>
|
|
||||||
<Button :loading="sortModal.loading" @click="sortModal.modal = false">取消</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal v-model="uploadModelShow" :mask-closable="false" width="600" title="导入目录树">
|
|
||||||
<BschImport ref="importRef" :tempFileUrl="tempFileUrl" :uploadUrl="uploadUrl" :uploadData="uploadData"
|
|
||||||
@on-success="reloadTable" />
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<Button @click="uploadModelShow = false">关闭</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import useBookInfo from "../useBookInfo";
|
|
||||||
import draggable from 'vuedraggable'
|
|
||||||
import BschImport from '@/components/BschImport.vue'
|
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
id: 0,
|
|
||||||
name: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { bookInfo } = useBookInfo()
|
|
||||||
const { $conf, $bus, $utils } = useCurrentInstance();
|
|
||||||
const bookId = ref<number>()
|
|
||||||
|
|
||||||
|
|
||||||
const title = computed(() => {
|
|
||||||
return props.name;
|
|
||||||
});
|
|
||||||
const refFormData = useTemplateRef('refFormData')
|
|
||||||
const refNodeNameInput = useTemplateRef('refNodeNameInput')
|
|
||||||
onBeforeMount(() => {
|
|
||||||
|
|
||||||
bookId.value = Number(props.id);
|
|
||||||
sendTable();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const uploadModelShow = ref(false);
|
|
||||||
const tempFileUrl = computed(() => {
|
|
||||||
let url = $conf.serviceHost + "/api/knowledge/getKnowledgeTemplate";
|
|
||||||
return url;
|
|
||||||
})
|
|
||||||
const uploadUrl = computed(() => {
|
|
||||||
return $conf.serviceHost + "/api/knowledge/importKnowledge";
|
|
||||||
})
|
|
||||||
const uploadData = computed(() => {
|
|
||||||
return { textBookId: bookId.value };
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const editNodeModal = reactive({
|
|
||||||
id: 0,
|
|
||||||
name: '',
|
|
||||||
title: '',
|
|
||||||
type: 0,
|
|
||||||
modal: false,
|
|
||||||
loading: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const sortModal = reactive({
|
|
||||||
modal: false,
|
|
||||||
loading: false,
|
|
||||||
parentId: 0, // 当前要排序的父节点
|
|
||||||
list: [] as { id: number; name: string }[]
|
|
||||||
})
|
|
||||||
|
|
||||||
const columns = ref([
|
|
||||||
{ title: '目录树结构', key: 'name', tree: true },
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
width: 300,
|
|
||||||
align: 'center',
|
|
||||||
render: (h: any, params: any) => {
|
|
||||||
const { id, name, children } = params.row;
|
|
||||||
const addChild = $utils.operatingTextHandle(h, '添加子节点', '', () => showAddNode(id));
|
|
||||||
const sortNode = $utils.operatingTextHandle(h, '子节点排序', '', () => showSortNode(params.row), !children)
|
|
||||||
const editNode = $utils.operatingTextHandle(h, '编辑', '', () => showEditNode(id, name));
|
|
||||||
const delNode = $utils.operatingTextHandle(h, '删除', 'text-error', () => showDeleteNode(id, name))
|
|
||||||
return [addChild, editNode, delNode, sortNode];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
const tableDataComp = computed(() => {
|
|
||||||
function recursion(tree: any[], level: number): any[] {
|
|
||||||
tree.forEach(element => {
|
|
||||||
element._showChildren = true;
|
|
||||||
element.enable = element.show;
|
|
||||||
element.level = level;
|
|
||||||
if (!!element.children && element.children.length > 0)
|
|
||||||
element.children = recursion(element.children, level + 1);
|
|
||||||
else if (!element.isParent) {
|
|
||||||
delete element.children;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return tree;
|
|
||||||
}
|
|
||||||
return recursion(tableData.value, 1)
|
|
||||||
})
|
|
||||||
const { loading: tableLoading, data: tableData, send: sendTable } = useRequest(
|
|
||||||
() => Apis.Knowledge.getTreeByTextbookId({ params: { textbookId: bookId.value }, cacheFor: null }),
|
|
||||||
{
|
|
||||||
initialData: [],
|
|
||||||
immediate: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function showAddNode(pid: number) {
|
|
||||||
editNodeModal.title = '新增子节点';
|
|
||||||
editNodeModal.modal = true;
|
|
||||||
editNodeModal.id = pid;
|
|
||||||
editNodeModal.type = 1;
|
|
||||||
editNodeModal.name = '';
|
|
||||||
nextTick().then(() => {
|
|
||||||
refFormData.value?.resetFields()
|
|
||||||
refNodeNameInput.value?.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
function showEditNode(id: number, name: string) {
|
|
||||||
editNodeModal.title = '编辑节点';
|
|
||||||
editNodeModal.modal = true;
|
|
||||||
editNodeModal.id = id;
|
|
||||||
editNodeModal.type = 2;
|
|
||||||
nextTick().then(() => {
|
|
||||||
refFormData.value?.resetFields()
|
|
||||||
refNodeNameInput.value.focus()
|
|
||||||
editNodeModal.name = name;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function showSortNode(parent: any) {
|
|
||||||
/* 仅当存在子节点才允许排序 */
|
|
||||||
if (!parent.children || !parent.children.length) {
|
|
||||||
return Message.warning('暂无子节点,无需排序')
|
|
||||||
}
|
|
||||||
sortModal.parentId = parent.id
|
|
||||||
sortModal.list = parent.children.map((c: any) => ({ id: c.id, name: c.name }))
|
|
||||||
sortModal.modal = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showDeleteNode(id: number, name: string) {
|
|
||||||
Modal.confirm({
|
|
||||||
title: '删除目录树节点',
|
|
||||||
content: `确定删除名为【${name}】的目录树节点记录?`,
|
|
||||||
onOk: async () => {
|
|
||||||
await Apis.Knowledge.deleteKnowledge({ params: { id } })
|
|
||||||
Message.success('删除成功!');
|
|
||||||
sendTable()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEditNode() {
|
|
||||||
let isAdd = editNodeModal.type == 1 ? true : false;
|
|
||||||
const validate = await refFormData.value.validate();
|
|
||||||
if (!validate) {
|
|
||||||
Message.error('请填写红色星号内容!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { id, name } = editNodeModal;
|
|
||||||
editNodeModal.loading = true;
|
|
||||||
if (isAdd) {
|
|
||||||
await Apis.Knowledge.addKnowledge({ params: { name, parentId: id } })
|
|
||||||
sendTable()
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
await Apis.Knowledge.updateKnowledge({ params: { name, id } })
|
|
||||||
sendTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
editNodeModal.loading = false;
|
|
||||||
editNodeModal.modal = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* 真正保存排序 */
|
|
||||||
async function saveSort() {
|
|
||||||
sortModal.loading = true
|
|
||||||
try {
|
|
||||||
/* 把排序后的 id 顺序传给后台 */
|
|
||||||
const idList = sortModal.list.map(i => i.id)
|
|
||||||
await Apis.Knowledge.updateOrderCode({ data: idList })
|
|
||||||
Message.success('排序已保存')
|
|
||||||
sortModal.modal = false
|
|
||||||
sendTable() // 刷新表格
|
|
||||||
} finally {
|
|
||||||
sortModal.loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function reloadTable() {
|
|
||||||
sendTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
h5.title {
|
|
||||||
position: relative;
|
|
||||||
padding-left: 12px;
|
|
||||||
line-height: 24px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
width: 3px;
|
|
||||||
background: #5979ea;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 4px;
|
|
||||||
position: absolute;
|
|
||||||
top: 5px;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-wrap {
|
|
||||||
padding: 0 12px;
|
|
||||||
max-height: 420px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 3px 0;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
|
|
||||||
.drag-handle {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 400;
|
|
||||||
margin-right: 8px;
|
|
||||||
color: #999;
|
|
||||||
cursor: move;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { computed } from "vue";
|
|
||||||
export default function useBookInfo() {
|
|
||||||
const bookInfo = computed(() => {
|
|
||||||
return { identity: 2, baseModId: 2000, name: "教材", path: "textbook", type: 1 };
|
|
||||||
});
|
|
||||||
return { bookInfo };
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="page-wrapper tree-wrapper">
|
|
||||||
<Group class="tree-container" v-model:curTreeNode="selectedNode" :treeApi="treeApi" @on-change="onTreeChange"
|
|
||||||
@on-tree-data="onTreeData" />
|
|
||||||
<div class="main-container">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<Button type="primary" @click="showAddUser">
|
|
||||||
<Icon type="md-add" style="margin-right: 4px" />新增用户
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Input placeholder="输入用户名 / 真实姓名搜索" class="fr" search clearable suffix="ios-search" style="width: 220px"
|
|
||||||
v-model="keyword" @on-search="onSearch" @on-clear="onSearch" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
|
||||||
|
|
||||||
<!-- 容器组件:新增 / 编辑 -->
|
|
||||||
<AddUserContainer ref="addRef" />
|
|
||||||
<EditUserContainer ref="editRef" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { ref, computed } from "vue";
|
|
||||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
|
||||||
import { OperatorEnum } from '@/types';
|
|
||||||
import Group from './group/index.vue'
|
|
||||||
import AddUserContainer from './user/AddUserContainer.vue';
|
|
||||||
import EditUserContainer from './user/EditUserContainer.vue';
|
|
||||||
|
|
||||||
defineOptions({ name: "GroupUser" });
|
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const treeApi = () => Apis.SysGroup.getTree();
|
|
||||||
|
|
||||||
/* ================= 组织树 ================= */
|
|
||||||
const selectedNode = ref<any>({})
|
|
||||||
const treeData = ref<any[]>([])
|
|
||||||
|
|
||||||
function onTreeChange(node: any) {
|
|
||||||
selectedNode.value = node;
|
|
||||||
dataParams.value.groupId = node?.id ?? null;
|
|
||||||
tableRef.value?.refreshData();
|
|
||||||
}
|
|
||||||
function onTreeData(tree: any[]) {
|
|
||||||
treeData.value = tree;
|
|
||||||
}
|
|
||||||
function onSearch() {
|
|
||||||
dataParams.value.name = keyword.value.trim() || undefined;
|
|
||||||
tableRef.value?.refreshData();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================= 所在组下拉(拍平组织树) ================= */
|
|
||||||
const groupOptions = computed(() => {
|
|
||||||
const flat: { id: number; name: string }[] = [];
|
|
||||||
const walk = (list: any[], prefix = '') => {
|
|
||||||
list.forEach(n => {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
walk(treeData.value);
|
|
||||||
return flat;
|
|
||||||
})
|
|
||||||
|
|
||||||
/* ================= 表格数据源 ================= */
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
|
||||||
const keyword = ref('')
|
|
||||||
const dataParams = ref<{
|
|
||||||
name: string | undefined;
|
|
||||||
groupId: number | null;
|
|
||||||
}>({
|
|
||||||
name: undefined,
|
|
||||||
groupId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
|
||||||
return Apis.SysUser.list({
|
|
||||||
params: {
|
|
||||||
name: dataParams.value.name,
|
|
||||||
groupId: dataParams.value.groupId ?? undefined,
|
|
||||||
// 选中组织时包含子组用户;未选中时不传
|
|
||||||
withSub: dataParams.value.groupId != null ? 1 : undefined,
|
|
||||||
pageIndex: params.pageIndex,
|
|
||||||
pageSize: params.pageSize,
|
|
||||||
},
|
|
||||||
cacheFor: null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }))
|
|
||||||
|
|
||||||
/* ================= 用户身份(roles[])展示 ================= */
|
|
||||||
const userTypeMap: Record<number, string> = {
|
|
||||||
1: '管理员', 2: '老师', 3: '校领导', 4: '局领导',
|
|
||||||
5: '学生', 6: '家长', 7: '工作号',
|
|
||||||
};
|
|
||||||
const roleMap: Record<number, string> = {
|
|
||||||
1: '超级管理员', 2: '学校管理员', 3: '普通用户',
|
|
||||||
};
|
|
||||||
function describeRoles(roles: any): string {
|
|
||||||
if (!Array.isArray(roles) || roles.length === 0) return '-';
|
|
||||||
const userTypes: string[] = [];
|
|
||||||
const specificRoles: string[] = [];
|
|
||||||
roles.forEach((r: any) => {
|
|
||||||
const n = Number(r);
|
|
||||||
if (userTypeMap[n]) userTypes.push(userTypeMap[n]);
|
|
||||||
else if (roleMap[n]) specificRoles.push(roleMap[n]);
|
|
||||||
});
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (userTypes.length) parts.push(userTypes.join('/'));
|
|
||||||
if (specificRoles.length) parts.push(specificRoles.join('/'));
|
|
||||||
return parts.length ? parts.join(' · ') : '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================= 表格列 ================= */
|
|
||||||
const columns = [
|
|
||||||
{ type: 'index', title: '序号', width: 70, align: 'center' },
|
|
||||||
{ title: '用户名', key: 'account', minWidth: 120 },
|
|
||||||
{ title: '真实姓名', key: 'realName', minWidth: 120 },
|
|
||||||
{ title: '手机号', key: 'phone', minWidth: 140 },
|
|
||||||
{
|
|
||||||
title: '用户身份', key: 'roles', minWidth: 180,
|
|
||||||
render: (h: any, { row }: any) => h('span', describeRoles(row.roles)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态', key: 'disabled', width: 90, align: 'center',
|
|
||||||
render: (h: any, { row }: any) =>
|
|
||||||
h('Tag', { color: row.disabled ? 'default' : 'success' },
|
|
||||||
row.disabled ? '已禁用' : '启用'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作', key: 'action', width: 220, align: 'center', fixed: 'right' as const,
|
|
||||||
render: (h: any, { row }: any) => {
|
|
||||||
const opList: any[] = [
|
|
||||||
h('a', {
|
|
||||||
style: { marginRight: '8px', color: '#577afb' },
|
|
||||||
onClick: () => handleEdit(row),
|
|
||||||
}, '编辑'),
|
|
||||||
h('a', {
|
|
||||||
style: { marginRight: '8px', color: '#ed4014' },
|
|
||||||
onClick: () => handleDelete(row),
|
|
||||||
}, '删除'),
|
|
||||||
h('a', {
|
|
||||||
style: { color: row.disabled ? '#19be6b' : '#ff9900' },
|
|
||||||
onClick: () => handleToggleStatus(row),
|
|
||||||
}, row.disabled ? '启用' : '禁用'),
|
|
||||||
];
|
|
||||||
return h('div', opList);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/* ================= 新增 ================= */
|
|
||||||
const addRef = ref();
|
|
||||||
function showAddUser() {
|
|
||||||
const curGroupId = selectedNode.value?.id ?? null;
|
|
||||||
addRef.value?.open(groupOptions.value, curGroupId, () => {
|
|
||||||
tableRef.value?.reloadData(OperatorEnum.add);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================= 编辑 ================= */
|
|
||||||
const editRef = ref();
|
|
||||||
function handleEdit(row: any) {
|
|
||||||
editRef.value?.open(row, groupOptions.value, () => {
|
|
||||||
tableRef.value?.reloadData(OperatorEnum.edit);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================= 删除 ================= */
|
|
||||||
async function handleDelete(row: any) {
|
|
||||||
await MyConfirm({
|
|
||||||
msg: `确定删除用户「${row.realName || row.account}」吗?`,
|
|
||||||
info: '删除后不可恢复!',
|
|
||||||
async onOk() {
|
|
||||||
try {
|
|
||||||
await Apis.SysUser.delete({ pathParams: { id: row.id } });
|
|
||||||
Message.success('删除成功!');
|
|
||||||
tableRef.value?.reloadData(OperatorEnum.del);
|
|
||||||
} catch (e: any) {
|
|
||||||
Message.error(e?.message || '删除失败');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================= 禁用 / 启用 ================= */
|
|
||||||
async function handleToggleStatus(row: any) {
|
|
||||||
const isDisabled = !!row.disabled;
|
|
||||||
await MyConfirm({
|
|
||||||
msg: `确定要${isDisabled ? '启用' : '禁用'}用户「${row.realName || row.account}」吗?`,
|
|
||||||
async onOk() {
|
|
||||||
try {
|
|
||||||
if (isDisabled) {
|
|
||||||
await Apis.SysUser.enable({ pathParams: { id: row.id } });
|
|
||||||
} else {
|
|
||||||
await Apis.SysUser.disable({ pathParams: { id: row.id } });
|
|
||||||
}
|
|
||||||
Message.success(isDisabled ? '已启用!' : '已禁用!');
|
|
||||||
tableRef.value?.reloadData(OperatorEnum.sel);
|
|
||||||
} catch (e: any) {
|
|
||||||
Message.error(e?.message || '操作失败');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<template>
|
|
||||||
<UserForm v-model="visible" :initForm="initFormData" :groupOptions="groupOptions" :isEdit="false"
|
|
||||||
:loading="submitting" @submit="handleSubmit" @cancel="visible = false" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from "vue";
|
|
||||||
import UserForm from "./UserForm.vue";
|
|
||||||
|
|
||||||
/* ---------- 对外暴露:由父组件(group-user/index)调用 ---------- */
|
|
||||||
const visible = ref(false);
|
|
||||||
|
|
||||||
// 所在组下拉数据(拍平后的组织树)
|
|
||||||
const groupOptions = ref<any[]>([]);
|
|
||||||
|
|
||||||
// 新增模式:始终传空初始值;若带 defaultGroupId 则预填 groupId
|
|
||||||
const initFormData = ref<Record<string, any>>({});
|
|
||||||
|
|
||||||
// 成功后回调
|
|
||||||
let onSuccessCallback: (() => void) | null = null;
|
|
||||||
|
|
||||||
// useForm 托管提交:loading=submitting、sendForm 发请求、onSuccess/onError 处理结果
|
|
||||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
|
||||||
(dto) => Apis.SysUser.add({ data: dto }),
|
|
||||||
{ initialForm: { account: '', realName: '', password: '', confirmPassword: '', idcard: '', phone: '', phone2: '', gender: 0, groupId: undefined, userType: [], role: undefined }, resetAfterSubmiting: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
onSuccess(() => {
|
|
||||||
Message.success('新增用户成功!');
|
|
||||||
visible.value = false;
|
|
||||||
onSuccessCallback?.();
|
|
||||||
});
|
|
||||||
onError((event) => {
|
|
||||||
Message.error(event?.error?.message || '新增失败');
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ---------- 打开弹窗 ---------- */
|
|
||||||
function open(groups: any[], defaultGroupId?: number | null, onSuccess?: () => void) {
|
|
||||||
groupOptions.value = groups || [];
|
|
||||||
// 若已选中某个组织节点,新增弹窗自动带入该组;未选时不带入
|
|
||||||
initFormData.value = defaultGroupId != null ? { groupId: defaultGroupId } : {};
|
|
||||||
onSuccessCallback = onSuccess || null;
|
|
||||||
visible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
|
|
||||||
async function handleSubmit(formData: Record<string, any>) {
|
|
||||||
form.value = buildPayload(formData);
|
|
||||||
await sendForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把表单字段映射成 SysUserRequest:
|
|
||||||
* - 只提交后端 SysUserRequest 里声明的字段
|
|
||||||
* - "用户身份"(userType checkbox) + "角色属性"(role select) 合并为 roles[](去重)
|
|
||||||
*/
|
|
||||||
function buildPayload(formData: Record<string, any>) {
|
|
||||||
const roleSet = new Set<number>();
|
|
||||||
(formData.userType || []).forEach((v: any) => roleSet.add(Number(v)));
|
|
||||||
if (formData.role != null && formData.role !== '') roleSet.add(Number(formData.role));
|
|
||||||
|
|
||||||
return {
|
|
||||||
account: formData.account,
|
|
||||||
password: formData.password,
|
|
||||||
confirmPassword: formData.confirmPassword,
|
|
||||||
realName: formData.realName,
|
|
||||||
idcard: formData.idcard || undefined,
|
|
||||||
phone: formData.phone,
|
|
||||||
groupId: formData.groupId,
|
|
||||||
roles: Array.from(roleSet),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ open, visible });
|
|
||||||
</script>
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
<template>
|
|
||||||
<UserForm v-model="visible" :initForm="detailData" :groupOptions="groupOptions" :isEdit="true"
|
|
||||||
:loading="submitting" @submit="handleSubmit" @cancel="visible = false" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from "vue";
|
|
||||||
import UserForm from "./UserForm.vue";
|
|
||||||
|
|
||||||
/* ---------- 对外暴露 ---------- */
|
|
||||||
const visible = ref(false);
|
|
||||||
const groupOptions = ref<any[]>([]);
|
|
||||||
|
|
||||||
// 编辑模式:当前编辑的用户 ID
|
|
||||||
const currentId = ref<number | null>(null);
|
|
||||||
// 编辑模式:回填数据(来自当前行,辅以 roles[] 拆分到 userType/role)
|
|
||||||
const detailData = ref<Record<string, any>>({});
|
|
||||||
|
|
||||||
// 成功后回调
|
|
||||||
let onSuccessCallback: (() => void) | null = null;
|
|
||||||
|
|
||||||
// useForm 托管提交——currentId 是 ref,闭包内 .value 在 sendForm 时取当前值
|
|
||||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
|
||||||
(dto) => Apis.SysUser.edit({ pathParams: { id: currentId.value }, data: dto }),
|
|
||||||
{ initialForm: { id: null, account: '', realName: '', password: '', confirmPassword: '', idcard: '', phone: '', phone2: '', gender: 0, groupId: undefined, userType: [], role: undefined }, resetAfterSubmiting: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
onSuccess(() => {
|
|
||||||
Message.success('编辑用户成功!');
|
|
||||||
visible.value = false;
|
|
||||||
onSuccessCallback?.();
|
|
||||||
});
|
|
||||||
onError((event) => {
|
|
||||||
Message.error(event?.error?.message || '编辑失败');
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ---------- 打开编辑弹窗 ---------- */
|
|
||||||
/**
|
|
||||||
* @param row - 当前行数据(来自 SysUser.list 接口返回的 SysUserVo)
|
|
||||||
* @param groups - 所在组下拉(拍平的组织树)
|
|
||||||
* @param onSuccess - 编辑成功后的回调
|
|
||||||
*/
|
|
||||||
function open(row: any, groups: any[], onSuccess?: () => void) {
|
|
||||||
if (!row?.id) {
|
|
||||||
Message.error('用户 ID 不存在');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
currentId.value = row.id;
|
|
||||||
groupOptions.value = groups || [];
|
|
||||||
detailData.value = buildInitForm(row);
|
|
||||||
onSuccessCallback = onSuccess || null;
|
|
||||||
visible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 把行数据映射成表单回填数据 ---------- */
|
|
||||||
function buildInitForm(row: any) {
|
|
||||||
const roles: number[] = Array.isArray(row.roles) ? row.roles : [];
|
|
||||||
// 用户身份(业务身份)= 1..7;角色属性(具体角色)= 1..3
|
|
||||||
const userType = roles.filter(r => r >= 1 && r <= 7);
|
|
||||||
const role = roles.filter(r => r >= 1 && r <= 3).slice(-1)[0];
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
account: row.account || '',
|
|
||||||
realName: row.realName || '',
|
|
||||||
idcard: row.idcard || '',
|
|
||||||
phone: row.phone || '',
|
|
||||||
groupId: row.groupId,
|
|
||||||
password: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
phone2: '',
|
|
||||||
gender: 0,
|
|
||||||
userType,
|
|
||||||
role,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
|
|
||||||
async function handleSubmit(formData: Record<string, any>) {
|
|
||||||
if (!currentId.value) return;
|
|
||||||
form.value = buildPayload(formData, currentId.value);
|
|
||||||
await sendForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把表单字段映射成 SysUserRequest:
|
|
||||||
* - 只提交后端 SysUserRequest 里声明的字段
|
|
||||||
* - "用户身份"(userType checkbox) + "角色属性"(role select) 合并为 roles[](去重)
|
|
||||||
* - 编辑时密码留空表示不修改,所以密码字段不传
|
|
||||||
*/
|
|
||||||
function buildPayload(formData: Record<string, any>, id: number) {
|
|
||||||
const roleSet = new Set<number>();
|
|
||||||
(formData.userType || []).forEach((v: any) => roleSet.add(Number(v)));
|
|
||||||
if (formData.role != null && formData.role !== '') roleSet.add(Number(formData.role));
|
|
||||||
|
|
||||||
const payload: any = {
|
|
||||||
id,
|
|
||||||
account: formData.account,
|
|
||||||
realName: formData.realName,
|
|
||||||
idcard: formData.idcard || undefined,
|
|
||||||
phone: formData.phone,
|
|
||||||
groupId: formData.groupId,
|
|
||||||
roles: Array.from(roleSet),
|
|
||||||
};
|
|
||||||
// 编辑时若填写了密码才提交(密码为空表示不修改)
|
|
||||||
if (formData.password) {
|
|
||||||
payload.password = formData.password;
|
|
||||||
payload.confirmPassword = formData.confirmPassword;
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ open, visible });
|
|
||||||
</script>
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Modal v-model="show" :mask-closable="false" width="800" scrollable :title="title"
|
|
||||||
@on-visible-change="onVisibleChange">
|
|
||||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="130" class="user-form width-95">
|
|
||||||
<!-- 第 1 行:用户名 / 真实姓名 -->
|
|
||||||
<Row>
|
|
||||||
<Col span="12">
|
|
||||||
<FormItem label="用户名:" prop="account">
|
|
||||||
<Input v-model="form.account" :disabled="isEdit" placeholder="请输入用户名" />
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
<Col span="12">
|
|
||||||
<FormItem label="真实姓名:" prop="realName">
|
|
||||||
<Input v-model="form.realName" placeholder="请输入真实姓名" />
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<!-- 第 2 行:密码 / 确认密码(编辑时变为「重置密码」语义,但视觉一致) -->
|
|
||||||
<Row>
|
|
||||||
<Col span="12">
|
|
||||||
<FormItem label="密码:" prop="password">
|
|
||||||
<Input v-model="form.password" type="password" :placeholder="isEdit ? '不修改请留空' : '请输入密码'" />
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
<Col span="12">
|
|
||||||
<FormItem label="确认密码:" prop="confirmPassword">
|
|
||||||
<Input v-model="form.confirmPassword" type="password"
|
|
||||||
:placeholder="isEdit ? '不修改请留空' : '请再次确认密码'" />
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<!-- 第 3 行:身份证号 / 手机号码 -->
|
|
||||||
<Row>
|
|
||||||
<Col span="12">
|
|
||||||
<FormItem label="身份证号:" prop="idcard">
|
|
||||||
<Input v-model="form.idcard" placeholder="请输入身份证号" />
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
<Col span="12">
|
|
||||||
<FormItem label="手机号码:" prop="phone">
|
|
||||||
<Input v-model="form.phone" placeholder="请输入手机号码" />
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<!-- 第 4 行:手机号码2 / 性别 -->
|
|
||||||
<Row>
|
|
||||||
<Col span="12">
|
|
||||||
<FormItem label="手机号码2:">
|
|
||||||
<Input v-model="form.phone2" placeholder="请输入手机号码" />
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
<Col span="12">
|
|
||||||
<FormItem label="性别:">
|
|
||||||
<RadioGroup v-model="form.gender">
|
|
||||||
<Radio :label="0">女</Radio>
|
|
||||||
<Radio :label="1">男</Radio>
|
|
||||||
</RadioGroup>
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<!-- 第 5 行:所在组(整行) -->
|
|
||||||
<Row>
|
|
||||||
<Col span="24">
|
|
||||||
<FormItem label="所在组:" prop="groupId">
|
|
||||||
<Select v-model="form.groupId" placeholder="请选择所在组" filterable transfer>
|
|
||||||
<Option v-for="g in groupOptions" :key="g.id" :value="g.id" :label="g.name" />
|
|
||||||
</Select>
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<!-- 第 6 行:用户身份(checkbox 多选,整行) -->
|
|
||||||
<Row>
|
|
||||||
<Col span="24">
|
|
||||||
<FormItem label="用户身份:" prop="userType">
|
|
||||||
<CheckboxGroup v-model="form.userType" class="user-type-group">
|
|
||||||
<Checkbox v-for="opt in userTypeOptions" :key="opt.value" :label="opt.value">
|
|
||||||
{{ opt.label }}
|
|
||||||
</Checkbox>
|
|
||||||
</CheckboxGroup>
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<!-- 第 7 行:角色属性(整行) -->
|
|
||||||
<Row>
|
|
||||||
<Col span="24">
|
|
||||||
<FormItem label="角色属性:" prop="role">
|
|
||||||
<Select v-model="form.role" placeholder="请选择" clearable transfer style="width: 220px">
|
|
||||||
<Option v-for="r in roleOptions" :key="r.value" :value="r.value" :label="r.label" />
|
|
||||||
</Select>
|
|
||||||
</FormItem>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<Button type="primary" :loading="loading" @click="handleSubmit">提交</Button>
|
|
||||||
<Button @click="handleCancel">取消</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, reactive, watch } from "vue";
|
|
||||||
|
|
||||||
/* ---------- Props / Emits ---------- */
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
modelValue: boolean;
|
|
||||||
initForm?: Record<string, any>;
|
|
||||||
groupOptions?: any[];
|
|
||||||
isEdit?: boolean;
|
|
||||||
loading?: boolean;
|
|
||||||
}>(), {
|
|
||||||
initForm: () => ({}),
|
|
||||||
groupOptions: () => [],
|
|
||||||
isEdit: false,
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'submit', 'cancel']);
|
|
||||||
|
|
||||||
const show = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: val => emit('update:modelValue', val),
|
|
||||||
});
|
|
||||||
|
|
||||||
const title = computed(() => props.isEdit ? '编辑用户' : '新增用户');
|
|
||||||
|
|
||||||
/* ---------- 表单数据 ---------- */
|
|
||||||
const defaultForm = (): Record<string, any> => ({
|
|
||||||
id: null,
|
|
||||||
account: '',
|
|
||||||
realName: '',
|
|
||||||
password: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
idcard: '',
|
|
||||||
phone: '',
|
|
||||||
phone2: '',
|
|
||||||
gender: 0,
|
|
||||||
groupId: undefined as number | undefined,
|
|
||||||
userType: [] as number[],
|
|
||||||
role: undefined as number | undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = reactive(defaultForm());
|
|
||||||
|
|
||||||
// 外部 initForm 变化时回填
|
|
||||||
watch(() => props.initForm, (val) => {
|
|
||||||
if (val && Object.keys(val).length > 0) {
|
|
||||||
Object.assign(form, defaultForm(), val);
|
|
||||||
} else {
|
|
||||||
Object.assign(form, defaultForm());
|
|
||||||
}
|
|
||||||
}, { immediate: true, deep: true });
|
|
||||||
|
|
||||||
/* ---------- 用户身份(业务身份多选)---------- */
|
|
||||||
const userTypeOptions = [
|
|
||||||
{ value: 1, label: '管理员' },
|
|
||||||
{ value: 2, label: '老师' },
|
|
||||||
{ value: 3, label: '校领导' },
|
|
||||||
{ value: 4, label: '局领导' },
|
|
||||||
{ value: 5, label: '学生' },
|
|
||||||
{ value: 6, label: '家长' },
|
|
||||||
{ value: 7, label: '工作号' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/* ---------- 角色属性(具体角色单选,固定枚举)---------- */
|
|
||||||
const roleOptions = [
|
|
||||||
{ value: 1, label: '超级管理员' },
|
|
||||||
{ value: 2, label: '学校管理员' },
|
|
||||||
{ value: 3, label: '普通用户' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/* ---------- 校验规则 ---------- */
|
|
||||||
// 自定义确认密码校验
|
|
||||||
const validateConfirmPassword = (rule: any, value: string, callback: any) => {
|
|
||||||
if (!props.isEdit) {
|
|
||||||
// 新增:必填 + 必须与密码一致
|
|
||||||
if (!value) {
|
|
||||||
callback(new Error('请再次输入密码'));
|
|
||||||
} else if (value !== form.password) {
|
|
||||||
callback(new Error('两次输入的密码不一致'));
|
|
||||||
} else {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 编辑:留空表示不修改密码;非空时必须与密码一致
|
|
||||||
if (value && value !== form.password) {
|
|
||||||
callback(new Error('两次输入的密码不一致'));
|
|
||||||
} else {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 自定义密码校验(编辑时可留空)
|
|
||||||
const validatePassword = (rule: any, value: string, callback: any) => {
|
|
||||||
if (!props.isEdit && !value) {
|
|
||||||
callback(new Error('请输入密码'));
|
|
||||||
} else if (value && value.length < 6) {
|
|
||||||
callback(new Error('密码长度不能少于 6 位'));
|
|
||||||
} else {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rules = {
|
|
||||||
account: [
|
|
||||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
|
||||||
{ min: 3, max: 30, message: '用户名长度 3-30 位', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
realName: [
|
|
||||||
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
password: [{ validator: validatePassword, trigger: 'blur' }],
|
|
||||||
confirmPassword: [{ validator: validateConfirmPassword, trigger: 'blur' }],
|
|
||||||
phone: [
|
|
||||||
{ required: true, message: '请输入手机号码', trigger: 'blur' },
|
|
||||||
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
groupId: [
|
|
||||||
{ required: true, type: 'number' as const, message: '请选择所在组', trigger: 'change' },
|
|
||||||
],
|
|
||||||
userType: [
|
|
||||||
{ required: true, type: 'array' as const, min: 1, message: '请选择至少一个用户身份', trigger: 'change' },
|
|
||||||
],
|
|
||||||
role: [
|
|
||||||
{ required: true, type: 'number' as const, message: '请选择角色属性', trigger: 'change' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------- 提交 ---------- */
|
|
||||||
const formRef = ref();
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
try {
|
|
||||||
await formRef.value?.validate();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
emit('submit', { ...form });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancel() {
|
|
||||||
emit('cancel');
|
|
||||||
}
|
|
||||||
|
|
||||||
function onVisibleChange(val: boolean) {
|
|
||||||
if (!val) {
|
|
||||||
emit('cancel');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.user-form {
|
|
||||||
margin: 0 auto;
|
|
||||||
max-height: 60vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
padding-right: 12px;
|
|
||||||
|
|
||||||
:deep(.ivu-form-item) {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ivu-form-item-label) {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #515a6e;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ivu-row) {
|
|
||||||
margin-left: 0 !important;
|
|
||||||
margin-right: 0 !important;
|
|
||||||
margin-bottom: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ivu-row:last-child) {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 用户身份多选:横向换行,避免撑爆 Col */
|
|
||||||
.user-type-group {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0 18px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import { ConfigEnv, UserConfig, defineConfig } from "vite";
|
|
||||||
import { createHtmlPlugin } from "vite-plugin-html";
|
|
||||||
import vue from "@vitejs/plugin-vue";
|
|
||||||
import path, { resolve } from "path";
|
|
||||||
import { createSvgIconsPlugin } from "vite-plugin-svg-icons";
|
|
||||||
import { viteMockServe } from "vite-plugin-mock";
|
|
||||||
import ViteAutoImport from "unplugin-auto-import/vite";
|
|
||||||
|
|
||||||
import UnoCSS from "@unocss/vite";
|
|
||||||
import presetUno from "@unocss/preset-uno";
|
|
||||||
|
|
||||||
import presetRemToPx from "@unocss/preset-rem-to-px";
|
|
||||||
|
|
||||||
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 : "base";
|
|
||||||
const outDir = command === "build" ? `dist/${subDir}` : "dist";
|
|
||||||
|
|
||||||
return {
|
|
||||||
plugins: [
|
|
||||||
vue(),
|
|
||||||
createSvgIconsPlugin({
|
|
||||||
iconDirs: [resolve(process.cwd(), "src/assets/icons")],
|
|
||||||
symbolId: "icon-[dir]-[name]", // symbol的id
|
|
||||||
inject: "body-last", // 插入的位置
|
|
||||||
customDomId: "__svg__icons__dom__", // svg的id
|
|
||||||
}),
|
|
||||||
viteMockServe({
|
|
||||||
mockPath: "./src/assets/mock",
|
|
||||||
}),
|
|
||||||
ViteAutoImport({
|
|
||||||
imports: [
|
|
||||||
"vue",
|
|
||||||
"vue-router",
|
|
||||||
{
|
|
||||||
"view-ui-plus": ["Button", "Table", "Modal", "Message", "Form", "FormItem", "Input", "Select"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
from: "alova/client",
|
|
||||||
imports: ["usePagination", "useRequest", "useWatcher", "useForm"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
dts: "./src/auto-imports.d.ts",
|
|
||||||
dirs: ["src/api"], // 自动导入 hooks
|
|
||||||
}),
|
|
||||||
createHtmlPlugin({
|
|
||||||
minify: true,
|
|
||||||
pages: [
|
|
||||||
{
|
|
||||||
entry: "src/main.ts",
|
|
||||||
template: "index.html",
|
|
||||||
filename: "index.html",
|
|
||||||
injectOptions: {
|
|
||||||
data: {
|
|
||||||
title: "铃铛学习中心",
|
|
||||||
injectScript: `<script src="./js/userType.js?type=teacher"></script>`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
UnoCSS({
|
|
||||||
presets: [
|
|
||||||
presetUno(), // 核心预设(Tailwind风格)
|
|
||||||
presetRemToPx({ baseFontSize: 4 }), // 1单位=1px(去rem化)
|
|
||||||
],
|
|
||||||
shortcuts: {
|
|
||||||
// 布局
|
|
||||||
"flex-center": "flex items-center justify-center",
|
|
||||||
// 常用组件
|
|
||||||
btn: "px-16px py-8px rounded-4px cursor-pointer transition-all duration-200",
|
|
||||||
"btn-primary": "bg-blue-600 text-white hover:bg-blue-700",
|
|
||||||
card: "bg-white rounded-8px shadow-md p-16px",
|
|
||||||
// 文本截断
|
|
||||||
"text-ellipsis": "truncate",
|
|
||||||
"text-multi-ellipsis": "line-clamp-2",
|
|
||||||
},
|
|
||||||
theme: {
|
|
||||||
fontSize: {
|
|
||||||
xs: "12px",
|
|
||||||
sm: "14px",
|
|
||||||
base: "16px",
|
|
||||||
lg: "18px",
|
|
||||||
xl: "20px",
|
|
||||||
"2xl": "24px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": path.resolve(__dirname, "./src"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
host: "localhost",
|
|
||||||
port: 8130,
|
|
||||||
// proxy: {
|
|
||||||
// // //api是自行设置的请求前缀,任何请求路径以/api开头的请求将被代理到对应的target目标
|
|
||||||
// // "/api": {
|
|
||||||
// // target: "http://localhost:8064/",
|
|
||||||
// // // target: "http://debugold.hongyucloud.com/ChildOptCourse",
|
|
||||||
// // changeOrigin: true, //需要代理跨域
|
|
||||||
// // rewrite: (path: any) => path.replace(/^\/api/, "/api/"), //路径重写,把'/api'替换为''
|
|
||||||
// // },
|
|
||||||
// },
|
|
||||||
},
|
|
||||||
// css预处理器
|
|
||||||
css: {
|
|
||||||
// preprocessorOptions: {
|
|
||||||
// scss: {
|
|
||||||
// additionalData: '@import "./src/assets/styles/utils/_varible.scss";',
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
},
|
|
||||||
base: "./",
|
|
||||||
build: {
|
|
||||||
target: "modules", //设置最终构建的浏览器兼容目标 //es2015(编译成es5) | modules
|
|
||||||
outDir, // 构建输出目录,根据环境变量区分 base/ld
|
|
||||||
assetsDir: "assets", // 静态资源得存放路径文件名 assets
|
|
||||||
sourcemap: false, //构建后是否生成 source map 文件
|
|
||||||
minify: "esbuild", // 项目压缩 :boolean | 'terser' | 'esbuild'
|
|
||||||
chunkSizeWarningLimit: 1000, //chunk 大小警告的限制(以 kbs 为单位)默认:500
|
|
||||||
cssTarget: "chrome61", //防止 vite 将 rgba() 颜色转化为 #RGBA 十六进制符号的形式 (要兼容的场景是安卓微信中的 webview 时,它不支持 CSS 中的 #RGBA 十六进制颜色符号)
|
|
||||||
rollupOptions: {
|
|
||||||
input: {
|
|
||||||
main: resolve("index.html"),
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
chunkFileNames: "static/js/[name]-[hash].js",
|
|
||||||
entryFileNames: "static/js/[name]-[hash].js",
|
|
||||||
assetFileNames: (assetInfo: any) => {
|
|
||||||
if (assetInfo.type === "asset" && /\.(jpe?g|png|gif|svg)$/i.test(assetInfo.name)) {
|
|
||||||
return "static/img/[name].[hash][ext]";
|
|
||||||
}
|
|
||||||
if (assetInfo.type === "asset" && /\.(ttf|woff|woff2|eot)$/i.test(assetInfo.name)) {
|
|
||||||
return "static/fonts/[name].[hash][ext]";
|
|
||||||
}
|
|
||||||
return "static/[ext]/[name]-[hash].[ext]";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
manualEntrypoint: "index.html",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="../../favicon.ico" />
|
||||||
|
<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>
|
||||||
|
<title><%- title %></title>
|
||||||
|
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
|
||||||
|
<script src="./libs/jquery.min.js"></script>
|
||||||
|
<script src="./libs/lodash.min.js"></script>
|
||||||
|
<script src="./libs/ztree/jquery.ztree.all.min.js"></script>
|
||||||
|
<script src=" https://base.bestsch.com/BschHome/base/js/bschload.min.js?v=2025011001"></script>
|
||||||
|
<%- injectScript %>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 933 B After Width: | Height: | Size: 933 B |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
@@ -1,268 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Deploy front/dist/prod to host /data/www/userbase/front using the Docker daemon
|
|
||||||
// that act_runner already mounts into the job container (/var/run/docker.sock).
|
|
||||||
//
|
|
||||||
// Why this script exists:
|
|
||||||
// act_runner (v0.6.1) ignores both `container.options` extra --volume and
|
|
||||||
// workflow `container.volumes` in this setup, so the host static dir can't be
|
|
||||||
// bind-mounted directly into the job container. But the host docker.sock IS
|
|
||||||
// mounted by default, so we use the Docker HTTP API to:
|
|
||||||
// 1. tar the built assets straight out of THIS container's filesystem
|
|
||||||
// (GET /containers/{jobId}/archive?path=...), and
|
|
||||||
// 2. upload that tar into a throwaway helper container that has
|
|
||||||
// /data/www/userbase mounted RW (PUT /containers/{helperId}/archive),
|
|
||||||
// then `mv` the extracted folder so files land at the correct top level.
|
|
||||||
//
|
|
||||||
// Diagnostics: every step is collected and written to /data/www/userbase/_debug.txt
|
|
||||||
// on the HOST via `docker exec` into the helper (the job container itself cannot
|
|
||||||
// write there). The Gitea logs API is unavailable here, so this file is our only
|
|
||||||
// reliable window into what happened.
|
|
||||||
//
|
|
||||||
// No docker CLI and no network egress required — we speak the Docker HTTP API
|
|
||||||
// over the unix socket directly with Node's http module.
|
|
||||||
|
|
||||||
const http = require('http');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const SOCKET = '/var/run/docker.sock';
|
|
||||||
const HOST_STATIC = '/data/www/userbase';
|
|
||||||
const HELPER_IMAGE = 'node:20'; // already cached on host (used by the job)
|
|
||||||
const STAGING = HOST_STATIC + '/_staging';
|
|
||||||
|
|
||||||
// --- low level docker http helpers -------------------------------------------
|
|
||||||
|
|
||||||
function dockerJson(method, p, bodyObj) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = bodyObj ? JSON.stringify(bodyObj) : null;
|
|
||||||
const headers = {};
|
|
||||||
if (data) {
|
|
||||||
headers['Content-Type'] = 'application/json';
|
|
||||||
headers['Content-Length'] = Buffer.byteLength(data);
|
|
||||||
}
|
|
||||||
const req = http.request(
|
|
||||||
{ socketPath: SOCKET, path: p, method, headers },
|
|
||||||
(res) => {
|
|
||||||
let buf = '';
|
|
||||||
res.on('data', (c) => (buf += c));
|
|
||||||
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
req.on('error', reject);
|
|
||||||
if (data) req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getArchive(id, p) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = http.request(
|
|
||||||
{
|
|
||||||
socketPath: SOCKET,
|
|
||||||
path: `/containers/${id}/archive?path=${encodeURIComponent(p)}`,
|
|
||||||
method: 'GET',
|
|
||||||
},
|
|
||||||
(res) => resolve(res)
|
|
||||||
);
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function putArchive(id, p, tarStream) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = http.request(
|
|
||||||
{
|
|
||||||
socketPath: SOCKET,
|
|
||||||
path: `/containers/${id}/archive?path=${encodeURIComponent(p)}`,
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/x-tar' },
|
|
||||||
},
|
|
||||||
(res) => {
|
|
||||||
let buf = '';
|
|
||||||
res.on('data', (c) => (buf += c));
|
|
||||||
res.on('end', () => resolve({ status: res.statusCode, body: buf }));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
req.on('error', reject);
|
|
||||||
tarStream.pipe(req);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run a command inside the helper and return its stdout/stderr (Detach:false so
|
|
||||||
// the call resolves only after the command finishes — reliable for sequencing).
|
|
||||||
async function runExec(id, cmd) {
|
|
||||||
const created = await dockerJson('POST', `/containers/${id}/exec`, {
|
|
||||||
Cmd: ['sh', '-c', cmd],
|
|
||||||
AttachStdout: true,
|
|
||||||
AttachStderr: true,
|
|
||||||
});
|
|
||||||
if (created.status >= 400) {
|
|
||||||
throw new Error('exec create failed: ' + created.status + ' ' + created.body);
|
|
||||||
}
|
|
||||||
const execId = JSON.parse(created.body).Id;
|
|
||||||
const started = await dockerJson('POST', `/exec/${execId}/start`, {
|
|
||||||
Detach: false,
|
|
||||||
Tty: false,
|
|
||||||
});
|
|
||||||
return started; // { status, body(stdout+stderr) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- helper container + diagnostics ------------------------------------------
|
|
||||||
|
|
||||||
function getContainerId() {
|
|
||||||
const cg = fs.readFileSync('/proc/self/cgroup', 'utf8');
|
|
||||||
const m = cg.match(/[0-9a-f]{64}/);
|
|
||||||
if (!m) {
|
|
||||||
const h = fs.readFileSync('/etc/hostname', 'utf8').trim();
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
return m[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
let HELPER_ID = null;
|
|
||||||
let DEBUG_BUF = [];
|
|
||||||
|
|
||||||
function dbg(line) {
|
|
||||||
const ts = new Date().toISOString();
|
|
||||||
DEBUG_BUF.push(`[${ts}] ${line}`);
|
|
||||||
console.log(line); // also emit to job log (best-effort)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the accumulated debug buffer to the HOST file via a base64 pipe. Passing
|
|
||||||
// the payload inline (single-quoted) avoids env-var/newline fragility that broke
|
|
||||||
// the earlier diagnostics channel. Uses Detach:false so we know it completed.
|
|
||||||
async function flushDebug() {
|
|
||||||
if (!HELPER_ID) {
|
|
||||||
console.error('flushDebug: no helper, cannot write host debug');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const content = DEBUG_BUF.join('\n') + '\n';
|
|
||||||
const b64 = Buffer.from(content, 'utf8').toString('base64');
|
|
||||||
const cmd = `echo ${b64} | base64 -d > ${HOST_STATIC}/_debug.txt`;
|
|
||||||
try {
|
|
||||||
const r = await runExec(HELPER_ID, cmd);
|
|
||||||
if (r.status >= 400) console.error('flushDebug exec failed:', r.status, r.body);
|
|
||||||
else dbg('debug written to host (' + content.length + ' bytes)');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('flushDebug error:', e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- main --------------------------------------------------------------------
|
|
||||||
|
|
||||||
function resolveDistPath() {
|
|
||||||
const candidates = [
|
|
||||||
path.join(process.cwd(), 'front', 'dist', 'prod'),
|
|
||||||
'/workspace/admin/userbase/front/dist/prod',
|
|
||||||
];
|
|
||||||
for (const c of candidates) {
|
|
||||||
if (fs.existsSync(c)) return c;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
dbg('START script version=2026-08-04c');
|
|
||||||
dbg('cwd=' + process.cwd());
|
|
||||||
|
|
||||||
// 1) helper container with the host static dir mounted RW; keep it alive.
|
|
||||||
const createBody = {
|
|
||||||
Image: HELPER_IMAGE,
|
|
||||||
HostConfig: { NetworkMode: 'none', Binds: [`${HOST_STATIC}:${HOST_STATIC}`] },
|
|
||||||
Cmd: ['sh', '-c', `mkdir -p ${STAGING} && exec tail -f /dev/null`],
|
|
||||||
};
|
|
||||||
const created = await dockerJson('POST', '/containers/create', createBody);
|
|
||||||
if (created.status >= 400) {
|
|
||||||
throw new Error('create helper failed: ' + created.status + ' ' + created.body);
|
|
||||||
}
|
|
||||||
HELPER_ID = JSON.parse(created.body).Id;
|
|
||||||
dbg('helper created id=' + HELPER_ID);
|
|
||||||
|
|
||||||
const started = await dockerJson('POST', `/containers/${HELPER_ID}/start`);
|
|
||||||
if (started.status >= 400) {
|
|
||||||
throw new Error('start helper failed: ' + started.status + ' ' + started.body);
|
|
||||||
}
|
|
||||||
dbg('helper started');
|
|
||||||
// clean any stale staging from a previous failed run
|
|
||||||
await runExec(HELPER_ID, `rm -rf ${STAGING} && mkdir -p ${STAGING}`);
|
|
||||||
|
|
||||||
// 2) locate the built assets inside THIS container.
|
|
||||||
const jobId = getContainerId();
|
|
||||||
dbg('job container id=' + jobId);
|
|
||||||
|
|
||||||
const distDir = resolveDistPath();
|
|
||||||
if (!distDir) {
|
|
||||||
dbg('dist dir NOT found; cwd listing:');
|
|
||||||
try {
|
|
||||||
dbg(fs.readdirSync(process.cwd()).join(' '));
|
|
||||||
const front = path.join(process.cwd(), 'front');
|
|
||||||
if (fs.existsSync(front)) dbg('front listing: ' + fs.readdirSync(front).join(' '));
|
|
||||||
} catch (e) {
|
|
||||||
dbg('ls err: ' + e.message);
|
|
||||||
}
|
|
||||||
throw new Error('built dist/prod not found under any candidate path');
|
|
||||||
}
|
|
||||||
dbg('dist dir=' + distDir);
|
|
||||||
dbg('dist listing: ' + fs.readdirSync(distDir).join(' '));
|
|
||||||
|
|
||||||
// 3) tar of the built assets, fetched from THIS container's own filesystem.
|
|
||||||
// We fetch WITHOUT a trailing slash so the tar contains a top-level "prod/"
|
|
||||||
// folder; we then mv it into place (deterministic, no reliance on the
|
|
||||||
// trailing-slash "contents only" quirk which this moby build ignores).
|
|
||||||
const tar = await getArchive(jobId, distDir);
|
|
||||||
if (tar.statusCode !== 200) {
|
|
||||||
let msg = '';
|
|
||||||
tar.on('data', (c) => (msg += c));
|
|
||||||
await new Promise((r) => tar.on('end', r));
|
|
||||||
throw new Error('get archive failed (status ' + tar.statusCode + '): ' + msg);
|
|
||||||
}
|
|
||||||
dbg('fetched dist tar (content-type=' + tar.headers['content-type'] + ')');
|
|
||||||
|
|
||||||
// 4) upload the tar into the staging dir of the helper's mounted host dir.
|
|
||||||
const put = await putArchive(HELPER_ID, STAGING, tar);
|
|
||||||
dbg('put archive status=' + put.status + (put.status >= 400 ? ' ' + put.body : ''));
|
|
||||||
if (put.status >= 400) {
|
|
||||||
throw new Error('put archive failed: ' + put.status + ' ' + put.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) move staging/prod -> HOST_STATIC/front (deterministic top-level landing).
|
|
||||||
const mv = await runExec(
|
|
||||||
HELPER_ID,
|
|
||||||
`rm -rf ${HOST_STATIC}/front && mv ${STAGING}/prod ${HOST_STATIC}/front && rm -rf ${STAGING}`
|
|
||||||
);
|
|
||||||
dbg('mv status=' + mv.status + ' out=' + mv.body.replace(/\n/g, ' | '));
|
|
||||||
|
|
||||||
// 6) verify on host via exec into helper.
|
|
||||||
const verify = await runExec(
|
|
||||||
HELPER_ID,
|
|
||||||
`echo "count=$(find ${HOST_STATIC}/front -type f | wc -l)"; ` +
|
|
||||||
`echo "has_stray_prod=$(test -d ${HOST_STATIC}/front/prod && echo YES || echo NO)"; ` +
|
|
||||||
`ls -la ${HOST_STATIC}/front | head -20`
|
|
||||||
);
|
|
||||||
dbg('verify output: ' + verify.body.replace(/\n/g, ' | '));
|
|
||||||
|
|
||||||
dbg('DEPLOY OK -> ' + HOST_STATIC + '/front (top-level, no prod/ nesting)');
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.then(async () => {
|
|
||||||
// On success, keep diagnostics only briefly, then remove the debug file so
|
|
||||||
// it is not left web-served at /userbase/_debug.txt. Failures keep it for
|
|
||||||
// post-mortem (see .catch below).
|
|
||||||
await flushDebug();
|
|
||||||
try {
|
|
||||||
if (HELPER_ID) await runExec(HELPER_ID, `rm -f ${HOST_STATIC}/_debug.txt`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('cleanup debug file failed:', e.message);
|
|
||||||
}
|
|
||||||
if (HELPER_ID) await dockerJson('DELETE', `/containers/${HELPER_ID}?force=true`);
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch(async (err) => {
|
|
||||||
dbg('DEPLOY FAILED: ' + err.stack);
|
|
||||||
await flushDebug(); // keep debug file on failure for post-mortem
|
|
||||||
if (HELPER_ID) await dockerJson('DELETE', `/containers/${HELPER_ID}?force=true`);
|
|
||||||
console.error('DEPLOY FAILED:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
target/*
|
|
||||||
!target/userbase.jar
|
|
||||||
src
|
|
||||||
.idea
|
|
||||||
*.iml
|
|
||||||
*.log
|
|
||||||
.workbuddy
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
EXCLUSION_MISSING. current:
|
|
||||||
EOF
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# UserBase 后端镜像(基于已构建好的 target/userbase.jar)
|
|
||||||
# 构建上下文 = service 目录: docker build -f docker/Dockerfile -t <img> .
|
|
||||||
FROM eclipse-temurin:8-jre
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends curl tzdata \
|
|
||||||
&& ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
|
||||||
&& echo "Asia/Shanghai" > /etc/timezone \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY target/userbase.jar /app/userbase.jar
|
|
||||||
# 镜像内 application-prod.yml 仅作「nacos 不可达时的兜底默认」;
|
|
||||||
# 正常启动由 run-app.sh 从 nacos 拉取线上配置(dataId=userbase-prod.yml)后覆盖。
|
|
||||||
COPY docker/application-prod.yml /app/config/application-prod.yml
|
|
||||||
COPY docker/run-app.sh /app/run-app.sh
|
|
||||||
RUN chmod +x /app/run-app.sh && mkdir -p /data/upload
|
|
||||||
|
|
||||||
EXPOSE 8060
|
|
||||||
ENTRYPOINT ["/app/run-app.sh"]
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# UserBase 生产配置(线上以 nacos 的 userbase-prod.yml 为准,本文件是 nacos 不可达时的兜底)
|
|
||||||
server:
|
|
||||||
port: 8060
|
|
||||||
servlet:
|
|
||||||
# 对外经 caddy 暴露为 http://www.yunhaixingtong.com/userbase
|
|
||||||
context-path: /userbase
|
|
||||||
|
|
||||||
spring:
|
|
||||||
mvc:
|
|
||||||
date-format: yyyy-MM-dd HH:mm:ss
|
|
||||||
servlet:
|
|
||||||
multipart:
|
|
||||||
max-file-size: 50MB
|
|
||||||
max-request-size: 200MB
|
|
||||||
jackson:
|
|
||||||
time-zone: GMT+8
|
|
||||||
date-format: yyyy-MM-dd HH:mm:ss
|
|
||||||
|
|
||||||
redis:
|
|
||||||
host: redis-server
|
|
||||||
port: 6379
|
|
||||||
database: 0
|
|
||||||
password: 'wv&WcDYAmLtqTIGM'
|
|
||||||
timeout: 2000ms
|
|
||||||
data:
|
|
||||||
redis:
|
|
||||||
repositories:
|
|
||||||
enabled: false
|
|
||||||
|
|
||||||
datasource:
|
|
||||||
url: jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
|
||||||
username: root
|
|
||||||
password: 'CVf^x$sW*b6CGHxh'
|
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
|
||||||
min-idle: 10
|
|
||||||
max-active: 30
|
|
||||||
|
|
||||||
jpa:
|
|
||||||
database: MySQL
|
|
||||||
show-sql: false
|
|
||||||
hibernate:
|
|
||||||
ddl-auto: update
|
|
||||||
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
|
|
||||||
properties:
|
|
||||||
hibernate.order_by.default_null_ordering: last
|
|
||||||
hibernate:
|
|
||||||
dialect: org.hibernate.dialect.MySQL5InnoDBDialect
|
|
||||||
open-in-view: true
|
|
||||||
|
|
||||||
rabbitmq:
|
|
||||||
host: rabbitmq
|
|
||||||
port: 5672
|
|
||||||
username: admin
|
|
||||||
password: 'JdU29I#Q2E4yXoUl'
|
|
||||||
listener:
|
|
||||||
direct:
|
|
||||||
auto-startup: false
|
|
||||||
simple:
|
|
||||||
auto-startup: false
|
|
||||||
|
|
||||||
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
|
|
||||||
yh:
|
|
||||||
sso:
|
|
||||||
server-url-prefix: http://sso.yunhaixingtong.com
|
|
||||||
client-host-url: http://www.yunhaixingtong.com/userbase
|
|
||||||
allow-headers: all
|
|
||||||
allow-origins: all
|
|
||||||
url-pattern: /api/*
|
|
||||||
mobi-api-pattern: /mobiapi/*
|
|
||||||
app-auth:
|
|
||||||
enabled: true
|
|
||||||
# 本应用自身身份(客户端签名用,若 UserBase 需调用其他带签名服务)
|
|
||||||
app-key: 100616
|
|
||||||
app-secret: 1febb9502d2744b6a235d3e4cfde4a2d
|
|
||||||
# 受信应用(服务端验签用):appKey -> appSecret
|
|
||||||
trusted-apps:
|
|
||||||
100616: 1febb9502d2744b6a235d3e4cfde4a2d
|
|
||||||
|
|
||||||
ADMIN_TAG_ID: 290
|
|
||||||
|
|
||||||
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
|
|
||||||
FILE_PATH: /data/upload
|
|
||||||
FILE_WEB_PATH: http://www.yunhaixingtong.com/userbase/upload/
|
|
||||||
ZIYUAN_WEB_PATH_HTTP: http://base.bestsch.com/ziyuanku/file/
|
|
||||||
UPLOAD_WEB_PATH_HTTP: http://www.yunhaixingtong.com/userbase/upload/file/
|
|
||||||
ORC_SERVICE_URL: http://oa.bestsch.com/OrcService/api/Ocr/Upload
|
|
||||||
|
|
||||||
logging:
|
|
||||||
level:
|
|
||||||
root: INFO
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# UserBase 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml)
|
|
||||||
# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到
|
|
||||||
# 镜像内烤好的 application-prod.yml 兜底默认。
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "[run-app] starting UserBase (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..."
|
|
||||||
|
|
||||||
exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/userbase.jar \
|
|
||||||
--spring.profiles.active=prod
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import ctypes
|
|
||||||
from ctypes import wintypes
|
|
||||||
|
|
||||||
rstrtmgr = ctypes.windll.rstrtmgr
|
|
||||||
|
|
||||||
class RM_UNIQUE_PROCESS(ctypes.Structure):
|
|
||||||
_fields_ = [("dwProcessId", wintypes.DWORD),
|
|
||||||
("ProcessStartTime", wintypes.FILETIME)]
|
|
||||||
|
|
||||||
class RM_PROCESS_INFO(ctypes.Structure):
|
|
||||||
_fields_ = [("Process", RM_UNIQUE_PROCESS),
|
|
||||||
("strAppName", ctypes.c_wchar * 255),
|
|
||||||
("strServiceShortName", ctypes.c_wchar * 63),
|
|
||||||
("ApplicationType", ctypes.c_int),
|
|
||||||
("AppStatus", ctypes.c_uint),
|
|
||||||
("TSSessionId", wintypes.DWORD),
|
|
||||||
("bRestartable", wintypes.BOOL)]
|
|
||||||
|
|
||||||
RmStartSession = rstrtmgr.RmStartSession
|
|
||||||
RmStartSession.argtypes = [ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32, ctypes.c_wchar_p]
|
|
||||||
RmStartSession.restype = ctypes.c_uint32
|
|
||||||
|
|
||||||
RmRegisterResources = rstrtmgr.RmRegisterResources
|
|
||||||
RmRegisterResources.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_wchar_p,
|
|
||||||
ctypes.c_uint32, ctypes.POINTER(ctypes.c_void_p),
|
|
||||||
ctypes.c_uint32, ctypes.c_wchar_p]
|
|
||||||
RmRegisterResources.restype = ctypes.c_uint32
|
|
||||||
|
|
||||||
RmGetList = rstrtmgr.RmGetList
|
|
||||||
RmGetList.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32),
|
|
||||||
ctypes.POINTER(RM_PROCESS_INFO), ctypes.POINTER(ctypes.c_uint32)]
|
|
||||||
RmGetList.restype = ctypes.c_uint32
|
|
||||||
|
|
||||||
RmEndSession = rstrtmgr.RmEndSession
|
|
||||||
RmEndSession.argtypes = [ctypes.c_uint32]
|
|
||||||
RmEndSession.restype = ctypes.c_uint32
|
|
||||||
|
|
||||||
session = ctypes.c_uint32(0)
|
|
||||||
desc = ctypes.create_unicode_buffer("diag")
|
|
||||||
rc = RmStartSession(ctypes.byref(session), 0, desc)
|
|
||||||
if rc != 0:
|
|
||||||
print("RmStartSession failed", rc)
|
|
||||||
raise SystemExit
|
|
||||||
|
|
||||||
path = ctypes.create_unicode_buffer(r"C:\ub-build\userbase\classes\application.yml")
|
|
||||||
rc = RmRegisterResources(session, 1, path, 0, None, 0, None)
|
|
||||||
print("RmRegisterResources rc=", rc)
|
|
||||||
|
|
||||||
nNeeded = ctypes.c_uint32(0)
|
|
||||||
nActual = ctypes.c_uint32(0)
|
|
||||||
reason = ctypes.c_uint32(0)
|
|
||||||
rc = RmGetList(session, ctypes.byref(nNeeded), ctypes.byref(nActual), None, ctypes.byref(reason))
|
|
||||||
print("first RmGetList rc=", rc, "needed=", nNeeded.value)
|
|
||||||
if nNeeded.value > 0:
|
|
||||||
arr = (RM_PROCESS_INFO * nNeeded.value)()
|
|
||||||
nActual = ctypes.c_uint32(nNeeded.value)
|
|
||||||
rc = RmGetList(session, ctypes.byref(nNeeded), ctypes.byref(nActual), arr, ctypes.byref(reason))
|
|
||||||
print("second RmGetList rc=", rc, "actual=", nActual.value)
|
|
||||||
for i in range(nActual.value):
|
|
||||||
p = arr[i]
|
|
||||||
print("HOLDER PID=", p.Process.dwProcessId, "app=", p.strAppName)
|
|
||||||
else:
|
|
||||||
print("NO PROCESS HOLDER (likely ACL/permission, not a lock)")
|
|
||||||
RmEndSession(session)
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
<parent>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
|
||||||
<version>2.7.18</version>
|
|
||||||
<relativePath/> <!-- lookup parent from repository -->
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<groupId>com.yunhai</groupId>
|
|
||||||
<artifactId>yunhaibase</artifactId>
|
|
||||||
<version>0.0.1</version>
|
|
||||||
<name>UserBase</name>
|
|
||||||
<description>用户中心</description>
|
|
||||||
<properties>
|
|
||||||
<java.version>1.8</java.version>
|
|
||||||
<cn.hutool.version>5.8.36</cn.hutool.version>
|
|
||||||
<easyexcel.version>3.1.2</easyexcel.version>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencyManagement>
|
|
||||||
<!-- 基础平台版本管理(含 yh-* 架包与 spring-boot-dependencies 对齐) -->
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.yunhai.platform</groupId>
|
|
||||||
<artifactId>yh-platform-dependencies</artifactId>
|
|
||||||
<version>1.0.0-SNAPSHOT</version>
|
|
||||||
<type>pom</type>
|
|
||||||
<scope>import</scope>
|
|
||||||
</dependency>
|
|
||||||
<!-- Spring Cloud Alibaba(Nacos 配置中心,对齐 Boot 2.7) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.alibaba.cloud</groupId>
|
|
||||||
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
|
|
||||||
<version>2021.0.5.0</version>
|
|
||||||
<type>pom</type>
|
|
||||||
<scope>import</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
</dependencyManagement>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- 仅引入 Spring Security 的加密模块(BCryptPasswordEncoder),不引入整套安全框架;密码加密与 CAS 校验共用 -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.security</groupId>
|
|
||||||
<artifactId>spring-security-crypto</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-devtools</artifactId>
|
|
||||||
<scope>runtime</scope>
|
|
||||||
<optional>true</optional>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.alibaba.cloud</groupId>
|
|
||||||
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<!-- Boot 2.4+ 默认不再处理 bootstrap.yml,需显式引入以继续从 Nacos 拉取配置 -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.cloud</groupId>
|
|
||||||
<artifactId>spring-cloud-starter-bootstrap</artifactId>
|
|
||||||
<version>3.1.8</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Spring Cache 核心抽象(@Cacheable / @CachePut / @CacheEvict 注解支持) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-cache</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Spring Cache 抽象层(含 CaffeineCacheManager) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework</groupId>
|
|
||||||
<artifactId>spring-context-support</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- 本地内存缓存兜底:Caffeine(版本由 Boot 2.7 父 POM 统一管理) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
|
||||||
<artifactId>caffeine</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Jackson JSON 序列化(Redis value 序列化,解决泛型/乱码) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.fasterxml.jackson.core</groupId>
|
|
||||||
<artifactId>jackson-databind</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Redisson 核心:分布式锁、限流器、Lua 复杂操作(与 Spring Cache 隔离,手动配置 RedissonClient 避免自动配置冲突) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.redisson</groupId>
|
|
||||||
<artifactId>redisson</artifactId>
|
|
||||||
<version>3.13.6</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.alibaba</groupId>
|
|
||||||
<artifactId>fastjson</artifactId>
|
|
||||||
<version>1.2.80</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.mysql</groupId>
|
|
||||||
<artifactId>mysql-connector-j</artifactId>
|
|
||||||
<scope>runtime</scope>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
<optional>true</optional>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- 核心基础工具 -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>cn.hutool</groupId>
|
|
||||||
<artifactId>hutool-core</artifactId>
|
|
||||||
<version>${cn.hutool.version}</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Swagger / OpenAPI 文档(springdoc,兼容 Boot 2.7;替代旧 Springfox) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springdoc</groupId>
|
|
||||||
<artifactId>springdoc-openapi-ui</artifactId>
|
|
||||||
<version>1.7.0</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- easypoi 导出相关 -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.commons</groupId>
|
|
||||||
<artifactId>commons-lang3</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>cn.afterturn</groupId>
|
|
||||||
<artifactId>easypoi-base</artifactId>
|
|
||||||
<version>4.1.0</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- 基础平台架包:认证(CAS/会话)+ 应用间签名鉴权 -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.yunhai.platform</groupId>
|
|
||||||
<artifactId>yh-auth-spring-boot-starter</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.yunhai.platform</groupId>
|
|
||||||
<artifactId>yh-app-auth-spring-boot-starter</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- CAS 客户端(浏览器 SSO 重定向 / ticket 校验 / getRemoteUser 包装)。
|
|
||||||
yh-auth 只读取 getRemoteUser,不负责跳转,由本依赖补齐标准 CAS 过滤器三件套。
|
|
||||||
版本 3.5.1 已在本地 .m2 仓库。 -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.jasig.cas.client</groupId>
|
|
||||||
<artifactId>cas-client-core</artifactId>
|
|
||||||
<version>3.5.1</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<build>
|
|
||||||
<finalName>userbase</finalName>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
|
||||||
<artifactId>maven-compiler-plugin</artifactId>
|
|
||||||
<configuration>
|
|
||||||
<!-- 关闭增量编译,避免 Windows 下 maven-status 状态文件被锁导致
|
|
||||||
"Error while storing the mojo status / 拒绝访问" -->
|
|
||||||
<useIncrementalCompilation>false</useIncrementalCompilation>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
|
||||||
<!-- 打可执行 fat jar(Docker 镜像直接 java -jar 启动) -->
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
|
||||||
<configuration>
|
|
||||||
<mainClass>com.yunhai.userbase.UserBaseApplication</mainClass>
|
|
||||||
</configuration>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<goals>
|
|
||||||
<goal>repackage</goal>
|
|
||||||
</goals>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
ci 构建档案:将构建产物重定向到 IDE(IntelliJ/Eclipse)索引目录之外,
|
|
||||||
避免 IDE 后台进程持续锁定 target/maven-status/.../createdFiles.lst 导致
|
|
||||||
"Error while storing the mojo status / 拒绝访问"。
|
|
||||||
另:输出目录需从未被 IDE 打开过,否则同一棵输出树下的 application.yml 等资源
|
|
||||||
会被 IDE 的 JPS 预加载进程持有句柄而写不进(拒绝访问)。常规本地构建无需激活此 profile。
|
|
||||||
说明:C:/ci-build 已被 IDE 锁过,现改用全新目录 C:/ub-build。
|
|
||||||
-->
|
|
||||||
<profiles>
|
|
||||||
<profile>
|
|
||||||
<id>ci</id>
|
|
||||||
<build>
|
|
||||||
<directory>C:/ub-build2/userbase</directory>
|
|
||||||
</build>
|
|
||||||
</profile>
|
|
||||||
</profiles>
|
|
||||||
|
|
||||||
</project>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package com.bestsch.openapi;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 迁移兼容 shim:原 bestsch 开放接口的"当前角色"载体。
|
|
||||||
* 仅保留 {@code Operator.of(...)} 所需的访问器,避免批量改动业务代码。
|
|
||||||
* 后续可替换为 UserBase 自有角色模型。
|
|
||||||
*/
|
|
||||||
public class UserSysRole {
|
|
||||||
|
|
||||||
private Integer userId;
|
|
||||||
private Integer schId;
|
|
||||||
private Integer roleId;
|
|
||||||
|
|
||||||
public Integer getUserId() {
|
|
||||||
return userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUserId(Integer userId) {
|
|
||||||
this.userId = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Integer getSchId() {
|
|
||||||
return schId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setSchId(Integer schId) {
|
|
||||||
this.schId = schId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Integer getRoleId() {
|
|
||||||
return roleId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setRoleId(Integer roleId) {
|
|
||||||
this.roleId = roleId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package com.bestsch.utils;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 迁移兼容 shim:原 bestsch 框架的业务异常类。
|
|
||||||
* UserBase 大量业务代码依赖此异常,迁移到基础平台期间保留原包名与常用构造签名,避免批量改动。
|
|
||||||
* 后续可统一替换为 UserBase 自有异常类型。
|
|
||||||
*/
|
|
||||||
public class DException extends RuntimeException {
|
|
||||||
|
|
||||||
public DException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public DException(String message, Throwable cause) {
|
|
||||||
super(message, cause);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 兼容历史调用方携带错误码的场景 */
|
|
||||||
public DException(String message, int code) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package com.yunhai.userbase;
|
|
||||||
|
|
||||||
import com.alibaba.fastjson.parser.ParserConfig;
|
|
||||||
import org.springframework.boot.SpringApplication;
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
|
||||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
|
||||||
|
|
||||||
@SpringBootApplication
|
|
||||||
@EnableTransactionManagement
|
|
||||||
@EnableAsync
|
|
||||||
@EnableScheduling
|
|
||||||
public class UserBaseApplication {
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
// 开启安全模式
|
|
||||||
ParserConfig.getGlobalInstance().setSafeMode(true);
|
|
||||||
SpringApplication.run(UserBaseApplication.class, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
package com.yunhai.userbase.aspect;
|
|
||||||
|
|
||||||
import com.alibaba.fastjson.JSON;
|
|
||||||
import com.yunhai.userbase.common.annotation.OperationLog;
|
|
||||||
import com.yunhai.userbase.common.context.UserContext;
|
|
||||||
import com.yunhai.userbase.entity.SysOperationLog;
|
|
||||||
import com.yunhai.userbase.service.ISysOperationLogService;
|
|
||||||
import com.yunhai.userbase.utils.GzipUtil;
|
|
||||||
import com.yunhai.userbase.utils.IpUtils;
|
|
||||||
import com.yunhai.userbase.utils.OperationLogResolver;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import org.aspectj.lang.ProceedingJoinPoint;
|
|
||||||
import org.aspectj.lang.annotation.Around;
|
|
||||||
import org.aspectj.lang.annotation.Aspect;
|
|
||||||
import org.aspectj.lang.reflect.MethodSignature;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.web.context.request.RequestContextHolder;
|
|
||||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
|
||||||
import javax.servlet.http.HttpServletResponse;
|
|
||||||
import javax.servlet.http.HttpSession;
|
|
||||||
import java.lang.reflect.Method;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 操作日志切面。<br>
|
|
||||||
* 拦截所有 Controller 方法,按如下规则自动采集并落库:<br>
|
|
||||||
* - GET 请求:自动记录为「一般(查询)」,无需任何注解;<br>
|
|
||||||
* - 非 GET 请求:仅当方法标注 @OperationLog 时才记录(沿用原注解式行为);<br>
|
|
||||||
* - 排除日志查看接口自身(SysOperationLogController),避免递归与刷屏。<br>
|
|
||||||
* 响应结果 GZIP 压缩 + 截断后,在 finally 中异步落库,失败不影响主业务。<br>
|
|
||||||
*
|
|
||||||
* @author chenx
|
|
||||||
* @date 2026/7/28
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Aspect
|
|
||||||
@Component
|
|
||||||
public class OperationLogAspect {
|
|
||||||
|
|
||||||
/** 不记录日志查询/下载接口自身,避免递归与刷屏。 */
|
|
||||||
private static final String LOG_CONTROLLER = "com.yunhai.userbase.controller.api.SysOperationLogController";
|
|
||||||
|
|
||||||
/** GET 自动记录时使用的默认响应保存上限(10KB)。 */
|
|
||||||
private static final int DEFAULT_MAX_RESPONSE_SIZE = 10240;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private ISysOperationLogService sysOperationLogService;
|
|
||||||
|
|
||||||
@Around("execution(* com.yunhai.userbase.controller..*.*(..))")
|
|
||||||
public Object around(ProceedingJoinPoint point) throws Throwable {
|
|
||||||
// 排除日志查看接口自身
|
|
||||||
if (LOG_CONTROLLER.equals(point.getSignature().getDeclaringTypeName())) {
|
|
||||||
return point.proceed();
|
|
||||||
}
|
|
||||||
|
|
||||||
HttpServletRequest request = getRequest();
|
|
||||||
String httpMethod = request != null ? request.getMethod() : "";
|
|
||||||
boolean isGet = "GET".equalsIgnoreCase(httpMethod);
|
|
||||||
|
|
||||||
// 解析方法上的 @OperationLog 注解(可能为 null)
|
|
||||||
OperationLog operationLog = findAnnotation(point);
|
|
||||||
|
|
||||||
// 决策:GET 自动记录;非 GET 仅在有注解时记录
|
|
||||||
if (!isGet && operationLog == null) {
|
|
||||||
return point.proceed();
|
|
||||||
}
|
|
||||||
|
|
||||||
long start = System.currentTimeMillis();
|
|
||||||
SysOperationLog entity = buildEntity(point, request, httpMethod);
|
|
||||||
|
|
||||||
// GET 未标注注解时使用默认参数;标注了则优先用注解配置
|
|
||||||
boolean saveResponse = operationLog != null ? operationLog.saveResponse() : true;
|
|
||||||
int maxResponseSize = operationLog != null ? operationLog.maxResponseSize() : DEFAULT_MAX_RESPONSE_SIZE;
|
|
||||||
|
|
||||||
Object result = null;
|
|
||||||
try {
|
|
||||||
result = point.proceed();
|
|
||||||
entity.setStatusCode(200);
|
|
||||||
fillResponse(entity, result, saveResponse, maxResponseSize);
|
|
||||||
} catch (Throwable e) {
|
|
||||||
entity.setStatusCode(500);
|
|
||||||
entity.setErrorMsg(truncate(e.getMessage(), 2000));
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
entity.setCostTime(System.currentTimeMillis() - start);
|
|
||||||
sysOperationLogService.saveAsync(entity);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从连接点解析方法上的 @OperationLog 注解。
|
|
||||||
*/
|
|
||||||
private OperationLog findAnnotation(ProceedingJoinPoint point) {
|
|
||||||
try {
|
|
||||||
MethodSignature signature = (MethodSignature) point.getSignature();
|
|
||||||
Method method = signature.getMethod();
|
|
||||||
return method.getAnnotation(OperationLog.class);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 组装日志实体(基础字段 + 级别/类型判定)。
|
|
||||||
*/
|
|
||||||
private SysOperationLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request, String httpMethod) {
|
|
||||||
SysOperationLog entity = new SysOperationLog();
|
|
||||||
|
|
||||||
Integer userId = UserContext.getCurrentUser();
|
|
||||||
entity.setUserId(userId != null ? userId.longValue() : 0L);
|
|
||||||
String username = request != null ? request.getRemoteUser() : null;
|
|
||||||
entity.setUsername(StringUtils.isNotBlank(username) ? username : "anonymous");
|
|
||||||
|
|
||||||
String requestUri = "";
|
|
||||||
if (request != null) {
|
|
||||||
entity.setRequestIp(IpUtils.getIpAddr(request));
|
|
||||||
requestUri = request.getRequestURI();
|
|
||||||
entity.setUserAgent(request.getHeader("User-Agent"));
|
|
||||||
entity.setRequestParams(buildRequestParams(point, request));
|
|
||||||
} else {
|
|
||||||
entity.setRequestParams(buildRequestParams(point, null));
|
|
||||||
}
|
|
||||||
entity.setRequestUri(requestUri);
|
|
||||||
entity.setRequestMethod(httpMethod);
|
|
||||||
|
|
||||||
String methodName = point.getSignature().getName();
|
|
||||||
entity.setLogLevel(OperationLogResolver.resolveLevel(httpMethod, requestUri, methodName).getCode());
|
|
||||||
entity.setOperationType(OperationLogResolver.resolveOperationType(httpMethod, requestUri, methodName));
|
|
||||||
entity.setClassMethod(point.getTarget().getClass().getName() + "." + methodName);
|
|
||||||
return entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建请求参数。<br>
|
|
||||||
* GET 优先使用 queryString;其余序列化方法入参,过滤掉请求/响应等不可序列化对象。<br>
|
|
||||||
*/
|
|
||||||
private String buildRequestParams(ProceedingJoinPoint point, HttpServletRequest request) {
|
|
||||||
try {
|
|
||||||
if (request != null && "GET".equalsIgnoreCase(request.getMethod())) {
|
|
||||||
String qs = request.getQueryString();
|
|
||||||
if (StringUtils.isNotBlank(qs)) {
|
|
||||||
return qs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Object[] args = point.getArgs();
|
|
||||||
if (args == null || args.length == 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
List<Object> filtered = new ArrayList<Object>();
|
|
||||||
for (Object arg : args) {
|
|
||||||
if (arg == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse
|
|
||||||
|| arg instanceof HttpSession) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
filtered.add(arg);
|
|
||||||
}
|
|
||||||
if (filtered.isEmpty()) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return JSON.toJSONString(filtered);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 写入响应结果(GZIP 压缩 + 截断)。
|
|
||||||
*/
|
|
||||||
private void fillResponse(SysOperationLog entity, Object result, boolean saveResponse, int maxResponseSize) {
|
|
||||||
if (!saveResponse || result == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
String resp = JSON.toJSONString(result);
|
|
||||||
if (resp.length() > maxResponseSize) {
|
|
||||||
resp = resp.substring(0, maxResponseSize) + "...[截断]";
|
|
||||||
}
|
|
||||||
entity.setResponseData(GzipUtil.compress(resp));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("操作日志响应结果序列化失败, uri={}", entity.getRequestUri(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private HttpServletRequest getRequest() {
|
|
||||||
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
|
||||||
return attrs != null ? attrs.getRequest() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String truncate(String text, int maxLen) {
|
|
||||||
if (text == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return text.length() > maxLen ? text.substring(0, maxLen) : text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package com.yunhai.userbase.common;
|
|
||||||
|
|
||||||
import org.springframework.validation.BindException;
|
|
||||||
import org.springframework.validation.FieldError;
|
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
|
||||||
|
|
||||||
import javax.validation.ConstraintViolation;
|
|
||||||
import javax.validation.ConstraintViolationException;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全局异常处理器:仅处理「参数校验」类异常,把 Spring 默认抛出的冗长消息收敛成「一句话」返回,
|
|
||||||
* 避免前端收到整段 Validation failed ... 堆栈文本。<br>
|
|
||||||
* 返回 HTTP 200 + {@code {code:4, msg:..., data:null}} 结构(而非 HTTP 400),
|
|
||||||
* 与前端 alova errorHandle 的约定一致:status!=200 会被当作网络错误,code=4 才走「参数错误」分支。<br>
|
|
||||||
* 注意:本处理器【不】处理业务异常(如 {@code DException}),业务异常交由项目框架统一处理
|
|
||||||
* (返回 code=5、msg=异常消息),此处若拦截会覆盖框架行为。
|
|
||||||
*
|
|
||||||
* @author chenx
|
|
||||||
*/
|
|
||||||
@RestControllerAdvice
|
|
||||||
public class GlobalExceptionHandler {
|
|
||||||
|
|
||||||
/** 参数校验失败的业务码,前端按「参数错误」处理 */
|
|
||||||
private static final int CODE_PARAM_ERROR = 4;
|
|
||||||
|
|
||||||
/** @RequestBody + @Validated/@Valid 失败:提取每个字段的 defaultMessage。 */
|
|
||||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
|
||||||
public Map<String, Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
|
|
||||||
return paramError(fieldErrorsMessage(ex.getBindingResult().getFieldErrors()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 普通 Bean 参数(如 SortChildren/Move 的 @Validated 请求体对象)校验失败。 */
|
|
||||||
@ExceptionHandler(BindException.class)
|
|
||||||
public Map<String, Object> handleBind(BindException ex) {
|
|
||||||
return paramError(fieldErrorsMessage(ex.getBindingResult().getFieldErrors()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 方法级约束(@PathVariable/@RequestParam 上的 @NotNull 等)校验失败。 */
|
|
||||||
@ExceptionHandler(ConstraintViolationException.class)
|
|
||||||
public Map<String, Object> handleConstraintViolation(ConstraintViolationException ex) {
|
|
||||||
String msg = ex.getConstraintViolations().stream()
|
|
||||||
.map(ConstraintViolation::getMessage)
|
|
||||||
.collect(Collectors.joining(";"));
|
|
||||||
return paramError(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String fieldErrorsMessage(java.util.List<FieldError> fieldErrors) {
|
|
||||||
if (fieldErrors == null || fieldErrors.isEmpty()) {
|
|
||||||
return "参数校验失败";
|
|
||||||
}
|
|
||||||
return fieldErrors.stream()
|
|
||||||
.map(FieldError::getDefaultMessage)
|
|
||||||
.collect(Collectors.joining(";"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** HTTP 200 + code/msg/data 结构体,前端根据 code 提示 msg */
|
|
||||||
private Map<String, Object> paramError(String msg) {
|
|
||||||
Map<String, Object> body = new LinkedHashMap<>();
|
|
||||||
body.put("code", CODE_PARAM_ERROR);
|
|
||||||
body.put("msg", msg);
|
|
||||||
body.put("data", null);
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package com.yunhai.userbase.common.annotation;
|
|
||||||
|
|
||||||
import java.lang.annotation.Documented;
|
|
||||||
import java.lang.annotation.ElementType;
|
|
||||||
import java.lang.annotation.Retention;
|
|
||||||
import java.lang.annotation.RetentionPolicy;
|
|
||||||
import java.lang.annotation.Target;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 操作日志注解。<br>
|
|
||||||
* 标注在 Controller 方法上,AOP 拦截后自动记录操作日志并异步落库。<br>
|
|
||||||
*
|
|
||||||
* @author chenx
|
|
||||||
* @date 2026/7/28
|
|
||||||
*/
|
|
||||||
@Target(ElementType.METHOD)
|
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
|
||||||
@Documented
|
|
||||||
public @interface OperationLog {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 操作描述,可选。
|
|
||||||
*/
|
|
||||||
String value() default "";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否保存响应结果(GZIP 压缩后入库)。
|
|
||||||
*/
|
|
||||||
boolean saveResponse() default true;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 响应结果序列化后的最大字符数,超过则截断。默认 10KB。
|
|
||||||
*/
|
|
||||||
int maxResponseSize() default 10240;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package com.yunhai.userbase.common.context;
|
|
||||||
|
|
||||||
public class UserContext {
|
|
||||||
|
|
||||||
private static final ThreadLocal<Integer> CURRENT_USER = new ThreadLocal<>();
|
|
||||||
|
|
||||||
public static void setCurrentUser(Integer userId) {
|
|
||||||
CURRENT_USER.set(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Integer getCurrentUser() {
|
|
||||||
return CURRENT_USER.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void clear() {
|
|
||||||
CURRENT_USER.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package com.yunhai.userbase.common.converter;
|
|
||||||
|
|
||||||
import com.yunhai.userbase.enums.ClientEnum;
|
|
||||||
|
|
||||||
import javax.persistence.AttributeConverter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ClientEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
|
||||||
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
|
|
||||||
*
|
|
||||||
* @author chenx
|
|
||||||
*/
|
|
||||||
public class ClientEnumConverter extends CommonIntEnumConverter<ClientEnum> {
|
|
||||||
|
|
||||||
public ClientEnumConverter() {
|
|
||||||
super(ClientEnum.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package com.yunhai.userbase.common.converter;
|
|
||||||
|
|
||||||
import com.yunhai.userbase.enums.IEnum;
|
|
||||||
|
|
||||||
import javax.persistence.AttributeConverter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用「int <-> 实现 IEnum<Integer> 的枚举」JPA 转换器抽象基类。<br>
|
|
||||||
* <p>
|
|
||||||
* <b>绑定机制(通用做法):</b>为每个枚举写一个极薄的子类,在构造器里传入枚举 class 即可完成绑定,
|
|
||||||
* 然后实体字段用 {@code @Convert(converter = XxxEnumConverter.class)} 引用该子类。例如:
|
|
||||||
* <pre>
|
|
||||||
* public class GroupTypeEnumConverter extends CommonIntEnumConverter<GroupTypeEnum> {
|
|
||||||
* public GroupTypeEnumConverter() { super(GroupTypeEnum.class); }
|
|
||||||
* }
|
|
||||||
* </pre>
|
|
||||||
* 这样同一套逻辑可服务任意枚举;又因为每个枚举拥有<b>独立的转换器实例</b>,
|
|
||||||
* 彼此之间不会串类型(标准 JPA 的 @Convert 是按 converter 类创建共享单例的,
|
|
||||||
* 若让多个不同枚举共用同一个泛型实例,则无法区分各自的枚举 class,故采用「每枚举一子类」的绑定方式)。
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* @param <E> 必须实现 IEnum<Integer> 的枚举类型
|
|
||||||
* @author chenx
|
|
||||||
*/
|
|
||||||
public abstract class CommonIntEnumConverter<E extends Enum<E> & IEnum<Integer>>
|
|
||||||
implements AttributeConverter<E, Integer> {
|
|
||||||
|
|
||||||
private final Class<E> enumCls;
|
|
||||||
|
|
||||||
protected CommonIntEnumConverter(Class<E> enumCls) {
|
|
||||||
this.enumCls = enumCls;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Integer convertToDatabaseColumn(E enumVal) {
|
|
||||||
return enumVal == null ? null : enumVal.getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public E convertToEntityAttribute(Integer dbVal) {
|
|
||||||
if (dbVal == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// 读库安全:无匹配的枚举值返回 null(不抛异常,避免脏数据导致整棵树加载失败)
|
|
||||||
return IEnum.getByKey(enumCls, dbVal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package com.yunhai.userbase.common.converter;
|
|
||||||
|
|
||||||
import com.yunhai.userbase.enums.GenderEnum;
|
|
||||||
|
|
||||||
import javax.persistence.AttributeConverter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GenderEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
|
||||||
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
|
|
||||||
*
|
|
||||||
* @author chenx
|
|
||||||
*/
|
|
||||||
public class GenderEnumConverter extends CommonIntEnumConverter<GenderEnum> {
|
|
||||||
|
|
||||||
public GenderEnumConverter() {
|
|
||||||
super(GenderEnum.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package com.yunhai.userbase.common.converter;
|
|
||||||
|
|
||||||
import com.yunhai.userbase.enums.GroupTypeEnum;
|
|
||||||
|
|
||||||
import javax.persistence.AttributeConverter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GroupTypeEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
|
||||||
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可,无需重复转换逻辑。
|
|
||||||
*
|
|
||||||
* @author chenx
|
|
||||||
*/
|
|
||||||
public class GroupTypeEnumConverter extends CommonIntEnumConverter<GroupTypeEnum> {
|
|
||||||
|
|
||||||
public GroupTypeEnumConverter() {
|
|
||||||
super(GroupTypeEnum.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package com.yunhai.userbase.common.converter;
|
|
||||||
|
|
||||||
import com.yunhai.userbase.enums.IntegEnum;
|
|
||||||
|
|
||||||
import javax.persistence.AttributeConverter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IntegEnum 专用 JPA 转换器(int <-> 枚举)。<br>
|
|
||||||
* 继承通用基类 CommonIntEnumConverter,仅在构造器绑定枚举 class 即可。
|
|
||||||
*
|
|
||||||
* @author chenx
|
|
||||||
*/
|
|
||||||
public class IntegEnumConverter extends CommonIntEnumConverter<IntegEnum> {
|
|
||||||
|
|
||||||
public IntegEnumConverter() {
|
|
||||||
super(IntegEnum.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||