From 3f0fcb88e71b615fec97ff166a46894f408f8086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BF=97=E5=BC=BA?= <357099073@qq.com> Date: Thu, 20 Aug 2026 16:21:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BC=81=E4=B8=9A=E7=BD=91?= =?UTF-8?q?=E7=AB=99=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api/services.js | 54 + backend/src/components/CommonAside.vue | 38 +- backend/src/router/index.js | 52 +- .../index}/components/edit.vue | 1372 ++++++++--------- .../index}/components/preview.vue | 698 ++++----- .../cms/{articles => article/index}/index.vue | 1354 ++++++++-------- .../type}/components/CategoryNode.vue | 554 +++---- .../type/components/edit.vue} | 648 ++++---- .../category.vue => article/type/index.vue} | 996 ++++++------ .../src/views/apps/cms/frontMenu/index.vue | 12 +- .../index}/components/edit.vue | 640 ++++---- .../cms/{products => product/index}/index.vue | 590 +++---- .../type}/components/edit.vue | 422 ++--- .../types => product/type}/index.vue | 478 +++--- .../index}/components/edit.vue | 630 ++++---- .../{services => solution/index}/index.vue | 576 +++---- .../cms/solution/type/components/edit.vue | 210 +++ .../views/apps/cms/solution/type/index.vue | 239 +++ docs/关于租户官网服务器端nginx的配置.md | 71 +- go/controllers/backend_article.go | 6 - go/controllers/backend_menu_front.go | 16 +- go/controllers/backend_product.go | 528 +++++++ go/controllers/backend_solution.go | 524 +++++++ go/controllers/tenant_site.go | 60 +- go/models/backend_menu_front.go | 2 +- go/models/cms_article.go | 41 - go/models/cms_product.go | 89 ++ go/models/cms_solution.go | 87 ++ go/models/front_menu_default.go | 77 + go/pkg/tagengine/engine.go | 31 +- go/pkg/tagengine/meta.go | 80 +- go/pkg/tagengine/provider.go | 114 +- go/routers/backend/backend.go | 22 + go/themes/theme1/about.html | 196 +++ go/themes/theme1/assets/js/main.js | 24 +- go/themes/theme1/contact.html | 260 ++++ go/themes/theme1/index.html | 523 ++----- go/themes/theme1/news.html | 251 +++ go/themes/theme1/news_detail.html | 243 +++ go/themes/theme1/page.html | 189 +++ go/themes/theme1/style.css | 558 +++++++ platform/src/views/template/tags.vue | 185 ++- sql/cleanup_cms_auto_categories.sql | 7 + sql/yz_cms_product.sql | 35 + sql/yz_cms_solution.sql | 34 + 45 files changed, 8758 insertions(+), 5058 deletions(-) rename backend/src/views/apps/cms/{articles => article/index}/components/edit.vue (96%) rename backend/src/views/apps/cms/{articles => article/index}/components/preview.vue (95%) rename backend/src/views/apps/cms/{articles => article/index}/index.vue (95%) rename backend/src/views/apps/cms/{articles => article/type}/components/CategoryNode.vue (95%) rename backend/src/views/apps/cms/{articles/components/edit-cate.vue => article/type/components/edit.vue} (96%) rename backend/src/views/apps/cms/{articles/category.vue => article/type/index.vue} (95%) rename backend/src/views/apps/cms/{products => product/index}/components/edit.vue (95%) rename backend/src/views/apps/cms/{products => product/index}/index.vue (96%) rename backend/src/views/apps/cms/{products/types => product/type}/components/edit.vue (95%) rename backend/src/views/apps/cms/{products/types => product/type}/index.vue (96%) rename backend/src/views/apps/cms/{services => solution/index}/components/edit.vue (95%) rename backend/src/views/apps/cms/{services => solution/index}/index.vue (94%) create mode 100644 backend/src/views/apps/cms/solution/type/components/edit.vue create mode 100644 backend/src/views/apps/cms/solution/type/index.vue create mode 100644 go/controllers/backend_product.go create mode 100644 go/controllers/backend_solution.go create mode 100644 go/models/cms_product.go create mode 100644 go/models/cms_solution.go create mode 100644 go/models/front_menu_default.go create mode 100644 go/themes/theme1/about.html create mode 100644 go/themes/theme1/contact.html create mode 100644 go/themes/theme1/news.html create mode 100644 go/themes/theme1/news_detail.html create mode 100644 go/themes/theme1/page.html create mode 100644 sql/cleanup_cms_auto_categories.sql create mode 100644 sql/yz_cms_product.sql create mode 100644 sql/yz_cms_solution.sql diff --git a/backend/src/api/services.js b/backend/src/api/services.js index 80aeee8..39df29e 100644 --- a/backend/src/api/services.js +++ b/backend/src/api/services.js @@ -51,3 +51,57 @@ export function deleteService(id) { method: 'delete' }) } + +////////////////////////////分类相关//////////////////////////// + +/** + * 获取解决方案分类列表 + * @param {Object} params - 查询参数 + * @returns {Promise} + */ +export function getServicesTypesList(params) { + return request({ + url: '/backend/servicesTypesList', + method: 'get', + params + }) +} + +/** + * 添加解决方案分类 + * @param {Object} data - 分类数据 + * @returns {Promise} + */ +export function addServicesTypes(data) { + return request({ + url: '/backend/addServicesTypes', + method: 'post', + data + }) +} + +/** + * 更新解决方案分类 + * @param {number} id - 分类ID + * @param {Object} data - 分类数据 + * @returns {Promise} + */ +export function updateServicesTypes(id, data) { + return request({ + url: `/backend/editServicesTypes/${id}`, + method: 'put', + data + }) +} + +/** + * 删除解决方案分类 + * @param {number} id - 分类ID + * @returns {Promise} + */ +export function deleteServicesTypes(id) { + return request({ + url: `/backend/deleteServicesTypes/${id}`, + method: 'delete' + }) +} diff --git a/backend/src/components/CommonAside.vue b/backend/src/components/CommonAside.vue index b469140..ddb0574 100644 --- a/backend/src/components/CommonAside.vue +++ b/backend/src/components/CommonAside.vue @@ -292,13 +292,13 @@ const processMenus = (menus) => { const fixedCmsMenu = { id: -200, path: "/apps/cms", - title: "文章中心", + title: "内容管理", icon: "Document", order: -200, children: [ { id: -201, - path: "/apps/cms/articles", + path: "/apps/cms/article", title: "文章管理", icon: "Document", order: 1, @@ -306,11 +306,43 @@ const fixedCmsMenu = { }, { id: -202, - path: "/apps/cms/articles/category", + path: "/apps/cms/article/type", title: "文章分类", icon: "Folder", order: 2, children: [] + }, + { + id: -203, + path: "/apps/cms/products", + title: "产品管理", + icon: "Goods", + order: 3, + children: [] + }, + { + id: -204, + path: "/apps/cms/products/types", + title: "产品分类", + icon: "Folder", + order: 4, + children: [] + }, + { + id: -205, + path: "/apps/cms/solutions", + title: "解决方案管理", + icon: "Promotion", + order: 5, + children: [] + }, + { + id: -206, + path: "/apps/cms/solutions/types", + title: "解决方案分类", + icon: "Folder", + order: 6, + children: [] } ] }; diff --git a/backend/src/router/index.js b/backend/src/router/index.js index 2eec62b..113f0ed 100644 --- a/backend/src/router/index.js +++ b/backend/src/router/index.js @@ -3,19 +3,60 @@ import { convertMenusToRoutes } from "./dynamicRoutes"; // 静态子路由:需要在 Main 框架内显示的页面 const staticMainChildren = [ - // CMS 文章中心是系统内置功能,不依赖数据库菜单配置。 + // 默认首页:访问 / 时直接展示工作台,URL 保持为 / { - path: "/apps/cms/articles", + path: "", + name: "Dashboard", + component: () => import("@/views/dashboard/index.vue"), + meta: { requiresAuth: true, title: "工作台" } + }, + // CMS 文章/产品/解决方案是系统内置功能,不依赖数据库菜单配置。 + // 统一结构:index 为列表页,type 为分类页。 + { + path: "/apps/cms/article", name: "CmsArticles", - component: () => import("@/views/apps/cms/articles/index.vue"), + component: () => import("@/views/apps/cms/article/index/index.vue"), meta: { requiresAuth: true, title: "文章管理", modulePath: "/apps/cms" } }, { - path: "/apps/cms/articles/category", + path: "/apps/cms/article/type", name: "CmsArticleCategories", - component: () => import("@/views/apps/cms/articles/category.vue"), + component: () => import("@/views/apps/cms/article/type/index.vue"), meta: { requiresAuth: true, title: "文章分类", modulePath: "/apps/cms" } }, + { + path: "/apps/cms/products", + name: "CmsProducts", + component: () => import("@/views/apps/cms/product/index/index.vue"), + meta: { requiresAuth: true, title: "产品管理", modulePath: "/apps/cms" } + }, + { + path: "/apps/cms/products/types", + name: "CmsProductCategories", + component: () => import("@/views/apps/cms/product/type/index.vue"), + meta: { requiresAuth: true, title: "产品分类", modulePath: "/apps/cms" } + }, + { + path: "/apps/cms/solutions", + name: "CmsSolutions", + component: () => import("@/views/apps/cms/solution/index/index.vue"), + meta: { requiresAuth: true, title: "解决方案管理", modulePath: "/apps/cms" } + }, + { + path: "/apps/cms/solutions/types", + name: "CmsSolutionCategories", + component: () => import("@/views/apps/cms/solution/type/index.vue"), + meta: { requiresAuth: true, title: "解决方案分类", modulePath: "/apps/cms" } + }, + // 兼容旧路径:articles/* -> article/* + { + path: "/apps/cms/articles", + redirect: "/apps/cms/article" + }, + { + path: "/apps/cms/articles/category", + redirect: "/apps/cms/article/type" + }, { path: "/user/userProfile", name: "userProfile", @@ -138,7 +179,6 @@ function addDynamicRoutes(menus) { path: "/", name: "Main", component: () => import("@/views/Main.vue"), - redirect: "/dashboard", meta: { requiresAuth: true }, children: [...staticMainChildren, ...dynamicRoutes] // 合并静态和动态子路由 }); diff --git a/backend/src/views/apps/cms/articles/components/edit.vue b/backend/src/views/apps/cms/article/index/components/edit.vue similarity index 96% rename from backend/src/views/apps/cms/articles/components/edit.vue rename to backend/src/views/apps/cms/article/index/components/edit.vue index 8e2ac45..076d53e 100644 --- a/backend/src/views/apps/cms/articles/components/edit.vue +++ b/backend/src/views/apps/cms/article/index/components/edit.vue @@ -1,686 +1,686 @@ - - - - - + + + + + diff --git a/backend/src/views/apps/cms/articles/components/preview.vue b/backend/src/views/apps/cms/article/index/components/preview.vue similarity index 95% rename from backend/src/views/apps/cms/articles/components/preview.vue rename to backend/src/views/apps/cms/article/index/components/preview.vue index 9068bfb..7747110 100644 --- a/backend/src/views/apps/cms/articles/components/preview.vue +++ b/backend/src/views/apps/cms/article/index/components/preview.vue @@ -1,349 +1,349 @@ - - - - - + + + + + diff --git a/backend/src/views/apps/cms/articles/index.vue b/backend/src/views/apps/cms/article/index/index.vue similarity index 95% rename from backend/src/views/apps/cms/articles/index.vue rename to backend/src/views/apps/cms/article/index/index.vue index 42b49eb..d99c0ff 100644 --- a/backend/src/views/apps/cms/articles/index.vue +++ b/backend/src/views/apps/cms/article/index/index.vue @@ -1,677 +1,677 @@ - - - - - + + + + + diff --git a/backend/src/views/apps/cms/articles/components/CategoryNode.vue b/backend/src/views/apps/cms/article/type/components/CategoryNode.vue similarity index 95% rename from backend/src/views/apps/cms/articles/components/CategoryNode.vue rename to backend/src/views/apps/cms/article/type/components/CategoryNode.vue index 0f8b4cb..f78a170 100644 --- a/backend/src/views/apps/cms/articles/components/CategoryNode.vue +++ b/backend/src/views/apps/cms/article/type/components/CategoryNode.vue @@ -1,278 +1,278 @@ - - - - - \ No newline at end of file diff --git a/backend/src/views/apps/cms/articles/components/edit-cate.vue b/backend/src/views/apps/cms/article/type/components/edit.vue similarity index 96% rename from backend/src/views/apps/cms/articles/components/edit-cate.vue rename to backend/src/views/apps/cms/article/type/components/edit.vue index c6d14e6..21aae19 100644 --- a/backend/src/views/apps/cms/articles/components/edit-cate.vue +++ b/backend/src/views/apps/cms/article/type/components/edit.vue @@ -1,324 +1,324 @@ - - - - - + + + + + diff --git a/backend/src/views/apps/cms/articles/category.vue b/backend/src/views/apps/cms/article/type/index.vue similarity index 95% rename from backend/src/views/apps/cms/articles/category.vue rename to backend/src/views/apps/cms/article/type/index.vue index 70ba02d..8eb6e8a 100644 --- a/backend/src/views/apps/cms/articles/category.vue +++ b/backend/src/views/apps/cms/article/type/index.vue @@ -1,498 +1,498 @@ - - - - - + + + + + diff --git a/backend/src/views/apps/cms/frontMenu/index.vue b/backend/src/views/apps/cms/frontMenu/index.vue index 94c39db..3e9b2b8 100644 --- a/backend/src/views/apps/cms/frontMenu/index.vue +++ b/backend/src/views/apps/cms/frontMenu/index.vue @@ -56,6 +56,9 @@ class="menu-icon" > {{ scope.row.title }} + + 系统默认 + @@ -110,7 +113,12 @@ 子菜单 - + @@ -122,6 +130,7 @@ text type="danger" @click="handleDeleteMenu(scope.row)" + :disabled="scope.row.tenant_id === 0" > @@ -168,6 +177,7 @@ import MenuEdit from "./components/edit.vue"; // 定义菜单数据类型 interface Menu { id: number; + tenant_id?: number; // 0 表示系统默认菜单(不可编辑/删除) pid: number; title: string; type: number; diff --git a/backend/src/views/apps/cms/products/components/edit.vue b/backend/src/views/apps/cms/product/index/components/edit.vue similarity index 95% rename from backend/src/views/apps/cms/products/components/edit.vue rename to backend/src/views/apps/cms/product/index/components/edit.vue index 7100ab2..17ea62d 100644 --- a/backend/src/views/apps/cms/products/components/edit.vue +++ b/backend/src/views/apps/cms/product/index/components/edit.vue @@ -1,319 +1,321 @@ - - - - - + + + + + diff --git a/backend/src/views/apps/cms/products/index.vue b/backend/src/views/apps/cms/product/index/index.vue similarity index 96% rename from backend/src/views/apps/cms/products/index.vue rename to backend/src/views/apps/cms/product/index/index.vue index 0d11ae8..215cbff 100644 --- a/backend/src/views/apps/cms/products/index.vue +++ b/backend/src/views/apps/cms/product/index/index.vue @@ -1,295 +1,295 @@ - - - - - + + + + + diff --git a/backend/src/views/apps/cms/products/types/components/edit.vue b/backend/src/views/apps/cms/product/type/components/edit.vue similarity index 95% rename from backend/src/views/apps/cms/products/types/components/edit.vue rename to backend/src/views/apps/cms/product/type/components/edit.vue index ff330ca..bc50820 100644 --- a/backend/src/views/apps/cms/products/types/components/edit.vue +++ b/backend/src/views/apps/cms/product/type/components/edit.vue @@ -1,211 +1,211 @@ - - - - - - + + + + + + diff --git a/backend/src/views/apps/cms/products/types/index.vue b/backend/src/views/apps/cms/product/type/index.vue similarity index 96% rename from backend/src/views/apps/cms/products/types/index.vue rename to backend/src/views/apps/cms/product/type/index.vue index 8a507d7..e3bc056 100644 --- a/backend/src/views/apps/cms/products/types/index.vue +++ b/backend/src/views/apps/cms/product/type/index.vue @@ -1,240 +1,240 @@ - - - - - + + + + + \ No newline at end of file diff --git a/backend/src/views/apps/cms/services/components/edit.vue b/backend/src/views/apps/cms/solution/index/components/edit.vue similarity index 95% rename from backend/src/views/apps/cms/services/components/edit.vue rename to backend/src/views/apps/cms/solution/index/components/edit.vue index 4d2a6b9..9921169 100644 --- a/backend/src/views/apps/cms/services/components/edit.vue +++ b/backend/src/views/apps/cms/solution/index/components/edit.vue @@ -1,315 +1,315 @@ - - - - - + + + + + diff --git a/backend/src/views/apps/cms/services/index.vue b/backend/src/views/apps/cms/solution/index/index.vue similarity index 94% rename from backend/src/views/apps/cms/services/index.vue rename to backend/src/views/apps/cms/solution/index/index.vue index b446af3..0dd8959 100644 --- a/backend/src/views/apps/cms/services/index.vue +++ b/backend/src/views/apps/cms/solution/index/index.vue @@ -1,281 +1,295 @@ - - - - - + + + + + diff --git a/backend/src/views/apps/cms/solution/type/components/edit.vue b/backend/src/views/apps/cms/solution/type/components/edit.vue new file mode 100644 index 0000000..e8693dc --- /dev/null +++ b/backend/src/views/apps/cms/solution/type/components/edit.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/backend/src/views/apps/cms/solution/type/index.vue b/backend/src/views/apps/cms/solution/type/index.vue new file mode 100644 index 0000000..78e113c --- /dev/null +++ b/backend/src/views/apps/cms/solution/type/index.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/docs/关于租户官网服务器端nginx的配置.md b/docs/关于租户官网服务器端nginx的配置.md index 7f3072c..d5dfd41 100644 --- a/docs/关于租户官网服务器端nginx的配置.md +++ b/docs/关于租户官网服务器端nginx的配置.md @@ -103,6 +103,29 @@ location /api/ { Go 的路由注册在 `/backend/*`、`/platform/*`(没有 `/api` 前缀),靠这个尾部斜杠剥前缀。 +### 2.5 七牛图片统一通过官网域名代理 + +完整配置已在两个主域名的 `server` 块中加入 `location /qiniu/`。该规则会把官网域名下的 `/qiniu/` 请求通过 **HTTP** 代理到历史七牛域名 `http://7colud.yunzer.cn/`,并去掉 `/qiniu/` 前缀;浏览器仍通过官网 HTTPS 接收图片。 + +例如,浏览器请求: + +```text +https://yunzer.com.cn/qiniu/2026/06/02/1780371759517804300.png +``` + +nginx 实际请求: + +```text +http://7colud.yunzer.cn/2026/06/02/1780371759517804300.png +``` + +**存储配置填写规则**:平台后台“存储配置”中的 `qiniu_domain` 必须与实际复制的主站配置一致,且**末尾不要加 `/`**: + +- 复制 `yunzer.com.cn` 配置时填写 `https://yunzer.com.cn/qiniu`; +- 复制 `dh2.fun` 配置时填写 `https://dh2.fun/qiniu`。 + +Go 官网渲染会将数据库中已有的 `http(s)://7colud.yunzer.cn/路径`(兼容历史拼写 `7cloud.yunzer.cn`)替换为此配置的地址;之后新上传的七牛文件也会直接使用该代理地址。`location /qiniu/` 的 `proxy_pass` **末尾斜杠不能删除**,否则七牛会收到包含 `/qiniu/` 的错误对象路径。 + --- ## 三、完整配置 —— 宝塔站点 `dh2.fun` @@ -186,6 +209,19 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # 七牛图片代理:/qiniu/ 前缀会被剥掉,转发到七牛对象路径 + # 存储配置 qiniu_domain 填 https://dh2.fun/qiniu(末尾不要加 /) + location /qiniu/ { + proxy_pass http://7colud.yunzer.cn/; + proxy_set_header Host 7colud.yunzer.cn; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # 浏览器缓存图片 7 天,减少重复请求 + expires 7d; + add_header Cache-Control "public, max-age=604800"; + } + # 其余一切请求全部交给 Go:首页、/news、/page/xxx、/themes/ 静态资源、 # /uploads/ 以及以后新增的任何路由,自动生效,nginx 永远不用再改 location / { @@ -339,6 +375,19 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # 七牛图片代理:/qiniu/ 前缀会被剥掉,转发到七牛对象路径 + # 存储配置 qiniu_domain 填 https://yunzer.com.cn/qiniu(末尾不要加 /) + location /qiniu/ { + proxy_pass http://7colud.yunzer.cn/; + proxy_set_header Host 7colud.yunzer.cn; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # 浏览器缓存图片 7 天,减少重复请求 + expires 7d; + add_header Cache-Control "public, max-age=604800"; + } + # 其余一切请求全部交给 Go:首页、/news、/page/xxx、/themes/ 静态资源、 # /uploads/ 以及以后新增的任何路由,自动生效,nginx 永远不用再改 location / { @@ -416,17 +465,18 @@ 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 侧前置条件**: +3. **`/api/` 和 `/qiniu/` 的 `proxy_pass` 尾部斜杠不能删**。`/api/` 的斜杠负责剥掉 `/api` 前缀(Go 路由注册在 `/backend/*`、`/platform/*`);`/qiniu/` 的斜杠负责剥掉 `/qiniu` 前缀,使七牛收到正确对象路径。兜底 `location /` 的 `proxy_pass` 则**不带**尾部斜杠(原样转发 URI)。 +4. **存储域名必须与复制的主站配置对应**。使用 `yunzer.com.cn` 配置则后台“存储配置”中的 `qiniu_domain` 填 `https://yunzer.com.cn/qiniu`;使用 `dh2.fun` 配置则填 `https://dh2.fun/qiniu`。均不带末尾 `/`。 +5. **不要再给官网域名加 `js|css` 正则缓存 location**。正则 location 优先级高于兜底前缀 location,会拦截 `/themes/xxx/static/main.js` 去本地 root 找文件导致 404。模板静态资源由 Go(beego)直接提供。 +6. **`proxy_set_header Host $host;` 必须保留**。Go 完全依赖 Host 识别租户;若透传成 upstream 地址,所有请求都会被识别失败。 +7. **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` 校验再重载;宝塔后续"站点设置"的某些操作可能重写配置文件,改前建议备份。 +8. **域名发放数据必须精确**。`yz_system_tenant_domain.full_domain` 要与实际访问域名完全一致(不带协议、不带端口、不带路径),且 `status=1`、`delete_time` 为 NULL。 +9. **DNS 泛解析**。`*.yunzer.com.cn`、`*.dh2.fun` 需要配置泛解析 A 记录指向服务器,否则新发放的子域名无法访问(与 nginx 无关)。 +10. **HSTS 提醒**。配置了 `Strict-Transport-Security`,一旦浏览器记住,证书出问题期间该域名将无法用 HTTP 访问;测试阶段可先注释掉该行。 +11. **宝塔面板操作提示**。直接编辑站点配置文件后,用 `nginx -t` 校验再重载;宝塔后续"站点设置"的某些操作可能重写配置文件,改前建议备份。 --- @@ -441,7 +491,10 @@ 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 +# 3. 验证七牛代理:应返回图片内容或 200/304,而不是 Go 的 404 页面 +curl -I https://yunzer.com.cn/qiniu/2026/06/02/1780371759517804300.png + +# 4. 浏览器访问 https://ceshi.yunzer.com.cn ``` ### 6.2 常见现象对照表 diff --git a/go/controllers/backend_article.go b/go/controllers/backend_article.go index 2f464ee..4f54cd2 100644 --- a/go/controllers/backend_article.go +++ b/go/controllers/backend_article.go @@ -88,12 +88,6 @@ func cmsEnsureTables(c *beego.Controller) bool { _ = c.ServeJSON() return false } - if err := models.EnsureCmsArticleDefaultCategories(); err != nil { - c.Ctx.Output.SetStatus(500) - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "初始化文章分类失败: " + err.Error()} - _ = c.ServeJSON() - return false - } return true } diff --git a/go/controllers/backend_menu_front.go b/go/controllers/backend_menu_front.go index 9d41e02..5ee23f9 100644 --- a/go/controllers/backend_menu_front.go +++ b/go/controllers/backend_menu_front.go @@ -34,6 +34,12 @@ func (c *BackendMenuFrontController) checkAuth() (uint64, bool) { _ = c.ServeJSON() return 0, false } + // tenant_id=0 为全局默认菜单保留值,不允许任何接口以租户身份操作 + if claims.TenantId <= 0 { + c.Data["json"] = map[string]interface{}{"code": 401, "msg": "租户ID缺失"} + _ = c.ServeJSON() + return 0, false + } return uint64(claims.TenantId), true } @@ -43,11 +49,15 @@ func (c *BackendMenuFrontController) List() { return } + // 保证全局默认导航(tenant_id=0)齐全,缺失自动补齐 + models.EnsureGlobalDefaultFrontMenus() + + // 全局默认菜单(tenant_id=0,不可删改)+ 租户自定义菜单 var menus []models.BackendMenuFront _, err := models.Orm.QueryTable("yz_backend_menu_front"). - Filter("tenant_id", tid). + Filter("tenant_id__in", []uint64{0, tid}). Filter("delete_time__isnull", true). - OrderBy("sort"). + OrderBy("sort", "id"). All(&menus) if err != nil { @@ -151,4 +161,4 @@ func (c *BackendMenuFrontController) Delete() { c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} } _ = c.ServeJSON() -} \ No newline at end of file +} diff --git a/go/controllers/backend_product.go b/go/controllers/backend_product.go new file mode 100644 index 0000000..00db7fc --- /dev/null +++ b/go/controllers/backend_product.go @@ -0,0 +1,528 @@ +package controllers + +import ( + "encoding/json" + "io" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// BackendProductController CMS 产品管理 +type BackendProductController struct { + beego.Controller +} + +// BackendProductCategoryController CMS 产品分类管理 +type BackendProductCategoryController struct { + beego.Controller +} + +func (c *BackendProductController) cmsClaims() (*jwtutil.Claims, error) { + return cmsBackendClaims(&c.Controller) +} + +func (c *BackendProductCategoryController) cmsClaims() (*jwtutil.Claims, error) { + return cmsBackendClaims(&c.Controller) +} + +func (c *BackendProductController) cmsJSONErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *BackendProductCategoryController) cmsJSONErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func cmsEnsureProductTables(c *beego.Controller) bool { + if err := models.EnsureCmsProductTables(); err != nil { + c.Ctx.Output.SetStatus(500) + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "初始化产品表失败: " + err.Error()} + _ = c.ServeJSON() + return false + } + return true +} + +func cmsProductToMap(row models.CmsProduct) map[string]interface{} { + return map[string]interface{}{ + "id": row.ID, + "tid": row.Tid, + "title": row.Title, + "thumb": row.Thumb, + "desc": row.Desc, + "content": row.Content, + "url": row.URL, + "sort": row.Sort, + "status": row.Status, + "create_time": row.CreateTime.Format("2006-01-02 15:04:05"), + "update_time": models.CmsFormatTime(row.UpdateTime), + } +} + +func cmsProductCateToMap(row models.CmsProductCategory) map[string]interface{} { + return map[string]interface{}{ + "id": row.ID, + "tid": row.Tid, + "title": row.Title, + "pid": row.Pid, + "desc": row.Desc, + "sort": row.Sort, + "status": row.Status, + "create_time": row.CreateTime.Format("2006-01-02 15:04:05"), + "update_time": models.CmsFormatTime(row.UpdateTime), + } +} + +// List GET /backend/productsList +func (c *BackendProductController) List() { + if !cmsEnsureProductTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + page, _ := c.GetInt("page", 1) + limit, _ := c.GetInt("limit", 10) + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 10 + } + + keyword := strings.TrimSpace(c.GetString("keyword")) + qs := models.Orm.QueryTable(new(models.CmsProduct)). + Filter("tid", tid). + Filter("delete_time__isnull", true) + if keyword != "" { + qs = qs.Filter("title__icontains", keyword) + } + if status, err := c.GetInt("status", -1); err == nil && status >= 0 { + qs = qs.Filter("status", status) + } + + total, _ := qs.Count() + var rows []models.CmsProduct + offset := (page - 1) * limit + _, err = qs.OrderBy("sort", "-id").Limit(limit, offset).All(&rows) + if err != nil && err != orm.ErrNoRows { + c.cmsJSONErr(500, 500, "获取产品列表失败") + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, cmsProductToMap(r)) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{"list": list, "total": total}, + } + _ = c.ServeJSON() +} + +type cmsProductPayload struct { + Title string `json:"title"` + Thumb string `json:"thumb"` + Desc string `json:"desc"` + Content string `json:"content"` + URL string `json:"url"` + Sort int `json:"sort"` + Status int8 `json:"status"` +} + +// Create POST /backend/addProducts +func (c *BackendProductController) Create() { + if !cmsEnsureProductTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsProductPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + title := strings.TrimSpace(p.Title) + if title == "" { + c.cmsJSONErr(400, 400, "产品名称不能为空") + return + } + + now := time.Now() + row := models.CmsProduct{ + Tid: tid, + Title: title, + Thumb: strings.TrimSpace(p.Thumb), + Desc: strings.TrimSpace(p.Desc), + Content: p.Content, + URL: strings.TrimSpace(p.URL), + Sort: p.Sort, + Status: p.Status, + CreateTime: now, + UpdateTime: &now, + } + id, err := models.Orm.Insert(&row) + if err != nil { + c.cmsJSONErr(500, 500, "添加失败") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "添加成功", "data": map[string]interface{}{"id": id}} + _ = c.ServeJSON() +} + +// Update PUT /backend/editProducts/:id +func (c *BackendProductController) Update() { + if !cmsEnsureProductTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsProductPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + title := strings.TrimSpace(p.Title) + if title == "" { + c.cmsJSONErr(400, 400, "产品名称不能为空") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsProduct)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(orm.Params{ + "title": title, + "thumb": strings.TrimSpace(p.Thumb), + "desc": strings.TrimSpace(p.Desc), + "content": p.Content, + "url": strings.TrimSpace(p.URL), + "sort": p.Sort, + "status": p.Status, + "update_time": now, + }) + if err != nil { + c.cmsJSONErr(500, 500, "更新失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "产品不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} + _ = c.ServeJSON() +} + +// Delete DELETE /backend/deleteProducts/:id +func (c *BackendProductController) Delete() { + if !cmsEnsureProductTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsProduct)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(orm.Params{"delete_time": now}) + if err != nil { + c.cmsJSONErr(500, 500, "删除失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "产品不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} + +// List GET /backend/productsTypesList +func (c *BackendProductCategoryController) List() { + if !cmsEnsureProductTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + page, _ := c.GetInt("page", 1) + limit, _ := c.GetInt("limit", 10) + if page < 1 { + page = 1 + } + if limit < 1 || limit > 1000 { + limit = 10 + } + + keyword := strings.TrimSpace(c.GetString("keyword")) + qs := models.Orm.QueryTable(new(models.CmsProductCategory)). + Filter("tid", tid). + Filter("delete_time__isnull", true) + if keyword != "" { + qs = qs.Filter("title__icontains", keyword) + } + + total, _ := qs.Count() + var rows []models.CmsProductCategory + offset := (page - 1) * limit + _, err = qs.OrderBy("sort", "id").Limit(limit, offset).All(&rows) + if err != nil && err != orm.ErrNoRows { + c.cmsJSONErr(500, 500, "获取产品分类列表失败") + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, cmsProductCateToMap(r)) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{"list": list, "total": total}, + } + _ = c.ServeJSON() +} + +type cmsProductCategoryPayload struct { + Title string `json:"title"` + Pid uint64 `json:"pid"` + Desc string `json:"desc"` + Sort int `json:"sort"` +} + +// Create POST /backend/addProductsTypes +func (c *BackendProductCategoryController) Create() { + if !cmsEnsureProductTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsProductCategoryPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + title := strings.TrimSpace(p.Title) + if title == "" { + c.cmsJSONErr(400, 400, "分类名称不能为空") + return + } + + now := time.Now() + row := models.CmsProductCategory{ + Tid: tid, + Title: title, + Pid: p.Pid, + Desc: strings.TrimSpace(p.Desc), + Sort: p.Sort, + Status: 1, + CreateTime: now, + UpdateTime: &now, + } + id, err := models.Orm.Insert(&row) + if err != nil { + c.cmsJSONErr(500, 500, "添加失败") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "添加成功", "data": map[string]interface{}{"id": id}} + _ = c.ServeJSON() +} + +// Update PUT /backend/editProductsTypes/:id +func (c *BackendProductCategoryController) Update() { + if !cmsEnsureProductTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsProductCategoryPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + title := strings.TrimSpace(p.Title) + if title == "" { + c.cmsJSONErr(400, 400, "分类名称不能为空") + return + } + if p.Pid == id { + c.cmsJSONErr(400, 400, "父级分类不能是自己") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsProductCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(orm.Params{ + "title": title, + "pid": p.Pid, + "desc": strings.TrimSpace(p.Desc), + "sort": p.Sort, + "update_time": now, + }) + if err != nil { + c.cmsJSONErr(500, 500, "更新失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "分类不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} + _ = c.ServeJSON() +} + +// Delete DELETE /backend/deleteProductsTypes/:id +func (c *BackendProductCategoryController) Delete() { + if !cmsEnsureProductTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + // 存在子分类时不允许删除 + childCnt, _ := models.Orm.QueryTable(new(models.CmsProductCategory)). + Filter("tid", tid). + Filter("pid", id). + Filter("delete_time__isnull", true). + Count() + if childCnt > 0 { + c.cmsJSONErr(400, 400, "该分类下存在子分类,请先删除子分类") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsProductCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(orm.Params{"delete_time": now}) + if err != nil { + c.cmsJSONErr(500, 500, "删除失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "分类不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} diff --git a/go/controllers/backend_solution.go b/go/controllers/backend_solution.go new file mode 100644 index 0000000..cda1597 --- /dev/null +++ b/go/controllers/backend_solution.go @@ -0,0 +1,524 @@ +package controllers + +import ( + "encoding/json" + "io" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// BackendSolutionController CMS 解决方案(特色服务)管理 +type BackendSolutionController struct { + beego.Controller +} + +// BackendSolutionCategoryController CMS 解决方案分类管理 +type BackendSolutionCategoryController struct { + beego.Controller +} + +func (c *BackendSolutionController) cmsClaims() (*jwtutil.Claims, error) { + return cmsBackendClaims(&c.Controller) +} + +func (c *BackendSolutionCategoryController) cmsClaims() (*jwtutil.Claims, error) { + return cmsBackendClaims(&c.Controller) +} + +func (c *BackendSolutionController) cmsJSONErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *BackendSolutionCategoryController) cmsJSONErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func cmsEnsureSolutionTables(c *beego.Controller) bool { + if err := models.EnsureCmsSolutionTables(); err != nil { + c.Ctx.Output.SetStatus(500) + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "初始化解决方案表失败: " + err.Error()} + _ = c.ServeJSON() + return false + } + return true +} + +func cmsSolutionToMap(row models.CmsSolution) map[string]interface{} { + return map[string]interface{}{ + "id": row.ID, + "tid": row.Tid, + "title": row.Title, + "thumb": row.Thumb, + "desc": row.Desc, + "url": row.URL, + "sort": row.Sort, + "status": row.Status, + "create_time": row.CreateTime.Format("2006-01-02 15:04:05"), + "update_time": models.CmsFormatTime(row.UpdateTime), + } +} + +func cmsSolutionCateToMap(row models.CmsSolutionCategory) map[string]interface{} { + return map[string]interface{}{ + "id": row.ID, + "tid": row.Tid, + "title": row.Title, + "pid": row.Pid, + "desc": row.Desc, + "sort": row.Sort, + "status": row.Status, + "create_time": row.CreateTime.Format("2006-01-02 15:04:05"), + "update_time": models.CmsFormatTime(row.UpdateTime), + } +} + +// List GET /backend/servicesList +func (c *BackendSolutionController) List() { + if !cmsEnsureSolutionTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + page, _ := c.GetInt("page", 1) + limit, _ := c.GetInt("limit", 10) + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 10 + } + + keyword := strings.TrimSpace(c.GetString("keyword")) + qs := models.Orm.QueryTable(new(models.CmsSolution)). + Filter("tid", tid). + Filter("delete_time__isnull", true) + if keyword != "" { + qs = qs.Filter("title__icontains", keyword) + } + if status, err := c.GetInt("status", -1); err == nil && status >= 0 { + qs = qs.Filter("status", status) + } + + total, _ := qs.Count() + var rows []models.CmsSolution + offset := (page - 1) * limit + _, err = qs.OrderBy("sort", "-id").Limit(limit, offset).All(&rows) + if err != nil && err != orm.ErrNoRows { + c.cmsJSONErr(500, 500, "获取解决方案列表失败") + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, cmsSolutionToMap(r)) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{"list": list, "total": total}, + } + _ = c.ServeJSON() +} + +type cmsSolutionPayload struct { + Title string `json:"title"` + Thumb string `json:"thumb"` + Desc string `json:"desc"` + URL string `json:"url"` + Sort int `json:"sort"` + Status int8 `json:"status"` +} + +// Create POST /backend/addServices +func (c *BackendSolutionController) Create() { + if !cmsEnsureSolutionTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsSolutionPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + title := strings.TrimSpace(p.Title) + if title == "" { + c.cmsJSONErr(400, 400, "名称不能为空") + return + } + + now := time.Now() + row := models.CmsSolution{ + Tid: tid, + Title: title, + Thumb: strings.TrimSpace(p.Thumb), + Desc: strings.TrimSpace(p.Desc), + URL: strings.TrimSpace(p.URL), + Sort: p.Sort, + Status: p.Status, + CreateTime: now, + UpdateTime: &now, + } + id, err := models.Orm.Insert(&row) + if err != nil { + c.cmsJSONErr(500, 500, "添加失败") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "添加成功", "data": map[string]interface{}{"id": id}} + _ = c.ServeJSON() +} + +// Update PUT /backend/editServices/:id +func (c *BackendSolutionController) Update() { + if !cmsEnsureSolutionTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsSolutionPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + title := strings.TrimSpace(p.Title) + if title == "" { + c.cmsJSONErr(400, 400, "名称不能为空") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsSolution)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(orm.Params{ + "title": title, + "thumb": strings.TrimSpace(p.Thumb), + "desc": strings.TrimSpace(p.Desc), + "url": strings.TrimSpace(p.URL), + "sort": p.Sort, + "status": p.Status, + "update_time": now, + }) + if err != nil { + c.cmsJSONErr(500, 500, "更新失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "记录不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} + _ = c.ServeJSON() +} + +// Delete DELETE /backend/deleteServices/:id +func (c *BackendSolutionController) Delete() { + if !cmsEnsureSolutionTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsSolution)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(orm.Params{"delete_time": now}) + if err != nil { + c.cmsJSONErr(500, 500, "删除失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "记录不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} + +// List GET /backend/servicesTypesList +func (c *BackendSolutionCategoryController) List() { + if !cmsEnsureSolutionTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + page, _ := c.GetInt("page", 1) + limit, _ := c.GetInt("limit", 10) + if page < 1 { + page = 1 + } + if limit < 1 || limit > 1000 { + limit = 10 + } + + keyword := strings.TrimSpace(c.GetString("keyword")) + qs := models.Orm.QueryTable(new(models.CmsSolutionCategory)). + Filter("tid", tid). + Filter("delete_time__isnull", true) + if keyword != "" { + qs = qs.Filter("title__icontains", keyword) + } + + total, _ := qs.Count() + var rows []models.CmsSolutionCategory + offset := (page - 1) * limit + _, err = qs.OrderBy("sort", "id").Limit(limit, offset).All(&rows) + if err != nil && err != orm.ErrNoRows { + c.cmsJSONErr(500, 500, "获取解决方案分类列表失败") + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, cmsSolutionCateToMap(r)) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{"list": list, "total": total}, + } + _ = c.ServeJSON() +} + +type cmsSolutionCategoryPayload struct { + Title string `json:"title"` + Pid uint64 `json:"pid"` + Desc string `json:"desc"` + Sort int `json:"sort"` +} + +// Create POST /backend/addServicesTypes +func (c *BackendSolutionCategoryController) Create() { + if !cmsEnsureSolutionTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + if tid == 0 { + c.cmsJSONErr(400, 400, "tid不能为空") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsSolutionCategoryPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + title := strings.TrimSpace(p.Title) + if title == "" { + c.cmsJSONErr(400, 400, "分类名称不能为空") + return + } + + now := time.Now() + row := models.CmsSolutionCategory{ + Tid: tid, + Title: title, + Pid: p.Pid, + Desc: strings.TrimSpace(p.Desc), + Sort: p.Sort, + Status: 1, + CreateTime: now, + UpdateTime: &now, + } + id, err := models.Orm.Insert(&row) + if err != nil { + c.cmsJSONErr(500, 500, "添加失败") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "添加成功", "data": map[string]interface{}{"id": id}} + _ = c.ServeJSON() +} + +// Update PUT /backend/editServicesTypes/:id +func (c *BackendSolutionCategoryController) Update() { + if !cmsEnsureSolutionTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + var p cmsSolutionCategoryPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.cmsJSONErr(400, 400, "参数错误") + return + } + title := strings.TrimSpace(p.Title) + if title == "" { + c.cmsJSONErr(400, 400, "分类名称不能为空") + return + } + if p.Pid == id { + c.cmsJSONErr(400, 400, "父级分类不能是自己") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsSolutionCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(orm.Params{ + "title": title, + "pid": p.Pid, + "desc": strings.TrimSpace(p.Desc), + "sort": p.Sort, + "update_time": now, + }) + if err != nil { + c.cmsJSONErr(500, 500, "更新失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "分类不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "更新成功"} + _ = c.ServeJSON() +} + +// Delete DELETE /backend/deleteServicesTypes/:id +func (c *BackendSolutionCategoryController) Delete() { + if !cmsEnsureSolutionTables(&c.Controller) { + return + } + claims, err := c.cmsClaims() + if err != nil { + c.cmsJSONErr(401, 401, err.Error()) + return + } + tid := cmsEffectiveTid(&c.Controller, claims) + id, _ := c.GetUint64(":id") + if id == 0 { + c.cmsJSONErr(400, 400, "无效ID") + return + } + + // 存在子分类时不允许删除 + childCnt, _ := models.Orm.QueryTable(new(models.CmsSolutionCategory)). + Filter("tid", tid). + Filter("pid", id). + Filter("delete_time__isnull", true). + Count() + if childCnt > 0 { + c.cmsJSONErr(400, 400, "该分类下存在子分类,请先删除子分类") + return + } + + now := time.Now() + n, err := models.Orm.QueryTable(new(models.CmsSolutionCategory)). + Filter("id", id). + Filter("tid", tid). + Filter("delete_time__isnull", true). + Update(orm.Params{"delete_time": now}) + if err != nil { + c.cmsJSONErr(500, 500, "删除失败") + return + } + if n == 0 { + c.cmsJSONErr(404, 404, "分类不存在") + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} diff --git a/go/controllers/tenant_site.go b/go/controllers/tenant_site.go index aee1d70..692acc9 100644 --- a/go/controllers/tenant_site.go +++ b/go/controllers/tenant_site.go @@ -120,32 +120,43 @@ 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(c.forceHTTPSStorageURLs(html)) + c.Ctx.WriteString(c.rewriteStorageURLs(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 - } +var legacyQiniuDomains = []string{ + "7colud.yunzer.cn", + "7cloud.yunzer.cn", +} + +// rewriteStorageURLs 将历史七牛域名替换为当前存储配置的访问域名。 +// 这样已存入数据库的旧完整 URL 会跟随新的 CDN 或 /qiniu/ 反向代理入口, +// 不需要批量更新业务数据。 +func (c *TenantSiteController) rewriteStorageURLs(html string) string { 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 rewriteLegacyQiniuURLs(html, storagePublicBaseURL(cfg.QiniuDomain)) +} + +func storagePublicBaseURL(domain string) string { + base := strings.TrimRight(strings.TrimSpace(domain), "/") + if base == "" || strings.HasPrefix(base, "/") || strings.HasPrefix(base, "http://") || strings.HasPrefix(base, "https://") { + return base + } + return "https://" + base +} + +func rewriteLegacyQiniuURLs(html, targetBase string) string { + if targetBase == "" { return html } - return strings.ReplaceAll(html, "http://"+host, "https://"+host) + for _, domain := range legacyQiniuDomains { + for _, scheme := range []string{"http://", "https://"} { + html = strings.ReplaceAll(html, scheme+domain+"/", targetBase+"/") + } + } + return html } // rewriteThemeAssets 将模板内书写的相对资源路径(href/src/url() 等) @@ -211,6 +222,11 @@ func rewriteThemeAssets(html, code string) string { // render 解析并输出模板文件 func (c *TenantSiteController) render(file string, mod func(ctx *tagengine.RenderCtx)) { + c.renderFile(file, "", mod) +} + +// renderFile 解析并输出模板;preferred 不存在时尝试 fallback(fallback 为空则直接 404) +func (c *TenantSiteController) renderFile(preferred, fallback string, mod func(ctx *tagengine.RenderCtx)) { tid, ok := c.resolveTid() if !ok { c.writeHTML(404, siteNotFoundPage) @@ -219,6 +235,12 @@ func (c *TenantSiteController) render(file string, mod func(ctx *tagengine.Rende code := c.resolveTemplateCode(tid) dir := filepath.Join(cmsThemesRoot(), code) + file := preferred + if _, err := os.Stat(filepath.Join(dir, file)); err != nil { + if fallback != "" { + file = fallback + } + } if _, err := os.Stat(filepath.Join(dir, file)); err != nil { c.writeHTML(404, fmt.Sprintf(renderErrorPage, "模板页面不存在")) return @@ -271,7 +293,7 @@ func (c *TenantSiteController) Page() { c.writeHTML(404, fmt.Sprintf(renderErrorPage, "页面不存在")) return } - c.render("page.html", func(ctx *tagengine.RenderCtx) { + c.renderFile(path+".html", "page.html", func(ctx *tagengine.RenderCtx) { ctx.PagePath = path }) } diff --git a/go/models/backend_menu_front.go b/go/models/backend_menu_front.go index 8de1077..3208a51 100644 --- a/go/models/backend_menu_front.go +++ b/go/models/backend_menu_front.go @@ -30,4 +30,4 @@ func (m *BackendMenuFront) TableName() string { func init() { orm.RegisterModel(new(BackendMenuFront)) -} \ No newline at end of file +} diff --git a/go/models/cms_article.go b/go/models/cms_article.go index ab85384..15eb4d5 100644 --- a/go/models/cms_article.go +++ b/go/models/cms_article.go @@ -109,47 +109,6 @@ CREATE TABLE IF NOT EXISTS yz_cms_article ( return err } -func EnsureCmsArticleDefaultCategories() error { - // 只初始化两级全局分类:文章中心(顶级)和新闻中心(文章中心的子分类)。 - // 不在启动时创建其它业务分类,后续分类由管理员按需新增。 - defaults := []struct { - name string - cid uint64 - sort int - }{ - {name: "文章中心", cid: 0, sort: 1}, - {name: "新闻中心", cid: 1, sort: 2}, - } - - for _, item := range defaults { - count, err := Orm.QueryTable(new(CmsArticleCategory)). - Filter("tid", 0). - Filter("name", item.name). - Filter("cid", item.cid). - Filter("delete_time__isnull", true). - Count() - if err != nil { - return err - } - if count > 0 { - continue - } - now := time.Now() - _, err = Orm.Insert(&CmsArticleCategory{ - Tid: 0, - Cid: item.cid, - Name: item.name, - Sort: item.sort, - Status: 1, - CreateTime: now, - }) - if err != nil { - return err - } - } - return nil -} - func CmsCategoryNameMap(tid uint64, ids []uint64) map[uint64]string { out := make(map[uint64]string) if len(ids) == 0 { diff --git a/go/models/cms_product.go b/go/models/cms_product.go new file mode 100644 index 0000000..e779177 --- /dev/null +++ b/go/models/cms_product.go @@ -0,0 +1,89 @@ +package models + +import ( + "sync" + "time" +) + +// CmsProduct CMS 产品 yz_cms_product +type CmsProduct struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid);default(0)" json:"tid"` + Title string `orm:"column(title);size(100)" json:"title"` + Thumb string `orm:"column(thumb);size(500);default()" json:"thumb"` + Desc string `orm:"column(desc);size(500);default()" json:"desc"` + Content string `orm:"column(content);type(mediumtext);null" json:"content"` + URL string `orm:"column(url);size(500);default()" json:"url"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *CmsProduct) TableName() string { + return "yz_cms_product" +} + +// CmsProductCategory CMS 产品分类 yz_cms_product_category +type CmsProductCategory struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid);default(0)" json:"tid"` + Title string `orm:"column(title);size(100)" json:"title"` + Pid uint64 `orm:"column(pid);default(0)" json:"pid"` + Desc string `orm:"column(desc);size(500);default()" json:"desc"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *CmsProductCategory) TableName() string { + return "yz_cms_product_category" +} + +var cmsProductTablesOnce sync.Once + +// EnsureCmsProductTables 首次使用时自动建表(若不存在)。 +func EnsureCmsProductTables() error { + var err error + cmsProductTablesOnce.Do(func() { + _, err = Orm.Raw(` +CREATE TABLE IF NOT EXISTS yz_cms_product ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + tid bigint unsigned NOT NULL DEFAULT 0, + title varchar(100) NOT NULL DEFAULT '', + thumb varchar(500) NOT NULL DEFAULT '', + ` + "`desc`" + ` varchar(500) NOT NULL DEFAULT '', + content mediumtext, + url varchar(500) NOT NULL DEFAULT '', + sort int NOT NULL DEFAULT 0, + status tinyint NOT NULL DEFAULT 1, + create_time datetime NOT NULL, + update_time datetime DEFAULT NULL, + delete_time datetime DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_tid_status (tid, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`).Exec() + if err != nil { + return + } + _, err = Orm.Raw(` +CREATE TABLE IF NOT EXISTS yz_cms_product_category ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + tid bigint unsigned NOT NULL DEFAULT 0, + title varchar(100) NOT NULL DEFAULT '', + pid bigint unsigned NOT NULL DEFAULT 0, + ` + "`desc`" + ` varchar(500) NOT NULL DEFAULT '', + sort int NOT NULL DEFAULT 0, + status tinyint NOT NULL DEFAULT 1, + create_time datetime NOT NULL, + update_time datetime DEFAULT NULL, + delete_time datetime DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_tid_pid (tid, pid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`).Exec() + }) + return err +} diff --git a/go/models/cms_solution.go b/go/models/cms_solution.go new file mode 100644 index 0000000..9dfb4df --- /dev/null +++ b/go/models/cms_solution.go @@ -0,0 +1,87 @@ +package models + +import ( + "sync" + "time" +) + +// CmsSolution CMS 解决方案(特色服务) yz_cms_solution +type CmsSolution struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid);default(0)" json:"tid"` + Title string `orm:"column(title);size(100)" json:"title"` + Thumb string `orm:"column(thumb);size(500);default()" json:"thumb"` + Desc string `orm:"column(desc);size(500);default()" json:"desc"` + URL string `orm:"column(url);size(500);default()" json:"url"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *CmsSolution) TableName() string { + return "yz_cms_solution" +} + +// CmsSolutionCategory CMS 解决方案分类 yz_cms_solution_category +type CmsSolutionCategory struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid);default(0)" json:"tid"` + Title string `orm:"column(title);size(100)" json:"title"` + Pid uint64 `orm:"column(pid);default(0)" json:"pid"` + Desc string `orm:"column(desc);size(500);default()" json:"desc"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *CmsSolutionCategory) TableName() string { + return "yz_cms_solution_category" +} + +var cmsSolutionTablesOnce sync.Once + +// EnsureCmsSolutionTables 首次使用时自动建表(若不存在)。 +func EnsureCmsSolutionTables() error { + var err error + cmsSolutionTablesOnce.Do(func() { + _, err = Orm.Raw(` +CREATE TABLE IF NOT EXISTS yz_cms_solution ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + tid bigint unsigned NOT NULL DEFAULT 0, + title varchar(100) NOT NULL DEFAULT '', + thumb varchar(500) NOT NULL DEFAULT '', + ` + "`desc`" + ` varchar(500) NOT NULL DEFAULT '', + url varchar(500) NOT NULL DEFAULT '', + sort int NOT NULL DEFAULT 0, + status tinyint NOT NULL DEFAULT 1, + create_time datetime NOT NULL, + update_time datetime DEFAULT NULL, + delete_time datetime DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_tid_status (tid, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`).Exec() + if err != nil { + return + } + _, err = Orm.Raw(` +CREATE TABLE IF NOT EXISTS yz_cms_solution_category ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + tid bigint unsigned NOT NULL DEFAULT 0, + title varchar(100) NOT NULL DEFAULT '', + pid bigint unsigned NOT NULL DEFAULT 0, + ` + "`desc`" + ` varchar(500) NOT NULL DEFAULT '', + sort int NOT NULL DEFAULT 0, + status tinyint NOT NULL DEFAULT 1, + create_time datetime NOT NULL, + update_time datetime DEFAULT NULL, + delete_time datetime DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_tid_pid (tid, pid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`).Exec() + }) + return err +} diff --git a/go/models/front_menu_default.go b/go/models/front_menu_default.go new file mode 100644 index 0000000..31279c4 --- /dev/null +++ b/go/models/front_menu_default.go @@ -0,0 +1,77 @@ +package models + +import "time" + +// FrontMenuDefault 全局默认前端导航项定义 +type FrontMenuDefault struct { + Title string + Path string + Type int8 // 菜单类型:2 页面 / 4 单页 + Sort int +} + +// FrontMenuDefaults 所有租户共用的默认导航(不可删除)。 +// 以 tenant_id=0 存放于同一张菜单表,表示全局共享: +// 租户端增删改接口都带 tenant_id 过滤,天然无法触碰这些行。 +// 路径对应官网前台路由:新闻中心 /news,其余为单页 /page/:path +// (单页内容在 backend 端 - 单页管理 中按 path 维护)。 +var FrontMenuDefaults = []FrontMenuDefault{ + {Title: "新闻中心", Path: "/news", Type: 2, Sort: 1}, + {Title: "产品展示", Path: "/page/products", Type: 4, Sort: 2}, + {Title: "解决方案", Path: "/page/solutions", Type: 4, Sort: 3}, + {Title: "关于我们", Path: "/page/about", Type: 4, Sort: 4}, + {Title: "联系我们", Path: "/page/contact", Type: 4, Sort: 5}, +} + +// EnsureGlobalDefaultFrontMenus 保证全局默认导航(tenant_id=0)齐全: +// 缺失的自动新增;被误删(软删)的自动恢复;已存在(含改名)的不动。 +// 以"在库生效数量 >= 默认项总数"作为短路条件,避免改名后重复插入。 +// 供后台菜单列表与前台导航渲染前调用,任何失败均静默忽略不阻断主流程。 +func EnsureGlobalDefaultFrontMenus() { + if Orm == nil { + return + } + qs := Orm.QueryTable(new(BackendMenuFront)).Filter("tenant_id", 0) + + activeCnt, err := qs.Filter("delete_time__isnull", true).Count() + if err != nil || activeCnt >= int64(len(FrontMenuDefaults)) { + return + } + + // 含软删记录一起取,用于恢复被误删的默认菜单 + var rows []BackendMenuFront + if _, err := qs.All(&rows); err != nil { + return + } + exist := make(map[string]*BackendMenuFront, len(rows)) + for i := range rows { + exist[rows[i].Title] = &rows[i] + } + + now := time.Now() + for _, def := range FrontMenuDefaults { + if row, ok := exist[def.Title]; ok { + if row.DeleteTime != nil { + _, _ = Orm.QueryTable(new(BackendMenuFront)). + Filter("id", row.ID). + Update(map[string]interface{}{ + "delete_time": nil, + "status": 1, + "is_visible": 1, + "update_time": now, + }) + } + continue + } + _, _ = Orm.Insert(&BackendMenuFront{ + TenantID: 0, + Pid: 0, + Title: def.Title, + Path: def.Path, + Sort: def.Sort, + Status: 1, + IsVisible: 1, + Type: def.Type, + }) + } +} diff --git a/go/pkg/tagengine/engine.go b/go/pkg/tagengine/engine.go index 5932e83..851b035 100644 --- a/go/pkg/tagengine/engine.go +++ b/go/pkg/tagengine/engine.go @@ -26,8 +26,12 @@ type SelfCloseHandler func(tid uint64, params map[string]string, ctx *RenderCtx) // GlobalProvider 全局标签提供器:返回 标签名->值 映射 type GlobalProvider func(tid uint64, ctx *RenderCtx) (map[string]string, error) +// BodyProvider 带循环体的块标签:自行组装 HTML(如带 Tab 的新闻中心) +type BodyProvider func(tid uint64, params map[string]string, ctx *RenderCtx, body string) (string, error) + var ( - blockProviders = map[string]Provider{} + blockProviders = map[string]Provider{} + bodyProviders = map[string]BodyProvider{} selfCloseHandlers = map[string]SelfCloseHandler{} globalProvider GlobalProvider ) @@ -35,6 +39,9 @@ var ( // RegisterBlock 注册循环标签({yz:name}...{/yz:name}) func RegisterBlock(name string, p Provider) { blockProviders[name] = p } +// RegisterBodyBlock 注册自行组装循环体的块标签 +func RegisterBodyBlock(name string, p BodyProvider) { bodyProviders[name] = p } + // RegisterSelfClose 注册自闭合标签({yz:name ... /}) func RegisterSelfClose(name string, h SelfCloseHandler) { selfCloseHandlers[name] = h } @@ -67,6 +74,13 @@ func parseAttrs(attrStr string) map[string]string { return out } +func applyFields(body string, row map[string]string) string { + return reField.ReplaceAllStringFunc(body, func(fm string) string { + field := reField.FindStringSubmatch(fm)[1] + return row[field] + }) +} + // Render 渲染模板目录下的指定文件 // 流水线:include 展开 → 块标签 → 自闭合标签 → 全局标签 func Render(templateDir, file string, ctx *RenderCtx) (string, error) { @@ -152,18 +166,21 @@ func renderBlocks(content string, ctx *RenderCtx) (string, error) { } rendered := "" - if p, ok := blockProviders[closeName]; ok { + body := out[openEnd:c[0]] + if bp, ok := bodyProviders[closeName]; ok { + s, err := bp(ctx.Tid, parseAttrs(attrs), ctx, body) + if err != nil { + return "", err + } + rendered = s + } else if p, ok := blockProviders[closeName]; ok { rows, err := p(ctx.Tid, parseAttrs(attrs), ctx) if err != nil { return "", err } - body := out[openEnd:c[0]] var sb strings.Builder for _, row := range rows { - sb.WriteString(reField.ReplaceAllStringFunc(body, func(fm string) string { - field := reField.FindStringSubmatch(fm)[1] - return row[field] - })) + sb.WriteString(applyFields(body, row)) } rendered = sb.String() } diff --git a/go/pkg/tagengine/meta.go b/go/pkg/tagengine/meta.go index a25de4f..1e000fd 100644 --- a/go/pkg/tagengine/meta.go +++ b/go/pkg/tagengine/meta.go @@ -4,11 +4,11 @@ package tagengine // 新增标签时在此登记一条,文档页自动同步,避免文档与实现脱节。 type TagMeta struct { Name string `json:"name"` // 标签名 - Category string `json:"category"` // 分类:global 全局 / loop 循环 / single 单页 / other 其他 + Category string `json:"category"` // 分类:guide 入门 / global 全局 / loop 循环 / single 单页 / other 其他 Syntax string `json:"syntax"` // 语法示例 Desc string `json:"desc"` // 用途说明 Params []string `json:"params"` // 可用参数(key=说明) - Fields []string `json:"fields"` // 循环体内可用 [field:xxx/] 字段 + Fields []string `json:"fields"` // 循环体内可用 [field:xxx/] 字段;global 分类下为标签清单(名称 说明 数据来源) Example string `json:"example"` // 模板内示例代码 } @@ -16,31 +16,68 @@ type TagMeta struct { func TagDocs() []TagMeta { return []TagMeta{ { - Name: "全局站点信息", - Category: "global", - Syntax: "{yz:sitename} {yz:logo} {yz:logow} {yz:ico} {yz:icp} {yz:copyright} {yz:companyname} {yz:description} {yz:companyintroduction}", - Desc: "取自当前租户的站点设置(backend 端 - 站点设置 - 基本信息),直接替换为对应文本/地址,无需闭合。logo 彩色Logo / logow 白色Logo / ico 站点图标 / companyintroduction 企业介绍(富文本)。", + Name: "基本用法", + Category: "guide", + Syntax: "{yz:标签} / {yz:标签 参数=\"值\"/} / {yz:标签}...[field:字段/]...{/yz:标签}", + Desc: "模板中用 {yz:xxx} 调用数据。三种形态:① 全局标签直接替换为文本,无需闭合;② 自闭合标签带参数、以 /} 结尾;③ 循环标签成对出现,循环体内用 [field:字段/] 输出每一行数据的字段。未配置的项输出空字符串,不会报错。", Params: nil, Fields: nil, - Example: "{yz:sitename}\n\"{yz:sitename}\"\n

