diff --git a/.gitignore b/.gitignore index aa7825e..66e6666 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,10 @@ backend/dist backend/dist.zip backend/output +# website +website/dist +website/dist.zip + # go go/go-platform.zip go/server.exe diff --git a/go/controllers/platform_website_setting.go b/go/controllers/platform_website_setting.go new file mode 100644 index 0000000..e3ef7dd --- /dev/null +++ b/go/controllers/platform_website_setting.go @@ -0,0 +1,107 @@ +package controllers + +import ( + "encoding/json" + "io" + "strings" + + "server/models" + + beego "github.com/beego/beego/v2/server/web" +) + +// PlatformWebsiteSettingController 平台官网站点设置(平台端专用) +// 对应前端 views/website/setting/index.vue: +// - GET /platform/website/setting/info +// - POST /platform/website/setting/save +// +// 数据落在 yz_platform_site_setting(通用键值结构:title/value/tag/remark)。 +// 该表完全数据驱动:表里插一行就是一个新的设置项,模板即可用 {yz:tag} 输出, +// platform 端界面也会自动出现该项,无需改动后端代码。 +type PlatformWebsiteSettingController struct { + beego.Controller +} + +func (c *PlatformWebsiteSettingController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]any{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +// Info GET /platform/website/setting/info +// 直接把表里所有设置项返回,前端按行渲染(新增设置项无需改代码): +// data.list = [{ id, title, value, tag, remark }] +// data.values = { "sitename": "云泽科技", ... }(key 即标签名,方便直接使用) +func (c *PlatformWebsiteSettingController) Info() { + if _, err := requirePlatform(&c.Controller); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + rows := models.GetPlatformSiteSettingRows() + list := make([]map[string]any, 0, len(rows)) + for _, r := range rows { + list = append(list, map[string]any{ + "id": r.ID, + "title": r.Title, + "value": r.Value, + "tag": r.Tag, + "remark": r.Remark, + }) + } + + c.Data["json"] = map[string]any{ + "code": 200, + "msg": "success", + "data": map[string]any{ + "list": list, + "values": models.GetPlatformSiteTagMap(), + }, + } + _ = c.ServeJSON() +} + +// Save POST /platform/website/setting/save +// 入参 { "sitename": "云泽科技", "subtitle": "...", ... },key 为标签名,按标签逐项 upsert。 +// 通用:表里已存在或新增的任意合法标签都可写入,不需要登记。 +func (c *PlatformWebsiteSettingController) Save() { + if _, err := requirePlatform(&c.Controller); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + var payload map[string]string + if uerr := json.Unmarshal(raw, &payload); uerr != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if len(payload) == 0 { + c.jsonErr(400, 400, "参数错误") + return + } + + if strings.TrimSpace(payload["sitename"]) == "" { + c.jsonErr(400, 400, "站点名称不能为空") + return + } + + // 统一去掉首尾空格,避免模板里输出多余空白 + values := make(map[string]string, len(payload)) + for k, v := range payload { + values[k] = strings.TrimSpace(v) + } + + if err = models.SavePlatformSiteSettings(values); err != nil { + c.jsonErr(500, 500, "保存失败: "+err.Error()) + return + } + + c.Data["json"] = map[string]any{"code": 200, "msg": "保存成功"} + _ = c.ServeJSON() +} diff --git a/go/controllers/tenant_site.go b/go/controllers/tenant_site.go index 0100590..904cd06 100644 --- a/go/controllers/tenant_site.go +++ b/go/controllers/tenant_site.go @@ -291,6 +291,21 @@ func (c *TenantSiteController) NewsDetail() { }) } +// SiteSetting GET /site/setting 官网前台站点设置(公开接口,无需登录) +// 按访问域名识别租户,返回 标签名 -> 值,数据来自 yz_platform_site_setting,与模板标签同源。 +// 完全数据驱动:表里有什么 tag,这里就返回什么标签,新增设置项无需改代码。 +// 供 SPA 官网(website/)读取,使 SPA 与 themes 直出模板的站点信息保持一致。 +func (c *TenantSiteController) SiteSetting() { + out := map[string]string{} + if tid, ok := c.resolveTid(); ok { + for k, v := range tagengine.SiteGlobals(tid) { + out[k] = v + } + } + c.Data["json"] = map[string]any{"code": 200, "msg": "success", "data": out} + _ = c.ServeJSON() +} + // Page GET /page/:path 单页 func (c *TenantSiteController) Page() { path := strings.TrimSpace(c.GetString(":path")) diff --git a/go/docs/sql/create_platform_site_setting.sql b/go/docs/sql/create_platform_site_setting.sql new file mode 100644 index 0000000..fa0e9fc --- /dev/null +++ b/go/docs/sql/create_platform_site_setting.sql @@ -0,0 +1,28 @@ +-- 平台官网站点设置表 yz_platform_site_setting(通用键值结构) +-- 一行一个设置项:title 名称 / value 值 / tag 标签名 / remark 说明。 +-- 模板里用 {yz:tag} 输出对应 value;新增设置项只需插一行,无需加字段。 +-- 服务端启动时会自动建表并补齐默认项(models.EnsurePlatformSiteSettingTable)。 +-- 已登记的默认设置项: +-- sitename 站点名称 +-- subtitle 副标题 +-- logo 深色 Logo(浅色背景) +-- logow 浅色 Logo(深色背景) +-- icp ICP 备案号 +-- policeicp 公安备案号 +-- copyright 版权信息 +-- privacy 隐私政策链接 +-- terms 使用条款链接 + +CREATE TABLE IF NOT EXISTS `yz_platform_site_setting` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `title` varchar(128) NOT NULL DEFAULT '' COMMENT '设置项名称', + `value` text COMMENT '设置项的值', + `tag` varchar(64) NOT NULL DEFAULT '' COMMENT '标签名,模板中写作 {yz:tag}', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注/填写说明', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tag` (`tag`, `delete_time`), + KEY `idx_delete_time` (`delete_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台官网站点设置表'; diff --git a/go/docs/sql/init_platform_site_setting.sql b/go/docs/sql/init_platform_site_setting.sql new file mode 100644 index 0000000..1a083bb --- /dev/null +++ b/go/docs/sql/init_platform_site_setting.sql @@ -0,0 +1,67 @@ +-- 平台官网站点设置 - 初始化/调整数据(yz_platform_site_setting,通用键值结构) +-- +-- 前提:表已按 create_platform_site_setting.sql 建成通用结构(title/value/tag/remark)。 +-- +-- remark 可选标记(用 | 分隔,未识别片段作为说明文字展示): +-- group:分组名 决定 platform 端界面落在哪个 tab(缺省「基本设置」) +-- type:控件类型 text(默认) / textarea / image(图片上传) +-- +-- 当前分组:基本设置 / 公司信息 / 备案信息 / 政策条款 +-- value 为建议初始值,请按实际情况修改(备案号请替换为真实备案号)。 +-- +-- ============================================================ +-- PART 1:全新环境(表中无数据)—— 直接执行 +-- ============================================================ +INSERT INTO `yz_platform_site_setting` + (`title`, `value`, `tag`, `remark`, `create_time`, `update_time`) +VALUES + -- 基本设置 + ('站点名称', '云泽科技', 'sitename', '常用于 和页头 | group:基本设置', NOW(), NOW()), + ('副标题', 'YUNZER TECH', 'subtitle', '站点名称下方的补充说明,常用于页头/首页 | group:基本设置', NOW(), NOW()), + ('Logo(深色)', '', 'logo', '深色 Logo,用于浅色背景(如页头) | group:基本设置 | type:image', NOW(), NOW()), + ('Logo(浅色)', '', 'logow', '浅色 Logo,用于深色背景(如页脚) | group:基本设置 | type:image', NOW(), NOW()), + -- 公司信息 + ('公司名称', '云泽科技有限公司', 'companyname', '公司全称,常用于页脚与「关于我们」 | group:公司信息', NOW(), NOW()), + ('联系电话', '400-000-0000', 'phone', '服务热线,常用于页头与页脚 | group:公司信息', NOW(), NOW()), + ('电子邮箱', 'contact@yunzer.com', 'email', '商务合作邮箱,可配合 mailto: 使用 | group:公司信息', NOW(), NOW()), + ('公司地址', '北京市海淀区中关村软件园 25 号楼', 'address', '公司所在地址,展示于页脚/联系页面 | group:公司信息', NOW(), NOW()), + ('工作时间', '工作日 9:00 - 18:00', 'worktime', '如:工作日 9:00 - 18:00 | group:公司信息', NOW(), NOW()), + -- 备案信息 + ('ICP 备案号', '京ICP备00000000号-1', 'icp', '请替换为真实备案号,常用于页脚 | group:备案信息', NOW(), NOW()), + ('公安备案', '京公网安备11010800000000号', 'policeicp', '请替换为真实公安备案号,常用于页脚 | group:备案信息', NOW(), NOW()), + ('版权信息', 'COPYRIGHT (C) 2026 YUNZER TECHNOLOGY CO., LTD. ALL RIGHTS RESERVED.', 'copyright', '常用于页脚 | group:备案信息', NOW(), NOW()), + -- 政策条款 + ('隐私政策', '/policy/privacy', 'privacy', '隐私政策页面链接 | group:政策条款', NOW(), NOW()), + ('使用条款', '/policy/terms', 'terms', '使用条款页面链接 | group:政策条款', NOW(), NOW()); + +-- ============================================================ +-- PART 2:已有数据(表里有旧的 9 项)—— 只做增量调整,可重复执行 +-- ============================================================ + +-- 2.1 隐私政策 / 使用条款 归入「政策条款」tab +UPDATE `yz_platform_site_setting` + SET `remark` = '隐私政策页面链接 | group:政策条款', `update_time` = NOW() + WHERE `tag` = 'privacy' AND `delete_time` IS NULL; + +UPDATE `yz_platform_site_setting` + SET `remark` = '使用条款页面链接 | group:政策条款', `update_time` = NOW() + WHERE `tag` = 'terms' AND `delete_time` IS NULL; + +-- 2.2 新增「公司信息」分组的 5 个设置项(已存在同 tag 的记录不会重复插入) +INSERT INTO `yz_platform_site_setting` + (`title`, `value`, `tag`, `remark`, `create_time`, `update_time`) +SELECT v.`title`, v.`value`, v.`tag`, v.`remark`, NOW(), NOW() +FROM ( + SELECT '公司名称' AS `title`, '云泽科技有限公司' AS `value`, 'companyname' AS `tag`, + '公司全称,常用于页脚与「关于我们」 | group:公司信息' AS `remark` + UNION ALL SELECT '联系电话', '400-000-0000', 'phone', '服务热线,常用于页头与页脚 | group:公司信息' + UNION ALL SELECT '电子邮箱', 'contact@yunzer.com', 'email', '商务合作邮箱,可配合 mailto: 使用 | group:公司信息' + UNION ALL SELECT '公司地址', '北京市海淀区中关村软件园 25 号楼', 'address', '公司所在地址,展示于页脚/联系页面 | group:公司信息' + UNION ALL SELECT '工作时间', '工作日 9:00 - 18:00', 'worktime', '如:工作日 9:00 - 18:00 | group:公司信息' +) v +WHERE NOT EXISTS ( + SELECT 1 FROM `yz_platform_site_setting` s + WHERE s.`tag` = v.`tag` AND s.`delete_time` IS NULL +); + +-- 校验:SELECT tag, title, value, remark FROM yz_platform_site_setting WHERE delete_time IS NULL ORDER BY id; diff --git a/go/models/init.go b/go/models/init.go index 72fd3e7..07b87d4 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -57,6 +57,7 @@ func Init(_ string) { new(SystemModules), new(StorageConfig), new(TenantSiteSetting), + new(PlatformSiteSetting), new(ComplaintCategory), new(PlatformComplaint), new(SystemSoftwareUpgrade), @@ -157,6 +158,7 @@ func Init(_ string) { EnsureCrmProductDevColumns() EnsureCrmUnitTable() EnsureErpCompanyContactMobilesColumn() + EnsurePlatformSiteSettingTable() } // EnsureCrmPaybackTable 回款计划表建表(CREATE TABLE IF NOT EXISTS,可重复执行; diff --git a/go/models/platform_site_setting.go b/go/models/platform_site_setting.go new file mode 100644 index 0000000..6367915 --- /dev/null +++ b/go/models/platform_site_setting.go @@ -0,0 +1,289 @@ +package models + +import ( + "regexp" + "strings" + "time" +) + +// PlatformSiteSetting 平台官网站点设置表 yz_platform_site_setting(通用键值结构) +// +// 一行一个设置项:title 名称 / value 值 / tag 标签名 / remark 说明。 +// 模板里用 {yz:tag} 即可输出对应 value,例如 tag=sitename → {yz:sitename}。 +// 新增设置项只需在 PlatformSiteSettingDefs 里登记一条(或直接在表里插一行), +// 无需再加表字段,因此该表对所有站点级设置通用。 +type PlatformSiteSetting struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Title string `orm:"column(title);size(128);default('')" json:"title"` // 设置项名称 + Value string `orm:"column(value);type(text);null" json:"value"` // 设置项的值 + Tag string `orm:"column(tag);size(64);default('')" json:"tag"` // 标签名,模板中写作 {yz:tag} + Remark string `orm:"column(remark);size(255);default('')" json:"remark"` // 备注/填写说明 + + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add;null" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);auto_now;null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *PlatformSiteSetting) TableName() string { + return "yz_platform_site_setting" +} + +// PlatformSiteSettingDef 站点设置项定义(顺序即 platform 端界面展示顺序) +type PlatformSiteSettingDef struct { + Tag string // 标签名,模板中 {yz:tag} + Title string // 设置项名称 + Remark string // 填写说明 +} + +// PlatformSiteSettingDefs 站点设置项定义清单(仅作初始化种子,不限制可新增的设置项) +// +// 该表现在完全由数据驱动:直接在表里插一行即成为一个新设置项,无需改动任何代码: +// - tag 即模板标签名,表里写了什么标签,模板就能用 {yz:tag} +// - remark 支持可选标记,用 | 分隔,未识别的片段当作说明文字展示: +// group:分组名 决定 platform 端界面落在哪个 tab(缺省归入「基本设置」) +// type:控件类型 text(默认,单行输入) / textarea(多行) / image(图片上传) +// 例:深色 Logo,用于浅色背景 | group:基本设置 | type:image +var PlatformSiteSettingDefs = []PlatformSiteSettingDef{ + // 基本设置 + {Tag: "sitename", Title: "站点名称", Remark: "常用于 <title> 和页头 | group:基本设置"}, + {Tag: "subtitle", Title: "副标题", Remark: "站点名称下方的补充说明,常用于页头/首页 | group:基本设置"}, + {Tag: "logo", Title: "Logo(深色)", Remark: "深色 Logo,用于浅色背景(如页头) | group:基本设置 | type:image"}, + {Tag: "logow", Title: "Logo(浅色)", Remark: "浅色 Logo,用于深色背景(如页脚) | group:基本设置 | type:image"}, + + // 公司信息 + {Tag: "companyname", Title: "公司名称", Remark: "公司全称,常用于页脚与「关于我们」 | group:公司信息"}, + {Tag: "phone", Title: "联系电话", Remark: "服务热线,常用于页头与页脚 | group:公司信息"}, + {Tag: "email", Title: "电子邮箱", Remark: "商务合作邮箱,可配合 mailto: 使用 | group:公司信息"}, + {Tag: "address", Title: "公司地址", Remark: "公司所在地址,展示于页脚/联系页面 | group:公司信息"}, + {Tag: "worktime", Title: "工作时间", Remark: "如:工作日 9:00 - 18:00 | group:公司信息"}, + + // 备案信息 + {Tag: "icp", Title: "ICP 备案号", Remark: "如:苏ICP备2026000000号-1,常用于页脚 | group:备案信息"}, + {Tag: "policeicp", Title: "公安备案", Remark: "如:苏公网安备32010000000000号,常用于页脚 | group:备案信息"}, + {Tag: "copyright", Title: "版权信息", Remark: "如:© 2026 公司名称 版权所有 | group:备案信息"}, + + // 政策条款 + {Tag: "privacy", Title: "隐私政策", Remark: "隐私政策链接,如 /page/privacy | group:政策条款"}, + {Tag: "terms", Title: "使用条款", Remark: "使用条款链接,如 /page/terms | group:政策条款"}, +} + +// rePlatformSiteTag 标签名校验:与 tagengine 全局标签正则保持一致 +var rePlatformSiteTag = regexp.MustCompile(`^[a-zA-Z_]\w*$`) + +// platformSiteSettingDef 按 tag 取定义;未登记时返回空定义 +func platformSiteSettingDef(tag string) PlatformSiteSettingDef { + for _, d := range PlatformSiteSettingDefs { + if d.Tag == tag { + return d + } + } + return PlatformSiteSettingDef{Tag: tag, Title: tag, Remark: ""} +} + +// GetPlatformSiteTagMap 返回 标签名 -> 值。 +// 已登记的定义项必定出现在返回结果中(未配置时为 ""),保证调用方不会缺 key。 +func GetPlatformSiteTagMap() map[string]string { + out := make(map[string]string, len(PlatformSiteSettingDefs)) + for _, d := range PlatformSiteSettingDefs { + out[d.Tag] = "" + } + var rows []PlatformSiteSetting + if _, err := Orm.QueryTable(new(PlatformSiteSetting)). + Filter("delete_time__isnull", true). + All(&rows); err != nil { + return out + } + for _, r := range rows { + if r.Tag != "" { + out[r.Tag] = r.Value + } + } + return out +} + +// SavePlatformSiteSettings 按标签名批量写入设置项(tag -> value)。 +// 已存在则更新 value,不存在则按定义插入;标签名非法的项直接忽略。 +func SavePlatformSiteSettings(values map[string]string) error { + now := time.Now() + for tag, val := range values { + tag = strings.TrimSpace(tag) + if !rePlatformSiteTag.MatchString(tag) { + continue + } + + cnt, err := Orm.QueryTable(new(PlatformSiteSetting)). + Filter("tag", tag). + Filter("delete_time__isnull", true). + Count() + if err != nil { + return err + } + + if cnt == 0 { + def := platformSiteSettingDef(tag) + if _, err = Orm.Insert(&PlatformSiteSetting{ + Title: def.Title, + Tag: tag, + Value: val, + Remark: def.Remark, + UpdateTime: &now, + }); err != nil { + return err + } + continue + } + + if _, err = Orm.QueryTable(new(PlatformSiteSetting)). + Filter("tag", tag). + Filter("delete_time__isnull", true). + Update(map[string]any{"value": val, "update_time": now}); err != nil { + return err + } + } + return nil +} + +// GetPlatformSiteSettingRows 返回库中实际存在的设置行(不做默认值补齐) +func GetPlatformSiteSettingRows() []PlatformSiteSetting { + var rows []PlatformSiteSetting + if _, err := Orm.QueryTable(new(PlatformSiteSetting)). + Filter("delete_time__isnull", true). + OrderBy("id"). + All(&rows); err != nil { + return nil + } + return rows +} + +// EnsurePlatformSiteSettingDefaults 补齐已登记但未入库的设置项(缺失时插入空值行) +// 注意:须用库中实际行判断,不能用 GetPlatformSiteTagMap —— 它预置了所有默认 tag 的 key, +// 会导致「已存在」判断永远成立、一行都插不进去。 +func EnsurePlatformSiteSettingDefaults() { + exist := map[string]bool{} + for _, r := range GetPlatformSiteSettingRows() { + if r.Tag != "" { + exist[r.Tag] = true + } + } + + now := time.Now() + for _, d := range PlatformSiteSettingDefs { + if exist[d.Tag] { + continue + } + if _, err := Orm.Insert(&PlatformSiteSetting{ + Title: d.Title, + Tag: d.Tag, + Value: "", + Remark: d.Remark, + UpdateTime: &now, + }); err != nil { + continue + } + exist[d.Tag] = true + } +} + +// EnsurePlatformSiteSettingTable 建表(通用键值结构,CREATE TABLE IF NOT EXISTS,可重复执行)。 +// 若检测到旧宽表结构(含 logo_dark 列),先把历史数据迁移为键值行,再补齐默认项。 +func EnsurePlatformSiteSettingTable() { + table := new(PlatformSiteSetting).TableName() + sql := `CREATE TABLE IF NOT EXISTS ` + table + ` ( + id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + title varchar(128) NOT NULL DEFAULT '' COMMENT '设置项名称', + value text COMMENT '设置项的值', + tag varchar(64) NOT NULL DEFAULT '' COMMENT '标签名,模板中写作 {yz:tag}', + remark varchar(255) NOT NULL DEFAULT '' COMMENT '备注/填写说明', + create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_time datetime DEFAULT NULL COMMENT '更新时间', + delete_time datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (id), + UNIQUE KEY uk_tag (tag, delete_time), + KEY idx_delete_time (delete_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台官网站点设置表(通用键值结构)'` + if _, err := Orm.Raw(sql).Exec(); err != nil { + return + } + + // 旧宽表升级场景:CREATE TABLE IF NOT EXISTS 不会改已有表,需补齐通用结构列 + ensurePlatformSiteSettingColumns(table) + + if hasTableColumn(table, "logo_dark") { + migrateLegacyPlatformSiteSetting(table) + } + EnsurePlatformSiteSettingDefaults() +} + +// ensurePlatformSiteSettingColumns 补齐通用结构列(列缺失时新增,已存在则忽略) +func ensurePlatformSiteSettingColumns(table string) { + cols := []struct{ name, def string }{ + {"title", "varchar(128) NOT NULL DEFAULT '' COMMENT '设置项名称'"}, + {"value", "text COMMENT '设置项的值'"}, + {"tag", "varchar(64) NOT NULL DEFAULT '' COMMENT '标签名,模板中写作 {yz:tag}'"}, + {"remark", "varchar(255) NOT NULL DEFAULT '' COMMENT '备注/填写说明'"}, + } + for _, c := range cols { + if hasTableColumn(table, c.name) { + continue + } + _, _ = Orm.Raw("ALTER TABLE " + table + " ADD COLUMN " + c.def).Exec() + } +} + +// hasTableColumn 判断表是否存在指定列(表不存在或查询异常时返回 false) +func hasTableColumn(table, column string) bool { + var cnt int64 + err := Orm.Raw( + "SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", + table, column, + ).QueryRow(&cnt) + return err == nil && cnt > 0 +} + +// migrateLegacyPlatformSiteSetting 旧宽表结构迁移: +// 旧列为 sitename/logo_dark/logo_light/icp/police_icp/copyright/privacy_policy/terms_of_service, +// 迁移时仅在目标标签行为空的情况下写入,避免覆盖新配置。 +func migrateLegacyPlatformSiteSetting(table string) { + type legacyRow struct { + Sitename string + LogoDark string + LogoLight string + Icp string + PoliceIcp string + Copyright string + Privacy string + Terms string + Companyname string + } + var rows []legacyRow + if _, err := Orm.Raw( + "SELECT IFNULL(sitename,'') sitename, IFNULL(logo_dark,'') logo_dark, IFNULL(logo_light,'') logo_light," + + " IFNULL(icp,'') icp, IFNULL(police_icp,'') police_icp, IFNULL(copyright,'') copyright," + + " IFNULL(privacy_policy,'') privacy_policy, IFNULL(terms_of_service,'') terms_of_service" + + " FROM " + table + " WHERE delete_time IS NULL ORDER BY id LIMIT 1").QueryRows(&rows); err != nil { + return + } + if len(rows) == 0 { + return + } + + old := rows[0] + current := GetPlatformSiteTagMap() + pending := map[string]string{ + "sitename": old.Sitename, + "logo": old.LogoDark, + "logow": old.LogoLight, + "icp": old.Icp, + "policeicp": old.PoliceIcp, + "copyright": old.Copyright, + "privacy": old.Privacy, + "terms": old.Terms, + } + for tag, val := range pending { + if val == "" || strings.TrimSpace(current[tag]) != "" { + continue + } + current[tag] = val + } + _ = SavePlatformSiteSettings(pending) +} diff --git a/go/pkg/tagengine/engine.go b/go/pkg/tagengine/engine.go index 851b035..5521ff9 100644 --- a/go/pkg/tagengine/engine.go +++ b/go/pkg/tagengine/engine.go @@ -213,6 +213,19 @@ func renderSelfClose(content string, ctx *RenderCtx) (string, error) { return out, nil } +// SiteGlobals 返回站点级全局标签值(key 即标签名),与模板内 {yz:xxx} 完全同源。 +// 供前台公开接口(GET /site/setting)输出给 SPA 官网使用,保证 SPA 与 themes 直出模板展示一致。 +func SiteGlobals(tid uint64) map[string]string { + if globalProvider == nil { + return map[string]string{} + } + vals, err := globalProvider(tid, &RenderCtx{Tid: tid}) + if err != nil || vals == nil { + return map[string]string{} + } + return vals +} + // renderGlobals 替换全局标签;未提供的标签置空 func renderGlobals(content string, ctx *RenderCtx) string { var vals map[string]string diff --git a/go/pkg/tagengine/meta.go b/go/pkg/tagengine/meta.go index 340a684..7d448d5 100644 --- a/go/pkg/tagengine/meta.go +++ b/go/pkg/tagengine/meta.go @@ -37,26 +37,30 @@ func TagDocs() []TagMeta { Name: "站点基本信息", Category: "global", Syntax: "{yz:sitename}、{yz:logo}、{yz:ico} …直接写在模板任意位置", - Desc: "取自当前租户的站点设置(backend 端 - 站点设置 - 基本信息)。直接替换为文本或地址,无需闭合;未配置的项输出空字符串。点击行末复制按钮可复制标签。", + Desc: "取自当前站点的站点设置。租户站点来自「站点设置 - 基本信息」(backend 端);平台官网(平台主域名)优先取「官网管理 - 站点设置」(platform 端),未填写时回退基本信息。直接替换为文本或地址,无需闭合;未配置的项输出空字符串。点击行末复制按钮可复制标签。", Params: nil, Fields: []string{ - "sitename 站点名称,常用于 <title> 和页头 来源:基本信息-站点名称", - "logo 彩色 Logo 图片地址,适合浅色背景 来源:基本信息-站点LOGO", - "logow 白色 Logo 图片地址,适合深色背景/页脚 来源:基本信息-白色LOGO", + "sitename 站点名称,常用于 <title> 和页头 来源:站点设置-站点名称", + "subtitle 副标题,站点名称下方的补充说明 来源:官网管理-站点设置-副标题", + "logo 深色 Logo 图片地址,适合浅色背景 来源:站点设置-深色LOGO", + "logow 浅色 Logo 图片地址,适合深色背景/页脚 来源:站点设置-浅色LOGO", "ico 站点图标地址,用于 <link rel=\"icon\"> 来源:基本信息-站点图标", "companyname 公司全称 来源:基本信息-公司名称", "companyintroduction 企业介绍(富文本HTML,直接用 div 承接) 来源:基本信息-企业介绍", "description 站点简介,可用于 meta description 来源:基本信息-站点描述", - "copyright 版权信息,常用于页脚 来源:基本信息-版权信息", - "icp ICP 备案号,常用于页脚 来源:基本信息-ICP备案", + "copyright 版权信息,常用于页脚 来源:站点设置-版权信息", + "icp ICP 备案号,常用于页脚 来源:站点设置-ICP备案", + "policeicp 公安备案号/公安网备标识,常用于页脚 来源:官网管理-站点设置-公安备案", + "privacy 隐私政策链接地址,如 /page/privacy 来源:官网管理-站点设置-隐私政策", + "terms 使用条款链接地址,如 /page/terms 来源:官网管理-站点设置-使用条款", }, - Example: "<head>\n <title>{yz:sitename}\n \n \n\n\"{yz:sitename}\"\n
{yz:companyintroduction}
\n", + Example: "\n {yz:sitename}\n \n \n\n\"{yz:sitename}\"\n
{yz:companyintroduction}
\n", }, { Name: "联系方式", Category: "global", Syntax: "{yz:phone}、{yz:email}、{yz:address}、{yz:worktime}", - Desc: "取自租户的公司信息(backend 端 - 公司信息),常用于页头联系栏和页脚。直接替换为文本,无需闭合。", + Desc: "取自公司信息。平台官网优先取「官网管理 - 站点设置 - 公司信息」,未填写时回退租户的公司信息(backend 端 - 公司信息)。直接替换为文本,无需闭合。", Params: nil, Fields: []string{ "phone 联系电话,别名 {yz:tel}、{yz:mobile} 三者等价 来源:公司信息-联系电话", diff --git a/go/pkg/tagengine/provider.go b/go/pkg/tagengine/provider.go index 9c6f465..4166d7e 100644 --- a/go/pkg/tagengine/provider.go +++ b/go/pkg/tagengine/provider.go @@ -68,6 +68,7 @@ func globalSiteInfo(tid uint64, ctx *RenderCtx) (map[string]string, error) { "companyintroduction": "", "phone": "", "tel": "", "mobile": "", "email": "", "address": "", "worktime": "", "keywords": "", "seotitle": "", "seodescription": "", + "policeicp": "", "privacy": "", "terms": "", "aboutus": "/page/about", "contact": "/page/contact", "news": "/news", "team": "/page/team", } var row models.TenantSiteSetting @@ -115,6 +116,21 @@ func globalSiteInfo(tid uint64, ctx *RenderCtx) (map[string]string, error) { out["seotitle"] = vals["seoTitle"] out["seodescription"] = vals["seoDescription"] } + + // 平台官网(tid 1)额外读取 platform 端「官网管理 - 站点设置」 + // (yz_platform_site_setting,通用键值结构:tag 即标签名)。 + // 值非空时覆盖同名全局标签,为空时保证标签存在(输出空串);租户站点不受影响。 + if tid == 1 { + for k, v := range models.GetPlatformSiteTagMap() { + if v == "" { + if _, ok := out[k]; !ok { + out[k] = "" + } + continue + } + out[k] = v + } + } return out, nil } diff --git a/go/routers/index/index.go b/go/routers/index/index.go index d472c40..a69518e 100644 --- a/go/routers/index/index.go +++ b/go/routers/index/index.go @@ -14,6 +14,9 @@ func Register() { beego.Router("/news/:id", &controllers.TenantSiteController{}, "get:NewsDetail") beego.Router("/page/:path", &controllers.TenantSiteController{}, "get:Page") + // 官网前台站点设置(公开接口,供 SPA 官网读取,字段与模板标签 {yz:xxx} 同源) + beego.Router("/site/setting", &controllers.TenantSiteController{}, "get:SiteSetting") + beego.Router("/index/headmenu", &controllers.IndexPortalController{}, "get:GetHeadMenu") beego.Router("/index/footerdata", &controllers.IndexPortalController{}, "get:GetFooterData") beego.Router("/index/newscentertop4", &controllers.IndexPortalController{}, "get:GetNewsCenterTop4") diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go index 6596ad0..7f7e3a8 100644 --- a/go/routers/platform/platform.go +++ b/go/routers/platform/platform.go @@ -305,4 +305,8 @@ func Register() { beego.Router("/platform/template/status", &controllers.PlatformTemplateController{}, "post:Status") beego.Router("/platform/template/delete/:id", &controllers.PlatformTemplateController{}, "post:Delete") beego.Router("/platform/template/tags", &controllers.PlatformTemplateController{}, "get:Tags") + + // 平台官网站点设置(yz_platform_site_setting) + beego.Router("/platform/website/setting/info", &controllers.PlatformWebsiteSettingController{}, "get:Info") + beego.Router("/platform/website/setting/save", &controllers.PlatformWebsiteSettingController{}, "post:Save") } diff --git a/platform/src/api/websiteSetting.js b/platform/src/api/websiteSetting.js new file mode 100644 index 0000000..f9a66af --- /dev/null +++ b/platform/src/api/websiteSetting.js @@ -0,0 +1,22 @@ +import request from '@/utils/request' + +// ==================== 官网管理 - 站点设置(平台端) ==================== +// 数据落在 yz_platform_site_setting(单行配置),字段可被官网模板标签调用: +// {yz:sitename} {yz:logo} {yz:logow} {yz:icp} {yz:policeicp} {yz:copyright} {yz:privacy} {yz:terms} + +// 读取站点设置 +export function getWebsiteSetting() { + return request({ + url: '/platform/website/setting/info', + method: 'get' + }) +} + +// 保存站点设置 +export function saveWebsiteSetting(data) { + return request({ + url: '/platform/website/setting/save', + method: 'post', + data + }) +} diff --git a/platform/src/router/index.js b/platform/src/router/index.js index a0bfdf2..f3e4933 100644 --- a/platform/src/router/index.js +++ b/platform/src/router/index.js @@ -27,6 +27,12 @@ const staticMainChildren = [ component: () => import("@/views/tools/passwordStore/index.vue"), meta: { requiresAuth: true, title: "密码存储" } }, + { + path: "/website/setting", + name: "PlatformWebsiteSetting", + component: () => import("@/views/website/setting/index.vue"), + meta: { requiresAuth: true, title: "站点设置" } + }, { path: "/template/index", name: "PlatformTemplateManage", diff --git a/platform/src/views/website/setting/components/TagTip.vue b/platform/src/views/website/setting/components/TagTip.vue new file mode 100644 index 0000000..7c74dc4 --- /dev/null +++ b/platform/src/views/website/setting/components/TagTip.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/platform/src/views/website/setting/components/tab_basic.vue b/platform/src/views/website/setting/components/tab_basic.vue new file mode 100644 index 0000000..619182e --- /dev/null +++ b/platform/src/views/website/setting/components/tab_basic.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/platform/src/views/website/setting/components/tab_company.vue b/platform/src/views/website/setting/components/tab_company.vue new file mode 100644 index 0000000..706a2b1 --- /dev/null +++ b/platform/src/views/website/setting/components/tab_company.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/platform/src/views/website/setting/components/tab_policy.vue b/platform/src/views/website/setting/components/tab_policy.vue new file mode 100644 index 0000000..adc5efc --- /dev/null +++ b/platform/src/views/website/setting/components/tab_policy.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/platform/src/views/website/setting/components/tab_record.vue b/platform/src/views/website/setting/components/tab_record.vue new file mode 100644 index 0000000..db0a844 --- /dev/null +++ b/platform/src/views/website/setting/components/tab_record.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/platform/src/views/website/setting/index.vue b/platform/src/views/website/setting/index.vue new file mode 100644 index 0000000..f025891 --- /dev/null +++ b/platform/src/views/website/setting/index.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/website/src/api/site.ts b/website/src/api/site.ts new file mode 100644 index 0000000..d56f582 --- /dev/null +++ b/website/src/api/site.ts @@ -0,0 +1,60 @@ +/** + * 站点设置接口 + * + * 数据来源:后端 yz_platform_site_setting(platform 端「官网管理 - 站点设置」)。 + * 返回字段名与官网模板标签一一对应,因此 SPA 官网与 themes 直出模板展示的 + * 站点信息始终一致: + * sitename {yz:sitename} 站点名称 + * logo {yz:logo} 深色 Logo(浅色背景) + * logow {yz:logow} 浅色 Logo(深色背景) + * icp {yz:icp} ICP 备案号 + * policeicp {yz:policeicp} 公安备案号 + * copyright {yz:copyright} 版权信息 + * privacy {yz:privacy} 隐私政策链接 + * terms {yz:terms} 使用条款链接 + */ + +/** + * 后端返回的站点设置:key 就是模板标签名 + * 完全数据驱动 —— 表里有什么 tag 就返回什么,新增设置项前端无需改动。 + * 下列为常用项的类型提示,其余标签通过索引签名访问即可。 + */ +export type SiteSetting = Record & { + /** {yz:sitename} 站点名称 */ + sitename?: string; + /** {yz:subtitle} 副标题 */ + subtitle?: string; + /** {yz:logo} 深色 Logo,用于浅色背景(如页头) */ + logo?: string; + /** {yz:logow} 浅色 Logo,用于深色背景(如页脚) */ + logow?: string; + /** {yz:icp} ICP 备案号 */ + icp?: string; + /** {yz:policeicp} 公安备案号 */ + policeicp?: string; + /** {yz:copyright} 版权信息 */ + copyright?: string; + /** {yz:privacy} 隐私政策链接 */ + privacy?: string; + /** {yz:terms} 使用条款链接 */ + terms?: string; +}; + +/** 接口前缀:开发期由 vite 代理 /api 到后端,生产可用 VITE_API_BASE_URL 覆盖 */ +const API_BASE = (import.meta as any).env?.VITE_API_BASE_URL || "/api"; + +/** 拉取当前域名的站点设置;接口不可用时返回 null,由调用方保留默认配置 */ +export async function fetchSiteSetting(): Promise { + try { + const res = await fetch(`${API_BASE}/site/setting`, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) return null; + const body = await res.json(); + if (body?.code !== 200 || !body.data) return null; + return body.data as SiteSetting; + } catch (error) { + console.warn("[site] 站点设置获取失败,使用默认配置", error); + return null; + } +} diff --git a/website/src/config/site.ts b/website/src/config/site.ts index bd34df3..9c84403 100644 --- a/website/src/config/site.ts +++ b/website/src/config/site.ts @@ -1,7 +1,20 @@ /** * 站点级公共配置:品牌、导航、页脚 - * 后续接入后端 CMS 时,只需替换此处的数据来源即可。 + * + * 站点信息的数据来源已切换为后端「官网管理 - 站点设置」(应用启动时由 loadSiteInfo 拉取), + * 与官网模板 themes/ 中使用的标签同源,括号内为对应标签: + * sitename 站点名称 {yz:sitename} + * logoDark 深色 Logo {yz:logo} + * logoLight 浅色 Logo {yz:logow} + * icp ICP 备案号 {yz:icp} + * policeIcp 公安备案号 {yz:policeicp} + * copyright 版权信息 {yz:copyright} + * privacy 隐私政策链接 {yz:privacy} + * terms 使用条款链接 {yz:terms} + * 后端未返回的字段保留文件内默认值。 */ +import { computed, reactive } from "vue"; +import { fetchSiteSetting } from "@/api/site"; export interface NavItem { label: string; @@ -13,7 +26,7 @@ export interface FooterColumn { links: NavItem[]; } -export const siteInfo = { +export const siteInfo = reactive({ brandMark: "YZ", brandName: "云泽科技", brandSub: "YUNZER TECH", @@ -25,7 +38,63 @@ export const siteInfo = { postalCode: "100193", copyright: "COPYRIGHT (C) 2026 YUNZER TECHNOLOGY CO., LTD. ALL RIGHTS RESERVED.", -}; + + // ↓↓↓ 以下字段由「官网管理 - 站点设置」下发(对应官网模板标签) ↓↓↓ + /** {yz:sitename} 站点名称 */ + sitename: "", + /** {yz:subtitle} 副标题 */ + subtitle: "", + /** {yz:logo} 深色 Logo,用于浅色背景(页头) */ + logoDark: "", + /** {yz:logow} 浅色 Logo,用于深色背景(页脚) */ + logoLight: "", + /** {yz:icp} ICP 备案号 */ + icp: "", + /** {yz:policeicp} 公安备案号 */ + policeIcp: "", + /** {yz:privacy} 隐私政策链接 */ + privacy: "", + /** {yz:terms} 使用条款链接 */ + terms: "", +}); + +/** + * 站点设置标签值:key 就是模板标签名,与后端 {yz:xxx} 完全一致 + * 供 v-yz 指令解析(

