更新website后端

This commit is contained in:
2026-09-15 00:08:36 +08:00
parent 7e6a3e969f
commit 9875832019
30 changed files with 1428 additions and 38 deletions
+4
View File
@@ -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
+107
View File
@@ -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()
}
+15
View File
@@ -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"))
@@ -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='平台官网站点设置表';
@@ -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', '常用于 <title> 和页头 | 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;
+2
View File
@@ -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,可重复执行;
+289
View File
@@ -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)
}
+13
View File
@@ -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
+12 -8
View File
@@ -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}</title>\n <link rel=\"icon\" href=\"{yz:ico}\">\n <meta name=\"description\" content=\"{yz:description}\">\n</head>\n<img src=\"{yz:logo}\" alt=\"{yz:sitename}\">\n<div class=\"about\">{yz:companyintroduction}</div>\n<footer>\n <p>{yz:copyright}</p>\n <p>{yz:icp}</p>\n</footer>",
Example: "<head>\n <title>{yz:sitename}</title>\n <link rel=\"icon\" href=\"{yz:ico}\">\n <meta name=\"description\" content=\"{yz:description}\">\n</head>\n<img src=\"{yz:logo}\" alt=\"{yz:sitename}\">\n<div class=\"about\">{yz:companyintroduction}</div>\n<footer>\n <p>{yz:copyright}</p>\n <p>{yz:icp}</p>\n <p>{yz:policeicp}</p>\n <p>\n <a href=\"{yz:privacy}\">隐私政策</a>\n <a href=\"{yz:terms}\">使用条款</a>\n </p>\n</footer>",
},
{
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} 三者等价 来源:公司信息-联系电话",
+16
View File
@@ -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
}
+3
View File
@@ -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")
+4
View File
@@ -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")
}
+22
View File
@@ -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
})
}
+6
View File
@@ -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",
@@ -0,0 +1,56 @@
<template>
<div class="tag-tip">
<span class="tag-tip__label">调用标签</span>
<code class="tag-tip__code">{{ tag }}</code>
<el-button link type="primary" size="small" @click="copy">复制</el-button>
<span v-if="desc" class="tag-tip__desc">{{ desc }}</span>
</div>
</template>
<script setup>
import { ElMessage } from 'element-plus'
const props = defineProps({
// 标签代码,如 {yz:sitename}
tag: { type: String, required: true },
// 用法补充说明
desc: { type: String, default: '' }
})
const copy = async () => {
try {
await navigator.clipboard.writeText(props.tag)
ElMessage.success('已复制')
} catch (e) {
ElMessage.error('复制失败,请手动复制')
}
}
</script>
<style lang="less" scoped>
.tag-tip {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
font-size: 12px;
line-height: 20px;
&__label {
color: #909399;
}
&__code {
padding: 1px 6px;
border-radius: 3px;
background: #f4f4f5;
color: #3973ff;
font-family: Consolas, Monaco, monospace;
}
&__desc {
color: #a8abb2;
}
}
</style>
@@ -0,0 +1,154 @@
<template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="130px"
class="tab-form"
>
<el-form-item label="站点名称" prop="sitename">
<el-input v-model="form.sitename" placeholder="请输入站点名称" />
<TagTip tag="{yz:sitename}" desc="常用于 &lt;title&gt; 和页头" />
</el-form-item>
<el-form-item label="副标题" prop="subtitle">
<el-input v-model="form.subtitle" placeholder="请输入副标题" />
<TagTip tag="{yz:subtitle}" desc="站点名称下方的补充说明,常用于页头/首页" />
</el-form-item>
<el-form-item label="Logo(深色)" prop="logo">
<el-upload
class="logo-uploader"
:show-file-list="false"
:auto-upload="false"
:on-change="(file) => handleUpload(file, 'logo')"
>
<img v-if="form.logo" :src="previewUrl(form.logo)" class="logo-image" />
<el-icon v-else class="logo-uploader-icon"><Plus /></el-icon>
</el-upload>
<TagTip tag="{yz:logo}" desc="深色 Logo,用于浅色背景(如页头)" />
</el-form-item>
<el-form-item label="Logo(浅色)" prop="logow">
<el-upload
class="logo-uploader"
:show-file-list="false"
:auto-upload="false"
:on-change="(file) => handleUpload(file, 'logow')"
>
<img
v-if="form.logow"
:src="previewUrl(form.logow)"
class="logo-image darkbg"
/>
<el-icon v-else class="logo-uploader-icon"><Plus /></el-icon>
</el-upload>
<TagTip tag="{yz:logow}" desc="浅色 Logo,用于深色背景(如页脚)" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="handleSave">保存设置</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { uploadFile } from '@/api/file'
import TagTip from './TagTip.vue'
const props = defineProps({
// 与父组件共享的表单对象(key 即标签名)
form: { type: Object, required: true },
saving: { type: Boolean, default: false }
})
const emit = defineEmits(['save', 'reset'])
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
const formRef = ref(null)
const rules = {
sitename: [{ required: true, message: '请输入站点名称', trigger: 'blur' }]
}
/** 图片预览地址:完整 URL 直接使用,站内相对路径拼接后端地址 */
const previewUrl = (url) => {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
return API_BASE_URL + url.replace(/^\//, '/')
}
// Logo 上传:上传成功后回填图片地址
const handleUpload = (file, field) => {
const fd = new FormData()
fd.append('file', file.raw)
fd.append('cate', 'site')
uploadFile(fd).then((res) => {
if ((res.code === 200 || res.code === 201) && res.data && res.data.url) {
props.form[field] = res.data.url.replace(/\\/g, '/')
} else {
ElMessage.error(res.msg || '上传失败')
}
})
}
const handleSave = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) emit('save')
})
}
const handleReset = () => {
formRef.value?.clearValidate()
emit('reset')
}
</script>
<style lang="less" scoped>
.tab-form {
max-width: 720px;
}
.darkbg {
background-color: #efefef;
}
.logo-uploader {
:deep(.el-upload) {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s;
&:hover {
border-color: #3973ff;
}
}
.logo-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 100px;
height: 100px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.logo-image {
width: 100px;
height: 100px;
object-fit: contain;
display: block;
}
}
</style>
@@ -0,0 +1,66 @@
<template>
<el-form ref="formRef" :model="form" label-width="130px" class="tab-form">
<el-form-item label="公司名称" prop="companyname">
<el-input v-model="form.companyname" placeholder="请输入公司名称" />
<TagTip tag="{yz:companyname}" desc="公司全称,常用于页脚与「关于我们」" />
</el-form-item>
<el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" placeholder="如:400-000-0000" />
<TagTip tag="{yz:phone}" desc="服务热线,常用于页头与页脚" />
</el-form-item>
<el-form-item label="电子邮箱" prop="email">
<el-input v-model="form.email" placeholder="如:contact@yunzer.com" />
<TagTip tag="{yz:email}" desc="商务合作邮箱,可配合 mailto: 使用" />
</el-form-item>
<el-form-item label="公司地址" prop="address">
<el-input
v-model="form.address"
type="textarea"
:rows="2"
placeholder="请输入公司地址"
/>
<TagTip tag="{yz:address}" desc="公司所在地址,展示于页脚/联系页面" />
</el-form-item>
<el-form-item label="工作时间" prop="worktime">
<el-input v-model="form.worktime" placeholder="如:工作日 9:00 - 18:00" />
<TagTip tag="{yz:worktime}" desc="常用于页脚与联系我们页面" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="handleSave">保存设置</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref } from 'vue'
import TagTip from './TagTip.vue'
defineProps({
// 与父组件共享的表单对象(key 即标签名)
form: { type: Object, required: true },
saving: { type: Boolean, default: false }
})
const emit = defineEmits(['save', 'reset'])
const formRef = ref(null)
const handleSave = () => emit('save')
const handleReset = () => {
formRef.value?.clearValidate()
emit('reset')
}
</script>
<style lang="less" scoped>
.tab-form {
max-width: 720px;
}
</style>
@@ -0,0 +1,52 @@
<template>
<el-form ref="formRef" :model="form" label-width="130px" class="tab-form">
<el-form-item label="隐私政策" prop="privacy">
<el-input v-model="form.privacy" placeholder="如:/policy/privacy" />
<TagTip
tag="{yz:privacy}"
desc="链接地址,模板中用 &lt;a href=&quot;{yz:privacy}&quot;&gt; 承接"
/>
</el-form-item>
<el-form-item label="使用条款" prop="terms">
<el-input v-model="form.terms" placeholder="如:/policy/terms" />
<TagTip
tag="{yz:terms}"
desc="链接地址,模板中用 &lt;a href=&quot;{yz:terms}&quot;&gt; 承接"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="handleSave">保存设置</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref } from 'vue'
import TagTip from './TagTip.vue'
defineProps({
// 与父组件共享的表单对象(key 即标签名)
form: { type: Object, required: true },
saving: { type: Boolean, default: false }
})
const emit = defineEmits(['save', 'reset'])
const formRef = ref(null)
const handleSave = () => emit('save')
const handleReset = () => {
formRef.value?.clearValidate()
emit('reset')
}
</script>
<style lang="less" scoped>
.tab-form {
max-width: 720px;
}
</style>
@@ -0,0 +1,51 @@
<template>
<el-form ref="formRef" :model="form" label-width="130px" class="tab-form">
<el-form-item label="ICP 备案号" prop="icp">
<el-input v-model="form.icp" placeholder="如:苏ICP备2026000000号-1" />
<TagTip tag="{yz:icp}" desc="常见于页脚,请填写真实备案号" />
</el-form-item>
<el-form-item label="公安备案" prop="policeicp">
<el-input v-model="form.policeicp" placeholder="如:苏公网安备32010000000000号" />
<TagTip tag="{yz:policeicp}" desc="公安网备案号,常见于页脚" />
</el-form-item>
<el-form-item label="版权信息" prop="copyright">
<el-input v-model="form.copyright" placeholder="如:© 2026 公司名称 版权所有" />
<TagTip tag="{yz:copyright}" desc="常用于页脚" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="handleSave">保存设置</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref } from 'vue'
import TagTip from './TagTip.vue'
defineProps({
// 与父组件共享的表单对象(key 即标签名)
form: { type: Object, required: true },
saving: { type: Boolean, default: false }
})
const emit = defineEmits(['save', 'reset'])
const formRef = ref(null)
const handleSave = () => emit('save')
const handleReset = () => {
formRef.value?.clearValidate()
emit('reset')
}
</script>
<style lang="less" scoped>
.tab-form {
max-width: 720px;
}
</style>
@@ -0,0 +1,184 @@
<template>
<div class="container-box website-setting">
<div class="header-bar">
<h2>站点设置</h2>
</div>
<el-alert
class="tips"
type="info"
:closable="false"
show-icon
title="每一项都对应一个官网模板标签 {yz:标签名},数据保存在 yz_platform_site_setting(通用键值表,key 即标签名)。"
/>
<div class="setting-container" v-loading="loading">
<el-tabs v-model="activeTab" class="setting-tabs">
<el-tab-pane label="基本设置" name="basic">
<tab-basic
v-if="activeTab === 'basic'"
:form="form"
:saving="saving"
@save="handleSave"
@reset="resetForm"
/>
</el-tab-pane>
<el-tab-pane label="公司信息" name="company">
<tab-company
v-if="activeTab === 'company'"
:form="form"
:saving="saving"
@save="handleSave"
@reset="resetForm"
/>
</el-tab-pane>
<el-tab-pane label="备案信息" name="record">
<tab-record
v-if="activeTab === 'record'"
:form="form"
:saving="saving"
@save="handleSave"
@reset="resetForm"
/>
</el-tab-pane>
<el-tab-pane label="政策条款" name="policy">
<tab-policy
v-if="activeTab === 'policy'"
:form="form"
:saving="saving"
@save="handleSave"
@reset="resetForm"
/>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getWebsiteSetting, saveWebsiteSetting } from '@/api/websiteSetting'
import tabBasic from './components/tab_basic.vue'
import tabCompany from './components/tab_company.vue'
import tabRecord from './components/tab_record.vue'
import tabPolicy from './components/tab_policy.vue'
const activeTab = ref('basic')
const loading = ref(false)
const saving = ref(false)
// 表单字段的 key 与数据表的 tag 一一对应(提交时即为 { 标签名: 值 })
const form = reactive({
// 基本设置
sitename: '', // {yz:sitename}
subtitle: '', // {yz:subtitle}
logo: '', // {yz:logo}
logow: '', // {yz:logow}
// 公司信息
companyname: '', // {yz:companyname}
phone: '', // {yz:phone}
email: '', // {yz:email}
address: '', // {yz:address}
worktime: '', // {yz:worktime}
// 备案信息
icp: '', // {yz:icp}
policeicp: '', // {yz:policeicp}
copyright: '', // {yz:copyright}
// 政策条款
privacy: '', // {yz:privacy}
terms: '' // {yz:terms}
})
// 拉取站点设置:接口返回 values(标签名 -> 值)回填到表单
const initSetting = async () => {
loading.value = true
try {
const res = await getWebsiteSetting()
if (res.code === 200 && res.data) {
const data = res.data.values || {}
Object.keys(form).forEach((key) => {
form[key] = data[key] || ''
})
} else {
ElMessage.error(res.msg || '获取站点设置失败')
}
} catch (e) {
ElMessage.error('获取站点设置失败')
} finally {
loading.value = false
}
}
// 保存:整个表单按「标签名 -> 值」提交,后端按 tag 逐项 upsert
const handleSave = async () => {
if (!String(form.sitename || '').trim()) {
ElMessage.warning('站点名称不能为空')
return
}
saving.value = true
try {
const res = await saveWebsiteSetting({ ...form })
if (res.code === 200) {
ElMessage.success('保存成功')
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (e) {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
// 重置:丢弃本地改动,重新拉取服务端配置
const resetForm = () => {
initSetting()
}
onMounted(() => {
initSetting()
})
</script>
<style lang="less" scoped>
.container-box {
padding: 20px;
border-radius: 4px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--el-text-color-primary);
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.tips {
margin-top: 16px;
}
.setting-container {
margin-top: 16px;
}
.setting-tabs {
:deep(.el-tabs__header) {
margin-bottom: 20px;
}
:deep(.el-tabs__nav-wrap::after) {
height: 1px;
}
}
</style>
+60
View File
@@ -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<string, string> & {
/** {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<SiteSetting | null> {
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;
}
}
+84 -8
View File
@@ -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 指令解析(<p v-yz>{yz:icp}</p>),未配置或后端未返回时输出空字符串
*/
export const siteTags = reactive<Record<string, string>>({});
/**
* 拉取站点设置并回填 siteInfo(应用启动时调用一次)。
* 接口异常时静默处理,页面继续使用默认配置。
*/
export async function loadSiteInfo(): Promise<void> {
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<NavItem[]>(() => [
{ 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);
+48
View File
@@ -0,0 +1,48 @@
/**
* {yz:xxx} 站点标签指令(dedecms 风格)
*
* 用法:在模板里直接写标签,元素加 v-yz 即可
* <p v-yz>{yz:icp}</p>
* <p v-yz>备案号:{yz:icp} | {yz:policeicp}</p>
*
* 元素内所有 {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<HTMLElement, () => 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<HTMLElement> = {
mounted(el) {
// watchEffect 会追踪 render 中读取到的标签值;
// 站点设置是异步拉取的,数据到位后会自动重新渲染
stops.set(el, watchEffect(() => render(el)));
},
unmounted(el) {
stops.get(el)?.();
stops.delete(el);
},
};
export default yz;
+40 -8
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { footerColumns, policyLinks, siteInfo } from "@/config/site";
import { footerColumns, isExternalLink, policyLinks, siteInfo } from "@/config/site";
</script>
<template>
@@ -8,8 +8,19 @@ import { footerColumns, policyLinks, siteInfo } from "@/config/site";
<!-- 品牌与联系方式 -->
<div class="footer-brand">
<RouterLink to="/" class="footer-brand__logo">
<span class="footer-brand__mark">{{ siteInfo.brandMark }}</span>
<span class="footer-brand__name">{{ siteInfo.fullName }}</span>
<!-- 站点设置 - 浅色 Logo({yz:logow}),未配置时回退品牌文字标记 -->
<img
v-if="siteInfo.logoLight"
:src="siteInfo.logoLight"
:alt="siteInfo.brandName"
class="footer-brand__logo-img"
/>
<template v-else>
<span class="footer-brand__mark">{{ siteInfo.brandMark }}</span>
<span class="footer-brand__name">{{
siteInfo.fullName || siteInfo.brandName
}}</span>
</template>
</RouterLink>
<ul class="footer-info">
@@ -17,9 +28,12 @@ import { footerColumns, policyLinks, siteInfo } from "@/config/site";
<span>地址</span>{{ siteInfo.address }}(邮编
{{ siteInfo.postalCode }})
</li>
<li>
<span>备案</span>京ICP备 00000000 号-1 | 京公网安备
11010800000000 号
<!-- 站点设置 - ICP 备案({yz:icp})与公安备案({yz:policeicp}) -->
<li v-if="siteInfo.icp || siteInfo.policeIcp">
<span>备案</span>
{{ siteInfo.icp }}
<template v-if="siteInfo.icp && siteInfo.policeIcp"> | </template>
{{ siteInfo.policeIcp }}
</li>
<li>
<span>联系</span>服务热线:{{ siteInfo.phone }} · 商务合作:{{
@@ -48,12 +62,22 @@ import { footerColumns, policyLinks, siteInfo } from "@/config/site";
<div class="site-footer__bottom">
<div class="container site-footer__bottom-inner">
<p class="copyright">{{ siteInfo.copyright }}</p>
<!-- 站点设置 - ICP 备案号:标签模式,{yz:icp} 由 v-yz 指令替换为站点设置中的值 -->
<p class="copyright" v-yz>{yz:icp}</p>
<div class="footer-end">
<ul class="policy-links">
<li v-for="link in policyLinks" :key="link.path">
<RouterLink :to="link.path">{{ link.label }}</RouterLink>
<!-- 站点设置 - 隐私政策({yz:privacy})/ 使用条款({yz:terms}),可能为站外完整 URL -->
<a
v-if="isExternalLink(link.path)"
:href="link.path"
target="_blank"
rel="noopener"
>
{{ link.label }}
</a>
<RouterLink v-else :to="link.path">{{ link.label }}</RouterLink>
</li>
</ul>
@@ -204,6 +228,14 @@ import { footerColumns, policyLinks, siteInfo } from "@/config/site";
margin-bottom: 26px;
}
/* 站点设置下发的浅色 Logo {yz:logow} */
&__logo-img {
height: 38px;
width: auto;
max-width: 210px;
object-fit: contain;
}
&__mark {
display: flex;
align-items: center;
+20 -3
View File
@@ -48,10 +48,19 @@ watch(
<div class="header-main">
<div class="container header-main__inner">
<RouterLink to="/" class="brand" aria-label="返回首页">
<span class="brand__mark">{{ siteInfo.brandMark }}</span>
<!-- 站点设置 - 深色 Logo({yz:logo}),未配置时回退品牌文字标记 -->
<img
v-if="siteInfo.logoDark"
:src="siteInfo.logoDark"
:alt="siteInfo.brandName"
class="brand__logo"
/>
<span v-else class="brand__mark">{{ siteInfo.brandMark }}</span>
<span class="brand__text">
<!-- 站点设置 - 站点名称({yz:sitename}) -->
<strong>{{ siteInfo.brandName }}</strong>
<em>{{ siteInfo.brandSub }}</em>
<!-- 站点设置 - 副标题({yz:subtitle}) -->
<em>{{ siteInfo.subtitle || siteInfo.brandSub }}</em>
</span>
</RouterLink>
@@ -184,6 +193,14 @@ watch(
gap: 12px;
flex-shrink: 0;
/* 站点设置下发的深色 Logo {yz:logo} */
&__logo {
height: 42px;
width: auto;
max-width: 190px;
object-fit: contain;
}
&__mark {
display: flex;
align-items: center;
@@ -201,7 +218,7 @@ watch(
&__text {
display: flex;
flex-direction: column;
line-height: 1.15;
line-height: 1.4;
strong {
font-size: 20px;
+8
View File
@@ -2,8 +2,16 @@ import { createApp } from "vue";
import App from "./App.vue";
import router from "./router"; // 配置路由
import store from "./store"; // 配置vuex
import { loadSiteInfo } from "./config/site"; // 站点设置:品牌/Logo/备案/版权/隐私政策等(与官网模板标签同源)
import { yz } from "./directives/yz"; // {yz:xxx} 站点标签解析指令
import "./styles/index.less";
const app = createApp(App);
// 注册 {yz:xxx} 标签指令:模板里写 <p v-yz>{yz:icp}</p> 即可
app.directive("yz", yz);
// 站点设置先于渲染发起,接口异常时静默回退到 config/site.ts 内的默认配置
void loadSiteInfo();
app.use(router).use(store).mount("#app");
+4 -2
View File
@@ -1,5 +1,6 @@
//index.ts
import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router";
import { siteInfo } from "@/config/site"; // 站点设置(站点名称 {yz:sitename} 等)
const DefaultLayout = () => import("../layout/DefaultLayout.vue");
const HomeView = () => import("../views/home/index.vue");
@@ -192,10 +193,11 @@ const router = createRouter({
},
});
/** 根据路由 meta 同步浏览器标题 */
/** 根据路由 meta 同步浏览器标题(站点名称取自站点设置 {yz:sitename}) */
router.afterEach((to) => {
const title = (to.meta.title as string | undefined) ?? "";
document.title = title ? `${title} | 云泽科技` : "云泽科技 | 官方网站";
const siteName = siteInfo.brandName || siteInfo.sitename;
document.title = title ? `${title} | ${siteName}` : `${siteName} | 官方网站`;
});
export default router;
+5 -3
View File
@@ -8,6 +8,7 @@ import PageHero from "@/components/PageHero.vue";
import BlockRenderer from "@/components/BlockRenderer.vue";
import ContentCard from "@/components/ContentCard.vue";
import AppIcon from "@/components/AppIcon.vue";
import { siteInfo } from "@/config/site";
/**
* 通用详情页
@@ -57,10 +58,11 @@ async function load() {
related.value = recommend;
loading.value = false;
// 详情页标题跟随内容变化
// 详情页标题跟随内容变化(站点名称取自站点设置 {yz:sitename})
const siteName = siteInfo.brandName || siteInfo.sitename;
document.title = detail
? `${detail.title} | 云泽科技`
: `${current.title} | 云泽科技`;
? `${detail.title} | ${siteName}`
: `${current.title} | ${siteName}`;
}
watch(() => `${section.value.key}|${slug.value}`, load, { immediate: true });
@@ -1,7 +1,6 @@
<script setup lang="ts">
import heroImage from "@/assets/images/hero-cover.svg";
const crumbs = ["首页", "关于云泽", "企业简介"];
import { siteInfo } from "@/config/site";
</script>
<template>
@@ -13,7 +12,8 @@ const crumbs = ["首页", "关于云泽", "企业简介"];
<div class="container hero__inner">
<h1 class="hero__title">云泽科技</h1>
<!-- 站点设置 - 站点名称({yz:sitename}) -->
<h1 class="hero__title">{{ siteInfo.brandName }}</h1>
<p class="hero__desc">
以云计算与数据智能为核心,为企业提供从咨询规划、产品研发到落地运维的<br />
+5 -3
View File
@@ -6,6 +6,7 @@ import { fetchSinglePage } from "@/api/content";
import PageHero from "@/components/PageHero.vue";
import BlockRenderer from "@/components/BlockRenderer.vue";
import AppIcon from "@/components/AppIcon.vue";
import { siteInfo } from "@/config/site";
/**
* 通用单页
@@ -29,10 +30,11 @@ watch(
page.value = (await fetchSinglePage(key.value)) ?? null;
loading.value = false;
// 单页标题跟随内容变化
// 单页标题跟随内容变化(站点名称取自站点设置 {yz:sitename})
const siteName = siteInfo.brandName || siteInfo.sitename;
document.title = page.value
? `${page.value.title} | 云泽科技`
: "云泽科技 | 官方网站";
? `${page.value.title} | ${siteName}`
: `${siteName} | 官方网站`;
},
{ immediate: true }
);