diff --git a/.env.production b/.env.production index 84e6086..5a15f92 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,6 @@ # 线上环境平台打包路径 +VITE_APP_ENV_LABEL = "生产环境" + VITE_PUBLIC_PATH = / # 线上环境路由历史模式(Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数") @@ -10,4 +12,4 @@ VITE_CDN = false # 是否启用gzip压缩或brotli压缩(分两种情况,删除原始文件和不删除原始文件) # 压缩时不删除原始文件的配置:gzip、brotli、both(同时开启 gzip 与 brotli 压缩)、none(不开启压缩,默认) # 压缩时删除原始文件的配置:gzip-clear、brotli-clear、both-clear(同时开启 gzip 与 brotli 压缩)、none(不开启压缩,默认) -VITE_COMPRESSION = "none" \ No newline at end of file +VITE_COMPRESSION = "none" diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..4b4879a --- /dev/null +++ b/.env.test @@ -0,0 +1,16 @@ +# 测试环境构建配置 +VITE_APP_ENV_LABEL = "测试环境" + +VITE_PUBLIC_PATH = / + +# 测试环境路由历史模式 +VITE_ROUTER_HISTORY = "hash" + +# 测试环境不使用 CDN,避免外部依赖影响验收 +VITE_CDN = false + +# 测试环境默认不生成压缩产物 +VITE_COMPRESSION = "none" + +# 本地调试 test mode 时的后端代理目标;线上测试环境由 Nginx 将 /api 代理到测试后端。 +VITE_API_PROXY_TARGET = "http://localhost:3501" diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..568137a --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,122 @@ +def isProductionDeploy() { + return params.DEPLOY_ENV == "production" +} + +def isTestDeploy() { + def branch = env.BRANCH_NAME ?: "" + return params.DEPLOY_ENV == "test" && !params.SKIP_DEPLOY && branch == env.TEST_BRANCH +} + +@NonCPS +boolean isUserTriggeredBuild() { + return currentBuild.rawBuild.getCauses().any { cause -> + cause.class.name == "hudson.model.Cause\$UserIdCause" + } +} + +pipeline { + agent any + + options { + timestamps() + disableConcurrentBuilds() + buildDiscarder(logRotator(numToKeepStr: "30", artifactNumToKeepStr: "10")) + } + + parameters { + choice( + name: "DEPLOY_ENV", + choices: ["test", "production"], + description: "test: develop 合并后自动部署测试环境;production: 仅允许手动输入 Tag 部署" + ) + string(name: "RELEASE_TAG", defaultValue: "", description: "生产部署必填,必须是 Gitea 中已存在的 Tag") + booleanParam(name: "SKIP_DEPLOY", defaultValue: false, description: "只构建检查,不执行部署") + } + + environment { + PROJECT_NAME = "role-admin" + TEST_BRANCH = "develop" + DEPLOY_BASE_DIR = "/srv/www" + } + + stages { + stage("Validate deploy policy") { + steps { + script { + if (isProductionDeploy()) { + if (!isUserTriggeredBuild()) { + error("生产环境禁止自动触发,只能在 Jenkins 手动 Build With Parameters。") + } + if (!params.RELEASE_TAG?.trim()) { + error("生产环境部署必须填写 RELEASE_TAG,且只能从项目 Tag 部署。") + } + } + + if (params.DEPLOY_ENV == "test" && env.BRANCH_NAME && env.BRANCH_NAME != env.TEST_BRANCH) { + echo "当前分支 ${env.BRANCH_NAME} 不是 ${env.TEST_BRANCH},本次只构建检查,不自动部署测试环境。" + } + } + } + } + + stage("Checkout production tag") { + when { + expression { isProductionDeploy() } + } + steps { + sh ''' + set -euo pipefail + git fetch --tags --force origin '+refs/tags/*:refs/tags/*' + tag_commit="$(git rev-parse -q --verify "refs/tags/${RELEASE_TAG}^{commit}")" + if [ -z "${tag_commit}" ]; then + echo "Tag not found: ${RELEASE_TAG}" >&2 + exit 1 + fi + git checkout -f "${tag_commit}" + git log -1 --oneline + ''' + } + } + + stage("Install") { + steps { + sh ''' + corepack enable || true + pnpm install --frozen-lockfile + ''' + } + } + + stage("Verify") { + steps { + sh "pnpm typecheck" + } + } + + stage("Build") { + steps { + script { + sh isProductionDeploy() ? "pnpm build:prod" : "pnpm build:test" + } + } + } + + stage("Deploy test") { + when { + expression { isTestDeploy() } + } + steps { + sh "bash deploy/jenkins/deploy-static.sh test dist" + } + } + + stage("Deploy production") { + when { + expression { isProductionDeploy() && !params.SKIP_DEPLOY } + } + steps { + sh "bash deploy/jenkins/deploy-static.sh production dist" + } + } + } +} diff --git a/README.md b/README.md index 5ba6cfb..2619986 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,11 @@ http://localhost:8848/ ├── .vscode/ # VS Code 推荐配置与 Vue 代码片段 ├── build/ # Vite 插件、CDN、压缩、构建信息与工具函数 ├── docs/ # 项目需求和后台扩展说明 -│ └── C_APP_ADMIN_REQUIREMENTS.md # C 端正式版管理后台需求文档 +│ ├── C_APP_ADMIN_REQUIREMENTS.md # C 端正式版管理后台需求文档 +│ └── ENVIRONMENT_DEPLOYMENT.md # 测试/生产环境拆分与 Jenkins 规则 +├── deploy/ # Jenkins 部署脚本 +│ └── jenkins/ +│ └── deploy-static.sh # 静态产物部署脚本 ├── public/ # 静态资源与运行时平台配置 ├── src/ # 前端源码 │ ├── api/ # HTTP API 封装,业务接口集中在 access.ts @@ -70,6 +74,7 @@ http://localhost:8848/ ├── types/ # 全局类型声明、组件声明、路由声明和 Vue shim ├── AGENTS.md # Codex/Agent 入口规则,转到 RTK.md ├── RTK.md # 本仓库协作规则与 README 同步要求 +├── Jenkinsfile # Jenkins 测试自动部署、生产手动 Tag 部署规则 ├── Dockerfile # 容器构建入口 ├── package.json # 依赖、脚本、engines 与 pnpm preinstall 限制 ├── pnpm-lock.yaml # pnpm 锁文件 @@ -86,6 +91,8 @@ http://localhost:8848/ | `pnpm dev` | 启动 Vite 开发服务,默认端口来自 `.env.development` 的 `VITE_PORT=8848` | 本地开发 | | `pnpm serve` | `pnpm dev` 的别名 | 兼容习惯命令 | | `pnpm build` | 清理 `dist` 后执行生产构建,内存上限设为 8192MB | 发布前打包 | +| `pnpm build:test` | 使用 `test` mode 构建测试环境静态产物 | Jenkins 测试部署 | +| `pnpm build:prod` | `pnpm build` 的生产构建别名 | Jenkins 生产部署 | | `pnpm build:staging` | 使用 `staging` mode 构建 | 预发布环境验证 | | `pnpm report` | 构建并打开 Rollup 可视化分析报告 | 分析包体积 | | `pnpm preview` | 预览已有 `dist` 构建产物 | 构建后本地验收 | @@ -177,10 +184,20 @@ http://localhost:8848/ - `.env`: 全局默认配置,目前包含端口和是否隐藏首页。 - `.env.development`: 开发环境端口、路由模式、API 代理目标。 +- `.env.test`: 测试环境构建配置,Jenkins 测试部署使用;线上测试站点的 `/api` 应由 Nginx 代理到测试后端。 - `.env.staging`: 预发布构建配置,可开启 CDN。 - `.env.production`: 生产构建配置。 - `public/platform-config.json`: pure-admin 运行时平台配置,例如标题、布局、主题、标签页和菜单搜索历史。 +## Jenkins 环境规则 + +流水线规则见 [docs/ENVIRONMENT_DEPLOYMENT.md](./docs/ENVIRONMENT_DEPLOYMENT.md)。 + +- 测试环境:`develop` 合并后自动触发,执行 `pnpm build:test`,部署到 `/srv/www/test/role-admin/current`。 +- 生产环境:禁止代码合并自动触发,只能在 Jenkins 手动选择 `DEPLOY_ENV=production` 并填写 Gitea 项目 Tag。 +- 生产部署会先 checkout 到 `RELEASE_TAG` 对应的提交,再执行 `pnpm build:prod` 和部署。 +- 测试、生产站点的 `/api` 必须分别代理到测试、生产 `access-manage`,避免环境串线。 + ## 协作与文档同步 - 文件结构、入口文件、业务模块、脚本或关键配置发生变化时,必须同步更新本 README 的「目录说明」「重要脚本」或对应说明段落。 diff --git a/deploy/jenkins/deploy-static.sh b/deploy/jenkins/deploy-static.sh new file mode 100644 index 0000000..1bc3447 --- /dev/null +++ b/deploy/jenkins/deploy-static.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +deploy_env="${1:?Usage: deploy-static.sh test|production [artifact_dir]}" +artifact_dir="${2:-dist}" + +case "${deploy_env}" in + test|production) + ;; + *) + echo "Unknown deploy environment: ${deploy_env}" >&2 + exit 1 + ;; +esac + +if [[ ! -d "${artifact_dir}" ]]; then + echo "Artifact directory not found: ${artifact_dir}" >&2 + exit 1 +fi + +project_name="${PROJECT_NAME:-role-admin}" +base_dir="${DEPLOY_BASE_DIR:-/srv/www}" +target_dir="${DEPLOY_TARGET_DIR:-${base_dir}/${deploy_env}/${project_name}}" +deploy_remote="${DEPLOY_REMOTE:-}" +release_id="${BUILD_NUMBER:-manual}-$(git rev-parse --short=12 HEAD 2>/dev/null || date +%Y%m%d%H%M%S)" + +remote_shell() { + if [[ -n "${deploy_remote}" ]]; then + ssh "${deploy_remote}" "$@" + else + bash -lc "$*" + fi +} + +remote_copy() { + local source_dir="$1" + local dest_dir="$2" + + if [[ -n "${deploy_remote}" ]]; then + rsync -az --delete "${source_dir}/" "${deploy_remote}:${dest_dir}/" + else + mkdir -p "${dest_dir}" + rsync -az --delete "${source_dir}/" "${dest_dir}/" + fi +} + +release_dir="${target_dir}/releases/${release_id}" + +remote_shell "mkdir -p '${release_dir}' '${target_dir}/logs'" +remote_copy "${artifact_dir}" "${release_dir}" +remote_shell "ln -sfn '${release_dir}' '${target_dir}/current'" + +if [[ -n "${DEPLOY_POST_DEPLOY_CMD:-}" ]]; then + remote_shell "cd '${target_dir}/current' && ${DEPLOY_POST_DEPLOY_CMD}" +else + echo "Deployed ${project_name} ${deploy_env} to ${target_dir}/current" +fi diff --git a/docs/ENVIRONMENT_DEPLOYMENT.md b/docs/ENVIRONMENT_DEPLOYMENT.md new file mode 100644 index 0000000..6c706f2 --- /dev/null +++ b/docs/ENVIRONMENT_DEPLOYMENT.md @@ -0,0 +1,81 @@ +# 环境拆分与流水线规则 + +`role-admin` 是静态前端项目。测试环境和生产环境通过独立构建模式、独立部署目录和独立 Nginx `/api` 代理拆分。 + +## 环境约定 + +| 环境 | 触发方式 | 代码依据 | 构建命令 | 默认部署目录 | +| --- | --- | --- | --- | --- | +| 测试环境 | `develop` 合并后自动触发 Jenkins | `develop` 最新提交 | `pnpm build:test` | `/srv/www/test/role-admin/current` | +| 生产环境 | Jenkins 手动触发 | Gitea 项目 Tag | `pnpm build:prod` | `/srv/www/production/role-admin/current` | + +生产环境禁止因代码合并自动部署。生产部署时必须在 Jenkins 参数中选择 `DEPLOY_ENV=production` 并填写已存在的 `RELEASE_TAG`。 + +## Jenkins 参数 + +| 参数 | 说明 | +| --- | --- | +| `DEPLOY_ENV` | `test` 或 `production`。默认 `test`。 | +| `RELEASE_TAG` | 生产环境必填,必须是 Gitea 仓库中已存在的 Tag。 | +| `SKIP_DEPLOY` | 为 `true` 时只执行安装、检查、构建,不部署。 | + +`Jenkinsfile` 会强制校验: + +- 测试环境只在 `develop` 分支自动部署。 +- 生产环境必须手动触发。 +- 生产环境必须填写 `RELEASE_TAG`。 +- 生产环境构建会先 checkout 到该 Tag 对应的提交,再部署。 + +## Nginx 拆分要求 + +静态资源部署目录默认如下: + +```text +/srv/www/test/role-admin/current +/srv/www/production/role-admin/current +``` + +两套站点的 `/api` 必须分别代理到对应后端: + +```text +测试 role-admin /api -> 测试 access-manage +生产 role-admin /api -> 生产 access-manage +``` + +不要让测试前端代理到生产后端,也不要让生产前端代理到测试后端。 + +## 生产发布流程 + +1. 在需要发布的提交上创建 Tag。 +2. 推送 Tag 到 Gitea。 +3. Jenkins 手动 Build With Parameters。 +4. 选择 `DEPLOY_ENV=production`。 +5. 填写 `RELEASE_TAG`。 +6. 执行构建部署。 + +示例: + +```bash +git tag -a v2026.06.05-1 -m "role-admin production release 2026-06-05" +git push origin v2026.06.05-1 +``` + +## 部署脚本 + +`deploy/jenkins/deploy-static.sh` 会把 `dist/` 发布到: + +```text +${DEPLOY_BASE_DIR}/${DEPLOY_ENV}/role-admin/releases/- +``` + +并更新: + +```text +${DEPLOY_BASE_DIR}/${DEPLOY_ENV}/role-admin/current +``` + +如果 Jenkins 不在目标服务器上运行,可以配置: + +```text +DEPLOY_REMOTE=user@server +``` diff --git a/package.json b/package.json index 9723b28..df0f6c3 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "dev": "NODE_OPTIONS=--max-old-space-size=4096 vite", "serve": "pnpm dev", "build": "rimraf dist && NODE_OPTIONS=--max-old-space-size=8192 vite build", + "build:test": "rimraf dist && vite build --mode test", + "build:prod": "pnpm build", "build:staging": "rimraf dist && vite build --mode staging", "report": "rimraf dist && vite build", "preview": "vite preview", diff --git a/src/layout/components/lay-navbar/index.vue b/src/layout/components/lay-navbar/index.vue index 6f356a4..633b433 100644 --- a/src/layout/components/lay-navbar/index.vue +++ b/src/layout/components/lay-navbar/index.vue @@ -9,6 +9,8 @@ import LaySidebarTopCollapse from "../lay-sidebar/components/SidebarTopCollapse. import LogoutCircleRLine from "~icons/ri/logout-circle-r-line"; import Setting from "~icons/ri/settings-3-line"; +const appEnvLabel = import.meta.env.VITE_APP_ENV_LABEL || "生产环境"; + const { layout, device, @@ -39,6 +41,9 @@ const {
+ + {{ appEnvLabel }} + @@ -93,6 +98,20 @@ const { height: 48px; color: #000000d9; + .environment-badge { + padding: 5px 9px; + margin-right: 8px; + font-size: 12px; + font-weight: 700; + line-height: 1; + color: #ffffff; + letter-spacing: 0; + white-space: nowrap; + background: rgb(24 24 27 / 86%); + border-radius: 999px; + box-shadow: 0 8px 20px rgb(0 0 0 / 14%); + } + .el-dropdown-link { display: flex; align-items: center; diff --git a/types/global.d.ts b/types/global.d.ts index be44913..5814650 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -70,6 +70,7 @@ declare global { VITE_CDN: boolean; VITE_HIDE_HOME: string; VITE_COMPRESSION: ViteCompression; + VITE_APP_ENV_LABEL?: string; VITE_API_PROXY_TARGET?: string; }