{yz:icp}

),未配置或后端未返回时输出空字符串 + */ +export const siteTags = reactive>({}); + +/** + * 拉取站点设置并回填 siteInfo(应用启动时调用一次)。 + * 接口异常时静默处理,页面继续使用默认配置。 + */ +export async function loadSiteInfo(): Promise { + const data = await fetchSiteSetting(); + if (!data) return; + + // {yz:sitename} → 页头品牌名 + if (data.sitename) { + siteInfo.sitename = data.sitename; + siteInfo.brandName = data.sitename; + } + if (data.subtitle) siteInfo.subtitle = data.subtitle; + if (data.logo) siteInfo.logoDark = data.logo; + if (data.logow) siteInfo.logoLight = data.logow; + // 公司信息:站点设置里有值就覆盖本地默认(页脚联系栏、页脚品牌全称),没填则保留默认 + if (data.companyname) siteInfo.fullName = data.companyname; + if (data.phone) siteInfo.phone = data.phone; + if (data.email) siteInfo.email = data.email; + if (data.address) siteInfo.address = data.address; + if (data.icp) siteInfo.icp = data.icp; + if (data.policeicp) siteInfo.policeIcp = data.policeicp; + if (data.copyright) siteInfo.copyright = data.copyright; + if (data.privacy) siteInfo.privacy = data.privacy; + if (data.terms) siteInfo.terms = data.terms; + + // 标签值:直接以接口返回的「标签名 -> 值」为准(v-yz 指令解析用)。 + // 数据驱动:表里新增的设置项会自动进入 siteTags,模板里写 {yz:新tag} 即可,无需改代码。 + Object.assign(siteTags, data); +} /** 顶部工具条链接 */ export const utilityLinks: NavItem[] = [ @@ -65,9 +134,16 @@ export const footerColumns: FooterColumn[] = [ }, ]; -/** 页脚底部合规链接 */ -export const policyLinks: NavItem[] = [ - { label: "隐私政策", path: "/policy/privacy" }, - { label: "使用条款", path: "/policy/terms" }, +/** + * 页脚底部合规链接 + * 隐私政策 {yz:privacy} / 使用条款 {yz:terms} 取自站点设置,未配置时回退站内默认页面 + */ +export const policyLinks = computed(() => [ + { label: "隐私政策", path: siteInfo.privacy || "/policy/privacy" }, + { label: "使用条款", path: siteInfo.terms || "/policy/terms" }, { label: "网站地图", path: "/policy/sitemap" }, -]; +]); + +/** 是否为站外链接(站点设置里可能填完整 URL) */ +export const isExternalLink = (path: string): boolean => + /^https?:\/\//i.test(path); diff --git a/website/src/directives/yz.ts b/website/src/directives/yz.ts new file mode 100644 index 0000000..6104ce4 --- /dev/null +++ b/website/src/directives/yz.ts @@ -0,0 +1,48 @@ +/** + * {yz:xxx} 站点标签指令(dedecms 风格) + * + * 用法:在模板里直接写标签,元素加 v-yz 即可 + *

{yz:icp}

+ *

备案号:{yz:icp} | {yz:policeicp}

+ * + * 元素内所有 {yz:标签名} 会被替换为「官网管理 - 站点设置」中对应的值, + * 未配置的标签输出空字符串(与后端 themes 模板行为一致)。 + * + * 注意:指令通过 innerHTML 替换内容,因此元素内只能放纯文本/HTML 片段, + * 不要放子组件或需要响应式绑定的内容;需要条件渲染的地方请用 siteInfo.xxx。 + */ +import { watchEffect, type Directive } from "vue"; +import { siteTags } from "@/config/site"; + +/** 与后端 tagengine 的全局标签正则保持一致 */ +const TAG_PATTERN = /\{yz:([a-zA-Z_]\w*)\}/g; + +/** 每个元素对应的 watchEffect 停止函数 */ +const stops = new WeakMap void>(); + +/** 依据当前标签值渲染元素内容;始终基于首次保存的原文,避免重复替换 */ +function render(el: HTMLElement): void { + if (!el.dataset.yzRaw) { + el.dataset.yzRaw = el.innerHTML; + } + const raw = el.dataset.yzRaw; + + el.innerHTML = raw.replace( + TAG_PATTERN, + (_match: string, key: string) => siteTags[key] ?? "" + ); +} + +export const yz: Directive = { + mounted(el) { + // watchEffect 会追踪 render 中读取到的标签值; + // 站点设置是异步拉取的,数据到位后会自动重新渲染 + stops.set(el, watchEffect(() => render(el))); + }, + unmounted(el) { + stops.get(el)?.(); + stops.delete(el); + }, +}; + +export default yz; diff --git a/website/src/layout/SiteFooter.vue b/website/src/layout/SiteFooter.vue index fe4b02f..c744f95 100644 --- a/website/src/layout/SiteFooter.vue +++ b/website/src/layout/SiteFooter.vue @@ -1,5 +1,5 @@