{yz:icp} {yz:copyright}

", + Example: "\n

{yz:sitename}

\n\n\n{yz:onepage path=\"about\" field=\"content\"/}\n\n\n{yz:arclist row=\"6\"}\n
  • [field:title/]
  • \n{/yz:arclist}", }, { - Name: "全局联系方式", - Category: "global", - Syntax: "{yz:phone} {yz:email} {yz:address} {yz:worktime}", - Desc: "取自租户的公司信息(backend 端 - 站点设置 - 公司信息),直接替换为文本,无需闭合。phone 联系电话(别名 {yz:tel} / {yz:mobile}) / email 电子邮箱 / address 公司地址 / worktime 工作时间。", + Name: "模板文件结构", + Category: "guide", + Syntax: "themes/{模板编码}/", + Desc: "模板存放在服务器 themes/{编码}/ 目录,按请求域名识别租户后用对应模板渲染。路由与文件的对应关系:首页 / → index.html;新闻列表 /news → news.html;文章详情 /news/:id → news_detail.html;单页 /page/:path → page.html。公共头部/底部建议拆成 header.html / footer.html,用 {yz:include/} 引入。模板内的相对资源路径(assets/css/xxx.css、style.css 等)渲染时会自动改写为 /themes/{编码}/ 绝对路径,无需手工改。", Params: nil, Fields: nil, - Example: "{yz:phone}\n{yz:email}\n

    {yz:address}

    \n

    工作时间:{yz:worktime}

    ", + Example: "themes/business/\n├── index.html ← 首页 /\n├── news.html ← 新闻列表 /news?page=N\n├── news_detail.html ← 文章详情 /news/:id\n├── page.html ← 单页 /page/about\n├── header.html ← 公共头部(include 引入)\n├── footer.html ← 公共底部(include 引入)\n└── assets/ ← 模板自带的 css/js/图片", }, { - Name: "全局 SEO 信息", + Name: "站点基本信息", Category: "global", - Syntax: "{yz:seo_title} {yz:keywords} {yz:seo_description}", - Desc: "取自租户的 SEO 设置(backend 端 - 站点设置 - SEO 设置),用于 head 区域的 meta 标签。seo_title 未填时建议回退用 {yz:sitename}。", + Syntax: "{yz:sitename}、{yz:logo}、{yz:ico} …直接写在模板任意位置", + Desc: "取自当前租户的站点设置(backend 端 - 站点设置 - 基本信息)。直接替换为文本或地址,无需闭合;未配置的项输出空字符串。点击行末复制按钮可复制标签。", Params: nil, - Fields: nil, - Example: "{yz:seo_title}\n\n", + Fields: []string{ + "sitename 站点名称,常用于 和页头 来源:基本信息-站点名称", + "logo 彩色 Logo 图片地址,适合浅色背景 来源:基本信息-站点LOGO", + "logow 白色 Logo 图片地址,适合深色背景/页脚 来源:基本信息-白色LOGO", + "ico 站点图标地址,用于 <link rel=\"icon\"> 来源:基本信息-站点图标", + "companyname 公司全称 来源:基本信息-公司名称", + "companyintroduction 企业介绍(富文本HTML,直接用 div 承接) 来源:基本信息-企业介绍", + "description 站点简介,可用于 meta description 来源:基本信息-站点描述", + "copyright 版权信息,常用于页脚 来源:基本信息-版权信息", + "icp ICP 备案号,常用于页脚 来源:基本信息-ICP备案", + }, + Example: "<head>\n <title>{yz:sitename}\n \n \n\n\"{yz:sitename}\"\n
    {yz:companyintroduction}
    \n
    \n

    {yz:copyright}

    \n

    {yz:icp}

    \n
    ", + }, + { + Name: "联系方式", + Category: "global", + Syntax: "{yz:phone}、{yz:email}、{yz:address}、{yz:worktime}", + Desc: "取自租户的公司信息(backend 端 - 公司信息),常用于页头联系栏和页脚。直接替换为文本,无需闭合。", + Params: nil, + Fields: []string{ + "phone 联系电话,别名 {yz:tel}、{yz:mobile} 三者等价 来源:公司信息-联系电话", + "email 电子邮箱,可配合 mailto: 使用 来源:公司信息-邮箱", + "address 公司地址 来源:公司信息-地址", + "worktime 工作时间 来源:公司信息-工作时间", + }, + Example: "\n服务热线:{yz:phone}\n{yz:email}\n\n\n

    地址:{yz:address}

    \n

    工作时间:{yz:worktime}

    ", + }, + { + Name: "SEO 信息", + Category: "global", + Syntax: "{yz:seotitle}、{yz:keywords}、{yz:seodescription}", + Desc: "取自租户的 SEO 设置(backend 端 - SEO 设置),用于 区域。seotitle 未填时输出为空,建议模板里用 {yz:sitename} 兜底。", + Params: nil, + Fields: []string{ + "seotitle SEO 标题,未填时建议回退 {yz:sitename} 来源:SEO设置-SEO标题", + "keywords SEO 关键词,用于 meta keywords 来源:SEO设置-关键词", + "seodescription SEO 描述,用于 meta description 来源:SEO设置-描述", + }, + Example: "{yz:seotitle}\n\n", }, { Name: "nav 导航菜单", @@ -69,6 +106,15 @@ func TagDocs() []TagMeta { Fields: []string{"id 文章ID", "title 标题(受 titlelen 影响)", "titlefull 完整标题", "desc 摘要", "image 缩略图", "arcurl 详情页链接(/news/ID)", "pubdate 发布日期", "views 阅读量", "author 作者"}, Example: "{yz:arclist row=\"6\" titlelen=\"20\"}\n
  • [field:title/][field:pubdate/]
  • \n{/yz:arclist}", }, + { + Name: "newscenter 新闻中心(带Tab)", + Category: "loop", + Syntax: "{yz:newscenter row=\"8\" titlelen=\"30\"}...{/yz:newscenter}", + Desc: "首页新闻中心区块。循环体是单条新闻卡片。若「新闻中心」分类下有子分类,则按子分类输出 Tab 并可切换,每个 Tab 展示 row 条;没有子分类则不输出 Tab,直接列出 row 条(含新闻中心及其子类文章)。", + Params: []string{"row 每个分类输出条数,默认 8,最大 50", "titlelen 标题截断字数,不填为完整标题"}, + Fields: []string{"id 文章ID", "title 标题(受 titlelen 影响)", "titlefull 完整标题", "desc 摘要", "image 缩略图", "arcurl 详情页链接(/news/ID)", "pubdate 发布日期", "views 阅读量", "author 作者"}, + Example: "{yz:newscenter row=\"8\" titlelen=\"28\"}\n\n{/yz:newscenter}", + }, { Name: "arcview 文章详情", Category: "single", diff --git a/go/pkg/tagengine/provider.go b/go/pkg/tagengine/provider.go index 6073289..745df72 100644 --- a/go/pkg/tagengine/provider.go +++ b/go/pkg/tagengine/provider.go @@ -2,6 +2,7 @@ package tagengine import ( "fmt" + "html" "strconv" "strings" @@ -23,6 +24,7 @@ func init() { RegisterBlock("arclist", arclistProvider) RegisterBlock("arcview", arcviewProvider) RegisterBlock("friendlink", friendlinkProvider) + RegisterBodyBlock("newscenter", newscenterHandler) RegisterSelfClose("onepage", onepageHandler) RegisterSelfClose("pagelist", pagelistHandler) } @@ -61,7 +63,8 @@ func globalSiteInfo(tid uint64, ctx *RenderCtx) (map[string]string, error) { "copyright": "", "companyname": "", "description": "", "companyintroduction": "", "phone": "", "tel": "", "mobile": "", "email": "", "address": "", "worktime": "", - "keywords": "", "seo_title": "", "seo_description": "", + "keywords": "", "seotitle": "", "seodescription": "", + "aboutus": "/page/about", "contact": "/page/contact", "news": "/news", "team": "/page/team", } var row models.TenantSiteSetting err := models.Orm.QueryTable(new(models.TenantSiteSetting)). @@ -105,8 +108,8 @@ func globalSiteInfo(tid uint64, ctx *RenderCtx) (map[string]string, error) { // SEO 三项取自租户扩展设置表(backend 端 - 站点设置 - SEO 设置) if vals, err := queryTenantSettingItems(tid, "seoKeywords", "seoTitle", "seoDescription"); err == nil { out["keywords"] = vals["seoKeywords"] - out["seo_title"] = vals["seoTitle"] - out["seo_description"] = vals["seoDescription"] + out["seotitle"] = vals["seoTitle"] + out["seodescription"] = vals["seoDescription"] } return out, nil } @@ -136,11 +139,14 @@ func queryTenantSettingItems(tid uint64, keys ...string) (map[string]string, err return out, nil } -// navProvider 导航菜单:一级菜单循环,[field:children/] 输出子菜单 HTML +// navProvider 导航菜单:一级菜单循环,[field:children/] 输出子菜单 HTML。 +// 一级菜单含全局默认(tenant_id=0,不可删)与租户自定义;子菜单仅租户自有。 func navProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[string]string, error) { + models.EnsureGlobalDefaultFrontMenus() + var rows []models.BackendMenuFront _, err := models.Orm.QueryTable(new(models.BackendMenuFront)). - Filter("tenant_id", tid). + Filter("tenant_id__in", []uint64{0, tid}). Filter("pid", 0). Filter("delete_time__isnull", true). OrderBy("sort", "id"). @@ -169,7 +175,7 @@ func navProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[st childHTML := "" if subs, ok := childMap[int64(m.ID)]; ok && len(subs) > 0 { var sb strings.Builder - sb.WriteString(`
      `) + sb.WriteString(`