更新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
+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 }
);