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 ----
|
||||
node_modules/
|
||||
|
||||
# ---- build output (CI builds, do not commit) ----
|
||||
front/dist/
|
||||
front/dist_ra/
|
||||
|
||||
# ---- java build ----
|
||||
service/target/
|
||||
|
||||
# ---- local tooling / IDE ----
|
||||
.local-context/
|
||||
# WorkBuddy local state
|
||||
.workbuddy/
|
||||
|
||||
# ---- env secrets (keep *.example) ----
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
# ---- logs ----
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
deploy/*_run*.log
|
||||
deploy/verify_run*.log
|
||||
deploy/push_run.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# ---- OS ----
|
||||
Thumbs.db
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
yarn.lock
|
||||
.npmrc
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
|
||||
# ---- IDE ----
|
||||
.idea/
|
||||
# ---- vite temp cache ----
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.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",
|
||||
"scripts": {
|
||||
"dev": "vite --mode dev",
|
||||
"build": "vite build --mode base",
|
||||
"build:base": "vite build --mode base",
|
||||
"build:ld": "vite build --mode ld",
|
||||
"build": "vite build",
|
||||
"build:prod": "vite build --mode prod",
|
||||
"build:ld": "vite build --mode ld",
|
||||
"build:base": "vite build --mode base",
|
||||
"preview": "vite preview",
|
||||
"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);
|
||||
}
|
||||
}
|
||||