diff --git a/backend/src/components/CommonAside.vue b/backend/src/components/CommonAside.vue index d240aea..b469140 100644 --- a/backend/src/components/CommonAside.vue +++ b/backend/src/components/CommonAside.vue @@ -489,6 +489,7 @@ h3 { font-size: 14px; font-weight: 500; position: relative; + background: transparent; html:not(.dark) & { color: rgba(255, 255, 255, 0.85); @@ -533,22 +534,40 @@ h3 { color: #ffffff !important; } + // 嵌套展开的子菜单容器保持透明,避免 Element Plus 注入的 + // --el-menu-bg-color 在二级及以下区域形成不透明背景块; + // 二级 ul 不需要上下内边距 + .el-menu--inline { + background: transparent !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + } + // 子菜单样式 .el-sub-menu { .el-sub-menu__title { position: relative; } - &.is-opened .el-sub-menu__title { - background: rgba(255, 255, 255, 0.08) !important; - } + // 展开的父级菜单标题不加背景,背景只留给当前激活的菜单项 .el-menu-item { - padding-left: 48px !important; font-size: 13px; } } + // 二级菜单项缩进 24px + .el-menu--inline > .el-menu-item, + .el-menu--inline > .el-sub-menu > .el-sub-menu__title { + padding-left: 24px !important; + } + + // 三级菜单项缩进 48px + .el-menu--inline .el-menu--inline > .el-menu-item, + .el-menu--inline .el-menu--inline > .el-sub-menu > .el-sub-menu__title { + padding-left: 48px !important; + } + // 暗色主题适配 html.dark & { .el-menu-item.is-active { @@ -566,10 +585,6 @@ h3 { background: rgba(255, 255, 255, 0.08) !important; color: var(--el-color-primary-light-3) !important; } - - .el-sub-menu.is-opened .el-sub-menu__title { - background: rgba(64, 158, 255, 0.08) !important; - } } } diff --git a/backend/src/views/apps/cms/articles/components/edit.vue b/backend/src/views/apps/cms/articles/components/edit.vue index ae0cbaa..8e2ac45 100644 --- a/backend/src/views/apps/cms/articles/components/edit.vue +++ b/backend/src/views/apps/cms/articles/components/edit.vue @@ -6,6 +6,18 @@ :before-close="handleBeforeClose" > + + + + 否 + 是 + + + + + + + @@ -128,17 +140,6 @@ - - - 否 - 是 - - - - - - -
@@ -233,9 +234,14 @@ const rules = { // 获取分类列表 async function fetchCategories() { try { - const res = await listCategories(); + const res = await listCategories({ page: 1, limit: 1000 }); if (res.code === 200) { - const categories = Array.isArray(res.data) ? res.data : []; + // 兼容两种返回结构:data 为数组 或 data.list 为数组 + const categories = Array.isArray(res.data) + ? res.data + : Array.isArray(res.data?.list) + ? res.data.list + : []; // 将分类转换为树形结构 cateOptions.value = buildCategoryTree(categories); } @@ -452,11 +458,7 @@ async function handleConfirm(status = 1) { : res; } - if ( - resp && - resp.code === 200 && - (resp.message === "success" || resp.msg === "success") - ) { + if (resp && resp.code === 200) { ElMessage.success( status === 0 ? "保存草稿成功" diff --git a/backend/src/views/apps/cms/articles/components/preview.vue b/backend/src/views/apps/cms/articles/components/preview.vue index baa5c6e..9068bfb 100644 --- a/backend/src/views/apps/cms/articles/components/preview.vue +++ b/backend/src/views/apps/cms/articles/components/preview.vue @@ -87,9 +87,11 @@ const fetchCategories = async () => { // 根据分类ID获取分类名称 const getCategoryName = (cate) => { - if (!cate) return "无分类"; + if (!cate && cate !== 0) return "无分类"; // 确保比较时类型一致 const cateId = Number(cate); + // 传入的不是数字(已经是分类名),直接展示 + if (Number.isNaN(cateId)) return String(cate); const category = categoryOptions.value.find( (item) => Number(item.id) === cateId, ); diff --git a/backend/src/views/apps/cms/articles/index.vue b/backend/src/views/apps/cms/articles/index.vue index 29b6d36..42b49eb 100644 --- a/backend/src/views/apps/cms/articles/index.vue +++ b/backend/src/views/apps/cms/articles/index.vue @@ -41,11 +41,12 @@
@@ -232,6 +233,7 @@ const loading = ref(false); const articleList = ref([]); const searchQuery = ref(""); const categoryFilter = ref(""); +const dateFilter = ref(null); const categoryOptions = ref([]); const currentPage = ref(1); const pageSize = ref(10); @@ -301,6 +303,8 @@ async function fetchArticleList() { const params = { keyword: searchQuery.value, cate: categoryFilter.value, + startDate: dateFilter.value?.[0] || "", + endDate: dateFilter.value?.[1] || "", page: currentPage.value, pageSize: pageSize.value, }; @@ -360,14 +364,15 @@ function handleEdit(row) { id: m.id, title: m.title || "", author: m.author || "", - cate: m.cate || "", + // 详情接口的 cate 返回的是分类名,回显/预览需要分类ID + cate: m.cate_id ?? m.cate ?? "", content: m.content || "", desc: m.desc || "", publish_time: m.publish_time || null, _raw: m, }; } else { - currentRow.value = { ...row }; + currentRow.value = { ...row, cate: row.cate_id ?? row.cate }; } dialogVisible.value = true; }); @@ -388,7 +393,8 @@ function handleView(row) { id: m.id, title: m.title || "", author: m.author || "", - cate: m.cate || "", + // 详情接口的 cate 返回的是分类名,预览组件按分类ID匹配名称 + cate: m.cate_id ?? m.cate ?? "", content: m.content || "", desc: m.desc || "", view_count: m.view_count || 0, @@ -586,6 +592,7 @@ function handleSearch() { function handleRefresh() { searchQuery.value = ""; categoryFilter.value = ""; + dateFilter.value = null; currentPage.value = 1; fetchArticleList(); } diff --git a/backend/src/views/apps/cms/banner/components/edit.vue b/backend/src/views/apps/cms/banner/components/edit.vue index e41bbdd..1748a7f 100644 --- a/backend/src/views/apps/cms/banner/components/edit.vue +++ b/backend/src/views/apps/cms/banner/components/edit.vue @@ -229,10 +229,13 @@ const beforeImageUpload = (file: any) => { // 图片上传成功 const handleImageSuccess = (response: any) => { - if (response.code === 200) { + // code 200=新上传成功,201=文件已存在(直接使用已有链接) + if (response.code === 200 || response.code === 201) { // 直接保存相对路径,不拼接API_BASE_URL currentBanner.value.image = response.data.url || response.data.path; - ElMessage.success("图片上传成功"); + ElMessage.success( + response.code === 201 ? "文件已存在,直接使用" : "图片上传成功", + ); } else { ElMessage.error(response.msg || "图片上传失败"); } diff --git a/backend/src/views/apps/cms/frontMenu/components/edit.vue b/backend/src/views/apps/cms/frontMenu/components/edit.vue index 20b5211..432fa35 100644 --- a/backend/src/views/apps/cms/frontMenu/components/edit.vue +++ b/backend/src/views/apps/cms/frontMenu/components/edit.vue @@ -414,10 +414,11 @@ const beforeImageUpload = (file: any) => { // 图片上传成功 const handleImageSuccess = (response: any) => { - if (response.code === 200) { + // code 200=新上传成功,201=文件已存在(直接使用已有链接) + if (response.code === 200 || response.code === 201) { // 直接保存相对路径,不拼接API_BASE_URL currentMenu.value.image = response.data.url || response.data.path; - ElMessage.success('图片上传成功'); + ElMessage.success(response.code === 201 ? '文件已存在,直接使用' : '图片上传成功'); } else { ElMessage.error(response.msg || '图片上传失败'); } diff --git a/backend/src/views/components/UmoEditor.vue b/backend/src/views/components/UmoEditor.vue index 97938cf..ea1681d 100644 --- a/backend/src/views/components/UmoEditor.vue +++ b/backend/src/views/components/UmoEditor.vue @@ -28,6 +28,8 @@ const isCreated = ref(false) const options = { height: '100%', + // umo-editor 会定时触发自动保存(onSave),内容已随表单统一提交,这里给空实现避免控制台报错 + onSave: async () => {}, } const onFileUpload = async (file) => { diff --git a/docs/关于租户官网服务器端nginx的配置.md b/docs/关于租户官网服务器端nginx的配置.md new file mode 100644 index 0000000..7f3072c --- /dev/null +++ b/docs/关于租户官网服务器端nginx的配置.md @@ -0,0 +1,474 @@ +# 关于租户官网服务器端 nginx 的配置 + +> 适用场景:多租户官网系统。访客访问租户域名(如 `ceshi.yunzer.com.cn`)时,由 nginx 转发给 Go 服务,Go 按请求 Host 识别租户,用标签引擎解析 `themes/{模板编码}/` 模板后直出 HTML。 +> +> 本文给出宝塔环境下两个主域名(`dh2.fun`、`yunzer.com.cn`)的完整 nginx 配置、设计原则与注意事项。 + +--- + +## 一、架构原理 + +``` +访客浏览器 + │ https://ceshi.yunzer.com.cn/ + ▼ +nginx(宝塔) + │ 全部请求 proxy_pass → Go 服务(upstream 统一管理地址) + │ 透传 Host 头(Go 靠它识别租户) + ▼ +Go 服务(beego,默认 8081 端口) + │ resolveTid:按 Host 查库识别租户 + │ tagengine:渲染 themes/{模板编码}/ 下的模板 + ▼ +直出 HTML(无需前端 SPA) +``` + +### 1.1 Go 侧提供的站点路由 + +| 路径 | 说明 | +|---|---| +| `GET /` | 租户官网首页(渲染 `index.html` 模板) | +| `GET /news` | 新闻列表(`?page=N` 分页) | +| `GET /news/:id` | 文章详情 | +| `GET /page/:path` | 单页(如 `/page/about`) | +| `/themes/*` | 模板静态资源(beego `SetStaticPath("/themes", "themes")`,对应 `go/themes/` 目录) | +| `/uploads/*` | 上传文件静态访问 | +| `/backend/*`、`/platform/*` 等 | 管理端接口(nginx 以 `/api/` 前缀转发并剥掉前缀) | + +> Go 的官网渲染路由在 `APP_MODE=all`(默认值)或 `APP_MODE=index` 时注册。 + +### 1.2 Go 按 Host 识别租户的规则(tenant_site.go) + +按顺序匹配: + +1. dev 环境:支持 `X-Tenant-ID` 请求头切换租户(生产无效); +2. `localhost` / `127.0.0.1` → 租户 1; +3. `yz_system_domain_pool.main_domain` 命中(status=1)→ 租户 1(平台主站); +4. `yz_system_tenant_domain.full_domain` 命中(status=1、未软删)→ 对应租户; +5. 全部未命中 → 返回"站点不存在"404 页(**不再默认回落租户 1**)。 + +所以平台端"域名发放"时必须保证 `yz_system_tenant_domain.full_domain` 精确等于实际访问域名(如 `ceshi.yunzer.com.cn`)。 + +--- + +## 二、配置核心策略(为什么这么写) + +### 2.1 upstream 统一管理后端地址 —— 迁移只改一行 + +所有 `proxy_pass` 不写死 IP,统一引用 upstream。迁移服务器时只改 upstream 里的 `server` 一行。 + +**upstream 写在哪里**:直接写在宝塔对应站点的配置文件里、**整个文件最顶部(第一个 `server {` 块之前)**,不需要配置到其他地方。宝塔站点配置文件会被 include 进主配置的 `http {}` 上下文,`upstream` 定义在 server 块之外即合法。常见报错:`unknown "go_backend_dh2" upstream`(顶部漏了 upstream 定义)、`"upstream" directive is not allowed here`(误写进了 server 块内)。 + +**注意:两个站点配置文件都会被 include 进同一个 http 上下文,upstream 重名会导致 `duplicate upstream` 启动失败,必须各起各的名字**(本文用 `go_backend_dh2` / `go_backend_yunzer`)。 + +### 2.2 默认全量转发 —— 以后加功能零配置 + +不采用"白名单式"逐个转发(`location /news`、`location /page/`……),而是: + +- nginx 只保留**必须拦截**的规则:敏感文件/目录返回 404、`/api/` 剥前缀; +- 其余一切请求走兜底 `location /` 转发给 Go; +- Go 对不认识的路径返回自己的 404 页,行为闭环。 + +这样以后 Go 增加任何路由(新功能页面、新接口页),**nginx 一行都不用改**。 + +### 2.3 管理端 SPA 的放置建议 + +主域名与所有子域名都转发给 Go 后,这些域名上就不能再跑 history 模式的 SPA 了。建议: + +- platform / backend 管理端部署在**专属子域名**(如 `admin.yunzer.com.cn`)或独立站点; +- nginx 里为它单独建一个 `server_name` 精确匹配的 server 块。nginx 匹配顺序是**精确 server_name 优先于正则**,不会被"所有子域名"的正则块抢走; +- 若确实需要静态文件与 Go 共存(纯静态、无 SPA 深链接),可用折中写法: + +```nginx +location / { + try_files $uri @go; # 本地存在的文件先出静态,其余转 Go +} +location @go { + proxy_pass http://go_backend_yunzer; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +### 2.4 `/api/` 的尾部斜杠不能删 + +```nginx +location /api/ { + proxy_pass http://go_backend_xxx/; # 尾部 / 负责把 /api/backend/login 映射成 /backend/login + ... +} +``` + +Go 的路由注册在 `/backend/*`、`/platform/*`(没有 `/api` 前缀),靠这个尾部斜杠剥前缀。 + +--- + +## 三、完整配置 —— 宝塔站点 `dh2.fun` + +文件位置:宝塔 → 网站 → dh2.fun → 设置 → 配置文件。整份替换。 + +```nginx +# ===== 后端 Go 服务地址统一定义,迁移时只改这一行 ===== +upstream go_backend_dh2 { + server 10.31.100.2:8081; # 若 nginx 与 Go 同机,建议改为 127.0.0.1:8081(迁移零改动) +} + +# ==================== 主域名 dh2.fun(全量托管给 Go)==================== +server { + listen 80; + listen 443 ssl; + listen 443 quic; + http2 on; + + # 只匹配主域名(子域名由下方独立 server 块处理) + server_name dh2.fun; + + #CERT-APPLY-CHECK--START + include /www/server/panel/vhost/nginx/well-known/dh2.fun.conf; + #CERT-APPLY-CHECK--END + include /www/server/panel/vhost/nginx/extension/dh2.fun/*.conf; + + #SSL-START SSL相关配置,请勿删除或修改下一行带注释的404规则 + #error_page 404/404.html; + + #HTTP_TO_HTTPS_START + set $isRedcert 1; + if ($server_port != 443) { + set $isRedcert 2; + } + if ( $uri ~ /\.well-known/ ) { + set $isRedcert 1; + } + if ($isRedcert != 1) { + rewrite ^(/.*)$ https://$host$1 permanent; + } + #HTTP_TO_HTTPS_END + + ssl_certificate /www/server/panel/vhost/cert/dh2.fun/fullchain.pem; + ssl_certificate_key /www/server/panel/vhost/cert/dh2.fun/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_tickets on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + add_header Strict-Transport-Security "max-age=31536000"; + error_page 497 https://$host$request_uri; + #SSL-END + + # --- 安全拦截:敏感文件 --- + location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$ { + return 404; + } + + # --- 安全拦截:敏感目录 --- + location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/ { + return 404; + } + + # SSL 证书验证目录 + location ~ \.well-known { + allow all; + } + + if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) { + return 403; + } + + # /api/ 转发并剥掉 /api 前缀(尾部斜杠不能删) + location /api/ { + proxy_pass http://go_backend_dh2/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 其余一切请求全部交给 Go:首页、/news、/page/xxx、/themes/ 静态资源、 + # /uploads/ 以及以后新增的任何路由,自动生效,nginx 永远不用再改 + location / { + proxy_pass http://go_backend_dh2; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + access_log /www/wwwlogs/dh2.fun.log; + error_log /www/wwwlogs/dh2.fun.error.log; +} + +# ==================== 租户官网渲染:*.dh2.fun 所有子域名 ==================== +server { + listen 80; + listen 443 ssl; + http2 on; + + # 匹配所有子域名,如 ceshi.dh2.fun(租户域名发放后无需再改 nginx) + server_name ~^(?.+)\.dh2\.fun$; + + #CERT-APPLY-CHECK--START + include /www/server/panel/vhost/nginx/well-known/dh2.fun.conf; + #CERT-APPLY-CHECK--END + + #HTTP_TO_HTTPS_START + set $isRedcert 1; + if ($server_port != 443) { + set $isRedcert 2; + } + if ( $uri ~ /\.well-known/ ) { + set $isRedcert 1; + } + if ($isRedcert != 1) { + rewrite ^(/.*)$ https://$host$1 permanent; + } + #HTTP_TO_HTTPS_END + + # 注意:子域名 HTTPS 要求泛域名证书(*.dh2.fun),见文档"注意事项" + ssl_certificate /www/server/panel/vhost/cert/dh2.fun/fullchain.pem; + ssl_certificate_key /www/server/panel/vhost/cert/dh2.fun/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + add_header Strict-Transport-Security "max-age=31536000"; + error_page 497 https://$host$request_uri; + + location ~ \.well-known { + allow all; + } + + # 全部请求转发给 Go,由 Go 按 Host(如 ceshi.dh2.fun)识别租户并渲染官网 + location / { + proxy_pass http://go_backend_dh2; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + access_log /www/wwwlogs/dh2.fun.tenant.log; + error_log /www/wwwlogs/dh2.fun.tenant.error.log; +} +``` + +--- + +## 四、完整配置 —— 宝塔站点 `yunzer.com.cn` + +文件位置:宝塔 → 网站 → yunzer.com.cn → 设置 → 配置文件。整份替换。 + +```nginx +# ===== 后端 Go 服务地址统一定义,迁移时只改这一行 ===== +# 注意:不能与 dh2.fun 配置里的 upstream 同名,否则 nginx 报 duplicate upstream +upstream go_backend_yunzer { + server 10.31.100.2:8081; # 若 nginx 与 Go 同机,建议改为 127.0.0.1:8081(迁移零改动) +} + +# ==================== 主域名 yunzer.com.cn(全量托管给 Go)==================== +server { + listen 80; + listen 443 ssl; + listen 443 quic; + http2 on; + + # 只匹配主域名(子域名由下方独立 server 块处理) + server_name yunzer.com.cn; + + #CERT-APPLY-CHECK--START + include /www/server/panel/vhost/nginx/well-known/yunzer.com.cn.conf; + #CERT-APPLY-CHECK--END + include /www/server/panel/vhost/nginx/extension/yunzer.com.cn/*.conf; + + #SSL-START SSL相关配置,请勿删除或修改下一行带注释的404规则 + #error_page 404/404.html; + + #HTTP_TO_HTTPS_START + set $isRedcert 1; + if ($server_port != 443) { + set $isRedcert 2; + } + if ( $uri ~ /\.well-known/ ) { + set $isRedcert 1; + } + if ($isRedcert != 1) { + rewrite ^(/.*)$ https://$host$1 permanent; + } + #HTTP_TO_HTTPS_END + + ssl_certificate /www/server/panel/vhost/cert/yunzer.com.cn/fullchain.pem; + ssl_certificate_key /www/server/panel/vhost/cert/yunzer.com.cn/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_tickets on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + add_header Strict-Transport-Security "max-age=31536000"; + error_page 497 https://$host$request_uri; + #SSL-END + + # --- 安全拦截:敏感文件 --- + location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$ { + return 404; + } + + # --- 安全拦截:敏感目录 --- + location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/ { + return 404; + } + + # SSL 证书验证目录 + location ~ \.well-known { + allow all; + } + + if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) { + return 403; + } + + # /api/ 转发并剥掉 /api 前缀(尾部斜杠不能删) + location /api/ { + proxy_pass http://go_backend_yunzer/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 其余一切请求全部交给 Go:首页、/news、/page/xxx、/themes/ 静态资源、 + # /uploads/ 以及以后新增的任何路由,自动生效,nginx 永远不用再改 + location / { + proxy_pass http://go_backend_yunzer; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + access_log /www/wwwlogs/yunzer.com.cn.log; + error_log /www/wwwlogs/yunzer.com.cn.error.log; +} + +# ==================== 租户官网渲染:*.yunzer.com.cn 所有子域名 ==================== +server { + listen 80; + listen 443 ssl; + http2 on; + + # 匹配所有子域名,如 ceshi.yunzer.com.cn(租户域名发放后无需再改 nginx) + server_name ~^(?.+)\.yunzer\.com\.cn$; + + #CERT-APPLY-CHECK--START + include /www/server/panel/vhost/nginx/well-known/yunzer.com.cn.conf; + #CERT-APPLY-CHECK--END + + #HTTP_TO_HTTPS_START + set $isRedcert 1; + if ($server_port != 443) { + set $isRedcert 2; + } + if ( $uri ~ /\.well-known/ ) { + set $isRedcert 1; + } + if ($isRedcert != 1) { + rewrite ^(/.*)$ https://$host$1 permanent; + } + #HTTP_TO_HTTPS_END + + # 注意:子域名 HTTPS 要求泛域名证书(*.yunzer.com.cn),见文档"注意事项" + ssl_certificate /www/server/panel/vhost/cert/yunzer.com.cn/fullchain.pem; + ssl_certificate_key /www/server/panel/vhost/cert/yunzer.com.cn/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + add_header Strict-Transport-Security "max-age=31536000"; + error_page 497 https://$host$request_uri; + + location ~ \.well-known { + allow all; + } + + # 全部请求转发给 Go,由 Go 按 Host(如 ceshi.yunzer.com.cn)识别租户并渲染官网 + location / { + proxy_pass http://go_backend_yunzer; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + access_log /www/wwwlogs/yunzer.com.cn.tenant.log; + error_log /www/wwwlogs/yunzer.com.cn.tenant.error.log; +} +``` + +> 若管理端(platform/backend 前端)部署在专属子域名(如 `admin.yunzer.com.cn`),在对应站点再加一个 `server_name admin.yunzer.com.cn;` **精确匹配**的 server 块指向其静态目录即可——精确匹配优先于子域名正则块,互不干扰。 + +--- + +## 五、重要注意事项 + +1. **子域名必须用泛域名证书**。租户域名是二级域名(`ceshi.yunzer.com.cn`),单域名证书会导致 HTTPS 证书错误。需在宝塔为 `*.yunzer.com.cn`、`*.dh2.fun` 申请泛域名证书(DNS 验证),并替换配置中的证书路径。 +2. **upstream 不能重名**。两个站点配置都在同一 http 上下文,本文分别用 `go_backend_dh2`、`go_backend_yunzer`;重名会 `duplicate upstream` 启动失败。 +3. **`/api/` 的 `proxy_pass` 尾部斜杠不能删**,它负责剥掉 `/api` 前缀(Go 路由注册在 `/backend/*`、`/platform/*`)。兜底 `location /` 的 `proxy_pass` 则**不带**尾部斜杠(原样转发 URI)。 +4. **不要再给官网域名加 `js|css` 正则缓存 location**。正则 location 优先级高于兜底前缀 location,会拦截 `/themes/xxx/static/main.js` 去本地 root 找文件导致 404。模板静态资源由 Go(beego)直接提供。 +5. **`proxy_set_header Host $host;` 必须保留**。Go 完全依赖 Host 识别租户;若透传成 upstream 地址,所有请求都会被识别失败。 +6. **Go 侧前置条件**: + - 启动模式 `APP_MODE` 为 `all`(默认)或 `index`,否则官网渲染路由未注册; + - `go/themes/` 目录与 Go 服务启动目录同级(模板文件在 **Go 项目目录** `go/themes/{编码}/`,与 platform/backend 前端项目无关); + - 数据库已执行 `sql/yz_cms_frontend_tables.sql`。 +7. **域名发放数据必须精确**。`yz_system_tenant_domain.full_domain` 要与实际访问域名完全一致(不带协议、不带端口、不带路径),且 `status=1`、`delete_time` 为 NULL。 +8. **DNS 泛解析**。`*.yunzer.com.cn`、`*.dh2.fun` 需要配置泛解析 A 记录指向服务器,否则新发放的子域名无法访问(与 nginx 无关)。 +9. **HSTS 提醒**。配置了 `Strict-Transport-Security`,一旦浏览器记住,证书出问题期间该域名将无法用 HTTP 访问;测试阶段可先注释掉该行。 +10. **宝塔面板操作提示**。直接编辑站点配置文件后,用 `nginx -t` 校验再重载;宝塔后续"站点设置"的某些操作可能重写配置文件,改前建议备份。 + +--- + +## 六、验证与排查 + +### 6.1 部署后验证顺序 + +```bash +# 1. 绕过 nginx 直接验证 Go(在服务器上执行) +curl -H "Host: ceshi.yunzer.com.cn" http://10.31.100.2:8081/ + +# 2. 验证 nginx → Go 链路 +curl -k -H "Host: ceshi.yunzer.com.cn" https://127.0.0.1/ + +# 3. 浏览器访问 https://ceshi.yunzer.com.cn +``` + +### 6.2 常见现象对照表 + +| 现象 | 原因 | 处理 | +|---|---|---| +| Go 页面显示"站点不存在" | Host 在 `yz_system_tenant_domain` / 域名池中查不到 | 核对 `full_domain` 拼写、`status`、`delete_time` | +| beego 默认 404 | `APP_MODE` 不含 `all`/`index`,渲染路由未注册 | 检查 Go 启动环境变量 | +| nginx 404(未到 Go) | 请求被本地静态/正则规则拦截 | 检查是否残留 `try_files /index.html`、`js|css` 正则 location | +| 浏览器证书错误 | 非泛域名证书 | 申请 `*.域名` 泛域名证书并替换路径 | +| 502 Bad Gateway | Go 服务未启动或 upstream 地址错误 | `curl http://127.0.0.1:8081/` 验证服务存活,改 upstream | +| 首页正常但样式丢失 | `/themes/` 未到达 Go | 确认兜底转发存在且无 js/css 正则拦截 | + +--- + +## 七、迁移服务器清单(以迁移到 192.168.1.101 为例) + +1. **新机器部署 Go**:复制 `go/` 目录(含 `themes/`、`conf/app.conf`),修改 `app.conf` 数据库连接为新库地址,启动服务; +2. **新库初始化**:导入业务数据,执行 `sql/` 下全部建表/变更 SQL(尤其 `yz_cms_frontend_tables.sql`); +3. **nginx**: + - nginx 与 Go 同机 → upstream 用 `127.0.0.1:8081`,配置零改动; + - 跨机 → 只改两个 upstream 块中的 `server` 一行; +4. **证书**:新机器宝塔上为两个域名重新申请/导入证书(含泛域名),路径与配置保持一致; +5. **DNS**:将 `dh2.fun`、`yunzer.com.cn` 及泛解析 `*` 的 A 记录改指向新服务器 IP; +6. **验证**:按第六节顺序逐一验证; +7. 确认无误后到域名服务商处调低旧解析 TTL、观察切换。 + +--- + +*本文档对应代码:`go/controllers/tenant_site.go`(Host 识别与渲染)、`go/pkg/tagengine/`(标签引擎)、`go/themes/default/`(内置默认模板)、`sql/yz_cms_frontend_tables.sql`(建站数据表)。* diff --git a/go/controllers/backend_article.go b/go/controllers/backend_article.go index ee89cb4..2f464ee 100644 --- a/go/controllers/backend_article.go +++ b/go/controllers/backend_article.go @@ -307,6 +307,15 @@ func (c *BackendArticleController) List() { qs = qs.Filter("cate_id", cid) } } + // 创建时间区间筛选(前端日期控件传 yyyy-MM-dd) + startDate := strings.TrimSpace(c.GetString("startDate")) + endDate := strings.TrimSpace(c.GetString("endDate")) + if startDate != "" { + qs = qs.Filter("create_time__gte", startDate+" 00:00:00") + } + if endDate != "" { + qs = qs.Filter("create_time__lte", endDate+" 23:59:59") + } total, _ := qs.Count() var rows []models.CmsArticle diff --git a/go/controllers/tenant_site.go b/go/controllers/tenant_site.go index 9a44a62..13decae 100644 --- a/go/controllers/tenant_site.go +++ b/go/controllers/tenant_site.go @@ -119,7 +119,32 @@ func (c *TenantSiteController) resolveTemplateCode(tid uint64) string { func (c *TenantSiteController) writeHTML(status int, html string) { c.Ctx.Output.SetStatus(status) c.Ctx.Output.Header("Content-Type", "text/html; charset=utf-8") - c.Ctx.WriteString(html) + c.Ctx.WriteString(c.forceHTTPSStorageURLs(html)) +} + +// forceHTTPSStorageURLs 访客以 HTTPS 访问站点时,把页面中存储(CDN)域名的 +// http:// 资源升级为 https://,避免混合内容(Mixed Content)被浏览器拦截。 +// 仅替换存储配置里那个域名,不动正文里的其他外部链接。 +func (c *TenantSiteController) forceHTTPSStorageURLs(html string) string { + proto := c.Ctx.Input.Header("X-Forwarded-Proto") + if proto == "" && c.Ctx.Input.IsSecure() { + proto = "https" + } + if proto != "https" { + return html + } + cfg, err := models.GetStorageConfig() + if err != nil || strings.TrimSpace(cfg.QiniuDomain) == "" { + return html + } + host := strings.TrimSpace(cfg.QiniuDomain) + host = strings.TrimPrefix(host, "http://") + host = strings.TrimPrefix(host, "https://") + host = strings.TrimRight(host, "/") + if host == "" { + return html + } + return strings.ReplaceAll(html, "http://"+host, "https://"+host) } // render 解析并输出模板文件 diff --git a/go/pkg/tagengine/engine.go b/go/pkg/tagengine/engine.go index 96edc91..5932e83 100644 --- a/go/pkg/tagengine/engine.go +++ b/go/pkg/tagengine/engine.go @@ -42,8 +42,10 @@ func RegisterSelfClose(name string, h SelfCloseHandler) { selfCloseHandlers[name func RegisterGlobal(p GlobalProvider) { globalProvider = p } var ( - // 最内层块标签(内部不再嵌套块标签),反复匹配直至收敛 - reBlock = regexp.MustCompile(`(?s)\{yz:([a-zA-Z]+)((?:\s+[a-zA-Z_][\w-]*\s*=\s*"[^"]*")*)\s*\}((?:(?!\{/?yz:).)*?)\{/yz:\1\}`) + // 块标签开标签(带属性);自闭合标签因 "/" 不会被匹配 + reBlockOpen = regexp.MustCompile(`\{yz:([a-zA-Z]+)((?:\s+[a-zA-Z_][\w-]*\s*=\s*"[^"]*")*)\s*\}`) + // 块标签闭标签 + reBlockClose = regexp.MustCompile(`\{/yz:([a-zA-Z]+)\}`) // 自闭合标签 {yz:name attr="v" /} reSelfClose = regexp.MustCompile(`\{yz:([a-zA-Z]+)((?:\s+[a-zA-Z_][\w-]*\s*=\s*"[^"]*")*)\s*/\}`) // 全局标签 {yz:word} @@ -122,28 +124,40 @@ func expandIncludes(dir, content string, depth int) (string, error) { return out, nil } -// renderBlocks 反复解析最内层块标签直至全部展开 +// renderBlocks 逐个解析块标签:每次取全文第一个闭标签(必属最内层块), +// 向前回溯同名开标签,按行展开后回填,重复至无块标签(Go RE2 不支持负向先行断言,故手工配对) func renderBlocks(content string, ctx *RenderCtx) (string, error) { out := content - for i := 0; i < 10; i++ { - if !reBlock.MatchString(out) { + for i := 0; i < 100; i++ { + c := reBlockClose.FindStringIndex(out) + if c == nil { break } - var ferr error - out = reBlock.ReplaceAllStringFunc(out, func(m string) string { - sub := reBlock.FindStringSubmatch(m) - name := strings.ToLower(sub[1]) - attrs := parseAttrs(sub[2]) - body := sub[3] - p, ok := blockProviders[name] - if !ok { - return "" // 未注册的块标签直接置空,避免标签泄漏到前台 + closeName := strings.ToLower(reBlockClose.FindStringSubmatch(out[c[0]:c[1]])[1]) + + // 在闭标签之前找最后一个同名开标签 + openIdx, openEnd := -1, -1 + attrs := "" + for _, om := range reBlockOpen.FindAllStringSubmatchIndex(out[:c[0]], -1) { + if strings.ToLower(out[om[2]:om[3]]) != closeName { + continue } - rows, err := p(ctx.Tid, attrs, ctx) + openIdx, openEnd = om[0], om[1] + attrs = out[om[4]:om[5]] + } + if openIdx < 0 { + // 孤立的闭标签:移除后继续,避免死循环 + out = out[:c[0]] + out[c[1]:] + continue + } + + rendered := "" + if p, ok := blockProviders[closeName]; ok { + rows, err := p(ctx.Tid, parseAttrs(attrs), ctx) if err != nil { - ferr = err - return "" + return "", err } + body := out[openEnd:c[0]] var sb strings.Builder for _, row := range rows { sb.WriteString(reField.ReplaceAllStringFunc(body, func(fm string) string { @@ -151,11 +165,10 @@ func renderBlocks(content string, ctx *RenderCtx) (string, error) { return row[field] })) } - return sb.String() - }) - if ferr != nil { - return "", ferr + rendered = sb.String() } + // 未注册的块标签置空,避免标签泄漏到前台 + out = out[:openIdx] + rendered + out[c[1]:] } return out, nil } diff --git a/go/server b/go/server new file mode 100644 index 0000000..04f7e42 Binary files /dev/null and b/go/server differ diff --git a/platform/components.d.ts b/platform/components.d.ts index 2e1dc36..df529bb 100644 --- a/platform/components.d.ts +++ b/platform/components.d.ts @@ -41,6 +41,7 @@ declare module 'vue' { ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElHeader: typeof import('element-plus/es')['ElHeader'] ElIcon: typeof import('element-plus/es')['ElIcon'] + ElImage: typeof import('element-plus/es')['ElImage'] ElInput: typeof import('element-plus/es')['ElInput'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElLink: typeof import('element-plus/es')['ElLink'] diff --git a/platform/package-lock.json b/platform/package-lock.json index 5990ee2..ea9fece 100644 --- a/platform/package-lock.json +++ b/platform/package-lock.json @@ -7099,7 +7099,7 @@ "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/platform/src/components/CommonAside.vue b/platform/src/components/CommonAside.vue index 470ae4c..dbf32ed 100644 --- a/platform/src/components/CommonAside.vue +++ b/platform/src/components/CommonAside.vue @@ -565,6 +565,7 @@ h3 { font-size: 14px; font-weight: 500; position: relative; + background: transparent; html:not(.dark) & { color: rgba(255, 255, 255, 0.85); @@ -609,19 +610,24 @@ h3 { color: #ffffff !important; } + // 嵌套展开的子菜单容器保持透明,避免 Element Plus 注入的 + // --el-menu-bg-color 在二级及以下区域形成不透明背景块; + // 二级 ul 不需要上下内边距 + .el-menu--inline { + background: transparent !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + } + // 子菜单样式 .el-sub-menu { .el-sub-menu__title { position: relative; } - &.is-opened .el-sub-menu__title { - background: rgba(255, 255, 255, 0.12) !important; - margin-left: -3px; - } + // 展开的父级菜单标题不加背景,背景只留给当前激活的菜单项 .el-menu-item { - padding-left: 48px !important; font-size: 13px; &.is-active { @@ -630,6 +636,18 @@ h3 { } } + // 二级菜单项缩进 24px + .el-menu--inline > .el-menu-item, + .el-menu--inline > .el-sub-menu > .el-sub-menu__title { + padding-left: 24px !important; + } + + // 三级菜单项缩进 48px + .el-menu--inline .el-menu--inline > .el-menu-item, + .el-menu--inline .el-menu--inline > .el-sub-menu > .el-sub-menu__title { + padding-left: 48px !important; + } + // 暗色主题适配 html.dark & { .el-menu-item.is-active { @@ -648,10 +666,6 @@ h3 { color: var(--el-color-primary-light-3) !important; } - .el-sub-menu.is-opened .el-sub-menu__title { - background: rgba(64, 158, 255, 0.08) !important; - } - .el-sub-menu .el-menu-item.is-active { background: rgba(64, 158, 255, 0.15) !important; } diff --git a/platform/src/views/components/UmoEditor.vue b/platform/src/views/components/UmoEditor.vue index 2a80d02..530470e 100644 --- a/platform/src/views/components/UmoEditor.vue +++ b/platform/src/views/components/UmoEditor.vue @@ -28,6 +28,8 @@ const isCreated = ref(false) const options = { height: '100%', + // umo-editor 会定时触发自动保存(onSave),内容已随表单统一提交,这里给空实现避免控制台报错 + onSave: async () => {}, } const onFileUpload = async (file) => { diff --git a/platform/src/views/template/index.vue b/platform/src/views/template/index.vue index a689f8c..4885e50 100644 --- a/platform/src/views/template/index.vue +++ b/platform/src/views/template/index.vue @@ -16,8 +16,13 @@ - 模板文件存放于服务器 themes/{编码}/ 目录;上传 zip 或扫描目录后自动登记。 - 租户端(backend)只能选用已启用的模板,模板编辑仅平台端可操作。 +