增加网站管理功能

This commit is contained in:
2026-09-17 23:00:06 +08:00
parent 1224735aa8
commit 67e819c629
98 changed files with 8711 additions and 2254 deletions
+175 -7
View File
@@ -1,10 +1,15 @@
import type {
Block,
Category,
ContentItem,
CoverTone,
ListQuery,
ListResult,
SectionKey,
SinglePage,
} from "@/types/content";
import { sections } from "@/config/sections";
import { assetUrl, getJSON, postJSON } from "./request";
import { products } from "@/data/products";
import { solutions } from "@/data/solutions";
import { cases } from "@/data/cases";
@@ -14,10 +19,20 @@ import { singlePages } from "@/data/pages";
/**
* 内容数据访问层
* 目前读取本地 mock 数据,接入后端 CMS 后只需把下面几个方法
* 换成 request.get("/cms/...") 即可,调用方无需改动。
*
* 已接入后端真实数据的栏目见 REMOTE_SECTIONS(数据来自 platform 端「官网管理」,
* 落库 yz_platform_website_*,由 Go 的 /site/* 接口对外提供)。
* 其余栏目仍读本地 mock,后续可逐个迁移 —— 接入时只需把栏目 key 加进
* REMOTE_SECTIONS 并补上对应的 fetchXxx 分支,调用方(list/detail 页)无需改动。
*/
/** 已接入后端接口的栏目 */
const REMOTE_SECTIONS: SectionKey[] = ["news"];
const isRemote = (section: SectionKey): boolean =>
REMOTE_SECTIONS.includes(section);
/** 本地 mock 数据(未接入后端的栏目使用) */
const registry: Record<SectionKey, ContentItem[]> = {
news,
products,
@@ -26,14 +41,121 @@ const registry: Record<SectionKey, ContentItem[]> = {
events,
};
/* ============================================================
* 后端数据 -> 页面内容模型
* ============================================================ */
/** /site/news 系列接口返回的文章结构 */
interface RemoteArticle {
id: number;
title: string;
summary?: string;
/** 文章自身的封面图 */
image?: string;
content?: string;
author?: string;
views?: number;
top?: number;
recommend?: number;
date?: string | null;
category_id?: number;
category_name?: string;
category_desc?: string;
/** 所属分类的图片,文章没有封面图时作为兜底 */
category_image?: string;
tags?: string[];
}
interface RemoteListData {
list: RemoteArticle[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
interface RemoteCategoryItem {
id: number;
name: string;
desc?: string;
image?: string;
count: number;
}
/** 封面配色池:后端数据没有配色字段,按内容ID稳定取色,避免每次渲染跳色 */
const TONES: CoverTone[] = ["navy", "blue", "teal", "indigo"];
const toneOf = (id: number): CoverTone =>
TONES[Math.abs(id || 0) % TONES.length];
/** 后端时间(RFC3339)-> YYYY-MM-DD */
function formatDate(value?: string | null): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/** 后端文章 -> 页面内容模型 */
function toContentItem(raw: RemoteArticle, withContent = false): ContentItem {
const blocks: Block[] = [];
// 正文由平台「文章管理」用富文本编辑器录入,这里整体作为一个 html 区块交给渲染器
if (withContent && raw.content) {
blocks.push({ type: "html", html: raw.content });
}
return {
slug: String(raw.id),
section: "news",
category: raw.category_id ? String(raw.category_id) : "",
categoryName: raw.category_name || "",
title: raw.title || "",
// 后端没有独立的副标题字段,列表卡片的 eyebrow 留空,不编造内容
subtitle: "",
summary: raw.summary || "",
tone: toneOf(raw.id),
date: formatDate(raw.date),
author: raw.author || "",
tags: raw.tags ?? [],
featured: raw.recommend === 1 || raw.top === 1,
// 封面兜底链:文章封面 -> 所属分类图片 -> 空(为空时 ContentCard 按 tone 渲染渐变占位)
image: assetUrl(raw.image) || assetUrl(raw.category_image),
blocks,
};
}
/* ============================================================
* 对外方法
* ============================================================ */
/** 模拟一次异步请求,保证调用方始终按异步处理 */
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
/** 列表 / 分类 / 搜索(带分页) */
export async function fetchList(query: ListQuery): Promise<ListResult> {
await tick();
const { section, category, keyword, page = 1, pageSize = 6 } = query;
if (isRemote(section)) {
const data = await getJSON<RemoteListData>("/site/news", {
page,
pageSize,
category,
keyword,
});
// 接口异常时不回落 mock:官网展示真实数据,无数据就展示空态,便于发现问题
if (!data) {
return { list: [], total: 0, page, pageSize, totalPages: 1 };
}
return {
list: (data.list ?? []).map((raw) => toContentItem(raw)),
total: data.total ?? 0,
page: data.page ?? page,
pageSize: data.pageSize ?? pageSize,
totalPages: data.totalPages ?? 1,
};
}
await tick();
let list = [...(registry[section] ?? [])];
if (category) {
@@ -71,6 +193,16 @@ export async function fetchDetail(
section: SectionKey,
slug: string
): Promise<ContentItem | undefined> {
if (isRemote(section)) {
const data = await getJSON<RemoteArticle>(
`/site/news/${encodeURIComponent(slug)}`
);
if (!data) return undefined;
// 浏览量 +1,失败不影响正文展示
void postJSON(`/site/news/${encodeURIComponent(slug)}/view`);
return toContentItem(data, true);
}
await tick();
return (registry[section] ?? []).find((item) => item.slug === slug);
}
@@ -81,8 +213,15 @@ export async function fetchRelated(
slug: string,
limit = 3
): Promise<ContentItem[]> {
await tick();
if (isRemote(section)) {
const data = await getJSON<{ list: RemoteArticle[] }>(
`/site/news/${encodeURIComponent(slug)}/related`,
{ limit }
);
return (data?.list ?? []).map((raw) => toContentItem(raw));
}
await tick();
const all = registry[section] ?? [];
const current = all.find((item) => item.slug === slug);
if (!current) return all.slice(0, limit);
@@ -97,12 +236,23 @@ export async function fetchRelated(
return [...sameCategory, ...others].slice(0, limit);
}
/** 统计各分类下的内容数量,用于筛选条上的角标 */
/** 统计各分类下的内容数量,用于筛选条上的角标;__all__ 为总数 */
export async function fetchCategoryCounts(
section: SectionKey
): Promise<Record<string, number>> {
await tick();
if (isRemote(section)) {
const counts: Record<string, number> = {};
const data = await getJSON<{ list: RemoteCategoryItem[]; total: number }>(
"/site/news/categories"
);
(data?.list ?? []).forEach((item) => {
counts[String(item.id)] = item.count ?? 0;
});
counts.__all__ = data?.total ?? 0;
return counts;
}
await tick();
const counts: Record<string, number> = {};
(registry[section] ?? []).forEach((item) => {
counts[item.category] = (counts[item.category] ?? 0) + 1;
@@ -111,6 +261,24 @@ export async function fetchCategoryCounts(
return counts;
}
/**
* 分类列表:已接入后端的栏目取后端分类,其余取 config/sections 的静态配置。
* 分类 slug 统一用字符串,便于路由参数传递。
*/
export async function fetchCategories(section: SectionKey): Promise<Category[]> {
if (isRemote(section)) {
const data = await getJSON<{ list: RemoteCategoryItem[] }>(
"/site/news/categories"
);
return (data?.list ?? []).map((item) => ({
slug: String(item.id),
name: item.name,
desc: item.desc ?? "",
}));
}
return sections[section].categories;
}
/** 单页:key 为去掉前导斜杠的路径,例如 about、support/docs */
export async function fetchSinglePage(
key: string
+69
View File
@@ -0,0 +1,69 @@
/**
* 官网 SPA 轻量请求封装
*
* 基地址规则与 api/site.ts 保持一致:
* 开发期 .env.development 的 VITE_API_BASE_URL 指向本地 Go 服务(http://localhost:9000),
* 未配置时回落 /api,由 vite 代理到后端。
*
* 约定:所有接口返回后端统一结构 { code, msg, data },
* 这里只把 data 交给调用方;请求失败或 code !== 200 时返回 null,
* 由调用方决定展示空态还是降级,避免异常直接冒泡到页面。
*/
const API_BASE = (
(import.meta as any).env?.VITE_API_BASE_URL || "/api"
).replace(/\/$/, "");
/** 拼接口完整地址 */
export function apiUrl(path: string): string {
return `${API_BASE}${path.startsWith("/") ? "" : "/"}${path}`;
}
/**
* 后端返回的资源地址补全为可访问地址。
* 图片等资源存的是 /uploads/... 相对路径,需要拼上接口域名。
*/
export function assetUrl(path?: string): string {
if (!path) return "";
if (/^https?:\/\//i.test(path)) return path;
return apiUrl(path);
}
/** GET 请求,返回 data;失败返回 null */
export async function getJSON<T>(
path: string,
params?: Record<string, unknown>
): Promise<T | null> {
try {
const qs = new URLSearchParams();
Object.entries(params ?? {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === "") return;
qs.append(key, String(value));
});
const query = qs.toString();
const res = await fetch(apiUrl(path) + (query ? `?${query}` : ""), {
headers: { Accept: "application/json" },
});
if (!res.ok) return null;
const body = await res.json();
if (body?.code !== 200) return null;
return (body.data ?? null) as T | null;
} catch (error) {
console.warn(`[api] GET ${path} 请求失败`, error);
return null;
}
}
/** POST 请求(无请求体),用于浏览量累加等副作用接口;失败静默 */
export async function postJSON(path: string): Promise<boolean> {
try {
const res = await fetch(apiUrl(path), {
method: "POST",
headers: { Accept: "application/json" },
});
return res.ok;
} catch (error) {
console.warn(`[api] POST ${path} 请求失败`, error);
return false;
}
}
+116
View File
@@ -0,0 +1,116 @@
<script setup lang="ts">
/**
* 全站「回到顶部」按钮
*
* 已在 main.ts 注册为全局组件,DefaultLayout 中渲染一次即对全站生效。
* 页面滚动超过 SHOW_OFFSET 后淡入,点击平滑回到页面顶部。
*/
import { onBeforeUnmount, onMounted, ref } from "vue";
/** 页面滚动超过该距离才显示按钮 */
const SHOW_OFFSET = 320;
const visible = ref(false);
const onScroll = () => {
visible.value = window.scrollY > SHOW_OFFSET;
};
const toTop = () => {
// html 上已有 scroll-behavior: smooth,这里显式声明以避免被局部样式覆盖
window.scrollTo({ top: 0, behavior: "smooth" });
};
onMounted(() => {
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
});
onBeforeUnmount(() => {
window.removeEventListener("scroll", onScroll);
});
</script>
<template>
<transition name="backtop">
<button
v-show="visible"
class="backtop"
type="button"
title="回到顶部"
aria-label="回到顶部"
@click="toTop"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 19.5V5m0 0-6.4 6.4M12 5l6.4 6.4"
fill="none"
stroke="currentColor"
stroke-width="1.9"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</transition>
</template>
<style scoped lang="less">
.backtop {
position: fixed;
right: 36px;
bottom: 44px;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
color: @white;
background: linear-gradient(140deg, @navy-700, @navy-900);
box-shadow: 0 12px 28px rgba(11, 20, 48, 0.26);
transition: transform 0.25s ease, box-shadow 0.25s ease;
svg {
width: 20px;
height: 20px;
}
&:hover {
transform: translateY(-3px);
background: linear-gradient(140deg, @blue-600, @navy-700);
box-shadow: 0 16px 34px rgba(47, 107, 243, 0.34);
}
&:focus-visible {
outline: 2px solid @blue-600;
outline-offset: 3px;
}
}
.backtop-enter-active,
.backtop-leave-active {
transition: opacity 0.28s ease, transform 0.28s ease;
}
.backtop-enter-from,
.backtop-leave-to {
opacity: 0;
transform: translateY(14px) scale(0.9);
}
@media (max-width: 768px) {
.backtop {
right: 18px;
bottom: 24px;
width: 42px;
height: 42px;
svg {
width: 17px;
height: 17px;
}
}
}
</style>
+172 -1
View File
@@ -8,8 +8,15 @@ defineProps<{ blocks: Block[] }>();
<template>
<div class="blocks">
<template v-for="(block, index) in blocks" :key="index">
<!-- 富文本正文:后端(平台「文章管理」)录入的 HTML 原样渲染 -->
<div
v-if="block.type === 'html'"
class="blk-html"
v-html="block.html"
></div>
<!-- 小标题 -->
<header v-if="block.type === 'heading'" class="blk-heading">
<header v-else-if="block.type === 'heading'" class="blk-heading">
<h2>{{ block.text }}</h2>
<p v-if="block.sub">{{ block.sub }}</p>
</header>
@@ -543,12 +550,176 @@ defineProps<{ blocks: Block[] }>();
}
}
/* ---------------- 富文本正文 ---------------- */
.blk-html {
font-size: 15.5px;
line-height: 2.05;
color: @ink-600;
word-break: break-word;
> :first-child {
margin-top: 0;
}
h1,
h2,
h3,
h4 {
margin: 34px 0 14px;
padding-left: 16px;
border-left: 3px solid @blue-600;
font-weight: 700;
line-height: 1.5;
color: @ink-900;
}
h1 {
font-size: 24px;
}
h2 {
font-size: 22px;
}
h3 {
font-size: 18px;
}
h4 {
font-size: 16px;
}
p {
margin: 16px 0;
}
a {
color: @blue-600;
text-decoration: underline;
&:hover {
opacity: 0.82;
}
}
strong {
font-weight: 700;
color: @ink-800;
}
ul,
ol {
margin: 16px 0;
padding-left: 26px;
li {
margin: 8px 0;
line-height: 1.95;
}
}
ul li {
list-style: disc;
}
ol li {
list-style: decimal;
}
img {
max-width: 100%;
height: auto;
margin: 18px 0;
border-radius: 10px;
}
blockquote {
margin: 22px 0;
padding: 22px 26px;
border-radius: 12px;
border-left: 3px solid @blue-600;
background: linear-gradient(140deg, #f5f8ff 0%, #eef3fd 100%);
color: @ink-800;
}
hr {
margin: 30px 0;
border: 0;
border-top: 1px solid @line;
}
pre {
margin: 18px 0;
padding: 18px 22px;
border-radius: 10px;
background: @bg-soft;
overflow-x: auto;
font-size: 13.5px;
line-height: 1.75;
}
code {
padding: 2px 7px;
border-radius: 4px;
background: @bg-soft;
font-family: Consolas, Monaco, "Courier New", monospace;
font-size: 13.5px;
color: @ink-800;
}
pre code {
padding: 0;
background: transparent;
}
table {
width: 100%;
margin: 20px 0;
border-collapse: collapse;
font-size: 14px;
th,
td {
padding: 13px 18px;
border: 1px solid @line;
text-align: left;
line-height: 1.75;
}
th {
background: @bg-soft;
font-weight: 600;
color: @ink-600;
}
}
}
/* ---------------- 响应式 ---------------- */
@media (max-width: 768px) {
.blocks {
gap: 24px;
}
.blk-html {
font-size: 14.5px;
line-height: 1.95;
h1,
h2,
h3,
h4 {
margin-top: 26px;
}
h1 {
font-size: 21px;
}
h2 {
font-size: 19px;
}
}
.blk-list,
.blk-features,
.blk-steps,
+29 -4
View File
@@ -17,8 +17,10 @@ const to = computed(
() => `${sections[props.item.section].detailPath}/${props.item.slug}`
);
const categoryLabel = computed(() =>
categoryName(props.item.section, props.item.category)
// 后端数据自带分类名;本地 mock 走 config/sections 查表
const categoryLabel = computed(
() =>
props.item.categoryName || categoryName(props.item.section, props.item.category)
);
/** 封面上的装饰文字:取标题首字,中文视觉上很像一个字标 */
@@ -28,8 +30,18 @@ const glyph = computed(() => props.item.title.slice(0, 1));
<template>
<RouterLink :to="to" class="content-card" :class="`content-card--${layout}`">
<div class="content-card__cover" :class="`tone-${item.tone}`">
<span class="content-card__deco" aria-hidden="true"></span>
<span class="content-card__glyph" aria-hidden="true">{{ glyph }}</span>
<!-- 有真实封面图时优先展示,否则用渐变 + 首字占位 -->
<img
v-if="item.image"
class="content-card__img"
:src="item.image"
:alt="item.title"
loading="lazy"
/>
<template v-else>
<span class="content-card__deco" aria-hidden="true"></span>
<span class="content-card__glyph" aria-hidden="true">{{ glyph }}</span>
</template>
<span class="content-card__badge">{{ categoryLabel }}</span>
</div>
@@ -93,6 +105,19 @@ const glyph = computed(() => props.item.title.slice(0, 1));
color: @white;
}
&__img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.6s cubic-bezier(0.22, 0.61, 0.36, 1);
}
&:hover &__img {
transform: scale(1.05);
}
&__deco {
position: absolute;
right: -34px;
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { navItems, siteInfo, utilityLinks } from "@/config/site";
import { siteInfo, utilityLinks } from "@/config/site";
// 页头主导航来自后端「官网管理 - 导航菜单」,未配置时回退 config/site.ts 的静态菜单
import { navMenus } from "@/config/nav";
const route = useRoute();
@@ -28,6 +30,13 @@ watch(
menuOpen.value = false;
}
);
// 点击「首页」:已在首页时属于重复导航,不会触发 router 的滚动重置,这里手动回到顶部
const onHomeClick = () => {
if (route.path === "/") {
window.scrollTo({ top: 0, behavior: "smooth" });
}
};
</script>
<template>
@@ -65,14 +74,48 @@ watch(
</RouterLink>
<nav class="main-nav">
<RouterLink
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="main-nav__link"
<!-- 静态首页入口:固定在最左,不受后台「导航菜单」配置影响 -->
<div class="main-nav__item">
<RouterLink to="/" class="main-nav__link is-home" @click="onHomeClick">首页</RouterLink>
</div>
<div
v-for="item in navMenus"
:key="item.key"
class="main-nav__item"
:class="{ 'is-parent': item.children.length > 0 }"
>
{{ item.label }}
</RouterLink>
<!-- 外链菜单新窗口打开 -->
<a
v-if="item.external"
:href="item.path"
target="_blank"
rel="noopener"
class="main-nav__link"
>
{{ item.label }}
</a>
<RouterLink v-else :to="item.path" class="main-nav__link">
{{ item.label }}
</RouterLink>
<!-- 二级菜单:后台「目录」类型下配置的子菜单 -->
<div v-if="item.children.length" class="main-nav__sub">
<template v-for="child in item.children" :key="child.key">
<a
v-if="child.external"
:href="child.path"
target="_blank"
rel="noopener"
>
{{ child.label }}
</a>
<RouterLink v-else :to="child.path">
{{ child.label }}
</RouterLink>
</template>
</div>
</div>
</nav>
<button
@@ -94,14 +137,42 @@ watch(
<transition name="drawer">
<nav v-show="menuOpen" class="mobile-nav">
<div class="container">
<RouterLink
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="mobile-nav__link"
>
{{ item.label }}
</RouterLink>
<!-- 静态首页入口:与主导航保持一致,固定在最前 -->
<RouterLink to="/" class="mobile-nav__link" @click="onHomeClick">首页</RouterLink>
<template v-for="item in navMenus" :key="item.key">
<a
v-if="item.external"
:href="item.path"
target="_blank"
rel="noopener"
class="mobile-nav__link"
>
{{ item.label }}
</a>
<RouterLink v-else :to="item.path" class="mobile-nav__link">
{{ item.label }}
</RouterLink>
<template v-for="child in item.children" :key="child.key">
<a
v-if="child.external"
:href="child.path"
target="_blank"
rel="noopener"
class="mobile-nav__link mobile-nav__link--sub"
>
{{ child.label }}
</a>
<RouterLink
v-else
:to="child.path"
class="mobile-nav__link mobile-nav__link--sub"
>
{{ child.label }}
</RouterLink>
</template>
</template>
<div class="mobile-nav__util">
<RouterLink
v-for="item in utilityLinks"
@@ -274,6 +345,61 @@ watch(
transform: scaleX(1);
}
}
/*
* 静态「首页」入口:to="/" 的 router-link-active 会命中所有子路由,
* 因此仅在精确匹配或悬停时才高亮,避免首页菜单全程处于选中态。
*/
&.is-home.router-link-active:not(.router-link-exact-active):not(:hover) {
color: @ink-800;
&::after {
transform: scaleX(0);
}
}
}
/* 一级菜单项容器:作为二级下拉的定位参照 */
&__item {
position: relative;
&:hover > .main-nav__sub {
opacity: 1;
visibility: visible;
transform: translate(-50%, 0);
}
}
/* 二级菜单(后台「目录」菜单下的子菜单) */
&__sub {
position: absolute;
left: 50%;
top: 100%;
z-index: 30;
min-width: 172px;
padding: 8px 0;
border-radius: 0 0 10px 10px;
background: @white;
box-shadow: 0 18px 40px rgba(11, 20, 48, 0.14);
opacity: 0;
visibility: hidden;
transform: translate(-50%, 8px);
transition: opacity 0.22s ease, transform 0.22s ease,
visibility 0.22s ease;
a {
display: block;
padding: 10px 22px;
font-size: 14.5px;
color: @ink-700;
white-space: nowrap;
transition: color 0.2s ease, background-color 0.2s ease;
&:hover {
background: @bg-soft;
color: @blue-600;
}
}
}
}
@@ -322,6 +448,14 @@ watch(
font-weight: 500;
color: @ink-800;
border-bottom: 1px solid @line;
/* 二级菜单:缩进以体现层级 */
&--sub {
padding-left: 22px;
font-size: 15px;
font-weight: 400;
color: @ink-600;
}
}
&__util {
+77
View File
@@ -0,0 +1,77 @@
/**
* 页头导航菜单配置
*
* 数据来源:后端「官网管理 - 导航菜单」(yz_platform_website_nav),
* 由 GET /site/nav 下发(仅启用且显示中的菜单,已按 pid 组好树)。
* 接口异常或后台未配置任何菜单时,回退到 config/site.ts 里的静态 navItems,
* 保证页头导航不会为空。
*
* 菜单类型(与平台端一致):
* 1 目录 仅分组,本身可带 path;配置了子菜单时页头渲染为下拉
* 2 页面 路由地址
* 3 外链 完整 URL,新窗口打开
* 4 单页 路由地址(内容取自「单页管理」)
*/
import { ref } from "vue";
import { getJSON } from "@/api/request";
import { navItems } from "./site";
export interface NavMenuItem {
/** v-for key:后端菜单用 nav-{id},静态回退用 static-{path} */
key: string;
label: string;
path: string;
/** 是否站外链接:后台菜单类型为「外链」或 path 填写了完整 URL */
external: boolean;
children: NavMenuItem[];
}
/** /site/nav 返回的节点结构 */
interface RemoteNavNode {
id: number;
pid: number;
title: string;
type: number;
path: string;
icon?: string;
external?: boolean;
children?: RemoteNavNode[];
}
const isExternalPath = (path: string): boolean => /^https?:\/\//i.test(path);
function toMenuItem(node: RemoteNavNode): NavMenuItem {
const path = node.path || "/";
return {
key: `nav-${node.id}`,
label: node.title || "",
path,
external: node.external ?? (node.type === 3 || isExternalPath(path)),
children: (node.children ?? []).map(toMenuItem),
};
}
/** 静态回退菜单:后台未配置时使用 */
const fallbackMenus = (): NavMenuItem[] =>
navItems.map((item) => ({
key: `static-${item.path}`,
label: item.label,
path: item.path,
external: isExternalPath(item.path),
children: [],
}));
/** 页头导航菜单(应用启动时由 loadNavMenus 拉取,初始为静态回退) */
export const navMenus = ref<NavMenuItem[]>(fallbackMenus());
/**
* 拉取后台配置的导航菜单(应用启动时调用一次)。
* 接口异常或后台未配置时保持静态回退菜单,不置空。
*/
export async function loadNavMenus(): Promise<void> {
const data = await getJSON<{ list: RemoteNavNode[] }>("/site/nav");
const list = data?.list ?? [];
if (list.length > 0) {
navMenus.value = list.map(toMenuItem);
}
}
+8 -2
View File
@@ -1,6 +1,10 @@
<script setup lang="ts">
import SiteHeader from "./SiteHeader.vue";
import SiteFooter from "./SiteFooter.vue";
/**
* 官网根布局:页头 + 路由出口 + 页脚
*
* SiteHeader / SiteFooter 已在 main.ts 注册为全局组件,
* 本文件(以及任意页面)直接使用标签即可,无需 import。
*/
</script>
<template>
@@ -10,6 +14,8 @@ import SiteFooter from "./SiteFooter.vue";
<RouterView />
</main>
<SiteFooter />
<!-- 全站回到顶部 -->
<BackTop />
</div>
</template>
+12
View File
@@ -3,15 +3,27 @@ import App from "./App.vue";
import router from "./router"; // 配置路由
import store from "./store"; // 配置vuex
import { loadSiteInfo } from "./config/site"; // 站点设置:品牌/Logo/备案/版权/隐私政策等(与官网模板标签同源)
import { loadNavMenus } from "./config/nav"; // 页头导航菜单(后台「官网管理 - 导航菜单」)
import { yz } from "./directives/yz"; // {yz:xxx} 站点标签解析指令
// 全局组件:官网页头 / 页脚 / 回到顶部
import SiteHeader from "./components/SiteHeader.vue";
import SiteFooter from "./components/SiteFooter.vue";
import BackTop from "./components/BackTop.vue";
import "./styles/index.less";
const app = createApp(App);
// 注册全局组件:任意页面直接写 <SiteHeader /> / <SiteFooter /> / <BackTop /> 即可,无需各自 import
app.component("SiteHeader", SiteHeader);
app.component("SiteFooter", SiteFooter);
app.component("BackTop", BackTop);
// 注册 {yz:xxx} 标签指令:模板里写 <p v-yz>{yz:icp}</p> 即可
app.directive("yz", yz);
// 站点设置先于渲染发起,接口异常时静默回退到 config/site.ts 内的默认配置
void loadSiteInfo();
// 页头导航菜单:接口异常或后台未配置时回退静态菜单
void loadNavMenus();
app.use(router).use(store).mount("#app");
+9
View File
@@ -173,6 +173,15 @@ const routes: RouteRecordRaw[] = [
meta: { title: "政策条款" },
},
// ---------------- 兼容后台「导航菜单」中配置的旧路径 ----------------
// 平台端「官网管理 - 导航菜单」当前配的是 CMS 风格路径,与 SPA 实际路由不一致,
// 这里做一层重定向,保证点菜单能落到正确页面。
// 推荐把导航菜单的路径改成 /news、/products、/about(/solutions、/cases 本来就一致),
// 改完后以下 3 条即可删除。
{ path: "newscenter", redirect: "/news" },
{ path: "productcenter", redirect: "/products" },
{ path: "aboutus", redirect: "/about" },
// ---------------- 兜底 ----------------
{
path: ":pathMatch(.*)*",
+10 -1
View File
@@ -53,6 +53,8 @@ export interface QuickLink {
/** 正文区块:详情页与单页共用同一套渲染器 */
export type Block =
/** 后端富文本正文(平台「文章管理」录入的 HTML),由 BlockRenderer 原样渲染 */
| { type: "html"; html: string }
| { type: "heading"; text: string; sub?: string }
| { type: "paragraph"; text: string }
| { type: "quote"; text: string; author?: string }
@@ -69,8 +71,10 @@ export type Block =
export interface ContentItem {
slug: string;
section: SectionKey;
/** 分类 slug */
/** 分类 slug(接入后端真实数据时为分类ID的字符串形式) */
category: string;
/** 分类展示名:后端数据直接带回,本地 mock 走 config/sections 查表 */
categoryName?: string;
title: string;
subtitle: string;
summary: string;
@@ -79,6 +83,11 @@ export interface ContentItem {
author?: string;
tags?: string[];
featured?: boolean;
/**
* 封面图完整地址。
* 兜底链:文章封面 -> 所属分类的图片 -> 空(为空时按 tone 渲染渐变占位封面)
*/
image?: string;
blocks: Block[];
}
+16
View File
@@ -0,0 +1,16 @@
/**
* 全局组件类型声明
*
* 与 main.ts 中 app.component(...) 的注册名保持一致,
* 让模板里的 <SiteHeader /> / <SiteFooter /> 获得类型提示与跳转。
* 新增全局组件时,两处都要补上。
*/
import type SiteHeader from "@/components/SiteHeader.vue";
import type SiteFooter from "@/components/SiteFooter.vue";
declare module "vue" {
export interface GlobalComponents {
SiteHeader: typeof SiteHeader;
SiteFooter: typeof SiteFooter;
}
}
+9 -2
View File
@@ -31,9 +31,16 @@ const item = ref<ContentItem | null>(null);
const related = ref<ContentItem[]>([]);
const loading = ref(true);
/** 分类展示名:后端数据直接带回,本地 mock 走 config/sections 查表 */
const categoryLabel = computed(() =>
item.value
? item.value.categoryName || categoryName(section.value.key, item.value.category)
: ""
);
const crumbs = computed(() =>
item.value
? [section.value.title, categoryName(section.value.key, item.value.category)]
? [section.value.title, categoryLabel.value]
: [section.value.title]
);
@@ -133,7 +140,7 @@ watch(() => `${section.value.key}|${slug.value}`, load, { immediate: true });
<li>
<span>分类</span>
<RouterLink :to="backPath">
{{ categoryName(section.key, item.category) }}
{{ categoryLabel }}
</RouterLink>
</li>
<li v-if="item.date">
@@ -4,35 +4,35 @@ import { siteInfo } from "@/config/site";
</script>
<template>
<section class="hero">
<div class="hero__media">
<section class="banner">
<div class="banner__media">
<img :src="heroImage" alt="云泽科技团队协作场景" />
</div>
<div class="hero__veil"></div>
<div class="banner__veil"></div>
<div class="container hero__inner">
<div class="container banner__inner">
<!-- 站点设置 - 站点名称({yz:sitename}) -->
<h1 class="hero__title">{{ siteInfo.brandName }}</h1>
<h1 class="banner__title">{{ siteInfo.brandName }}</h1>
<p class="hero__desc">
<p class="banner__desc">
以云计算与数据智能为核心,为企业提供从咨询规划、产品研发到落地运维的<br />
一站式数字化解决方案,让技术真正服务于业务增长。
</p>
<div class="hero__rule"></div>
<div class="banner__rule"></div>
<div class="hero__meta">
<div class="hero__stat">
<div class="banner__meta">
<div class="banner__stat">
<strong>12 年</strong>
<span class="hero__stat-label">行业深耕</span>
<span class="banner__stat-label">行业深耕</span>
<em>服务 1000+ 企业客户</em>
</div>
<div class="hero__divider"></div>
<div class="banner__divider"></div>
<div class="hero__stat hero__stat--contact">
<span class="hero__stat-icon" aria-hidden="true">
<div class="banner__stat banner__stat--contact">
<span class="banner__stat-icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path
d="M6.6 3.5h2.6l1.5 3.7-1.9 1.3a12.4 12.4 0 0 0 5.7 5.7l1.3-1.9 3.7 1.5v2.6a2 2 0 0 1-2.2 2C10.7 17.7 6.3 13.3 4.6 5.7a2 2 0 0 1 2-2.2Z"
@@ -44,7 +44,7 @@ import { siteInfo } from "@/config/site";
</svg>
</span>
<strong>400-000-0000</strong>
<span class="hero__stat-label">售前咨询专线</span>
<span class="banner__stat-label">售前咨询专线</span>
<em>工作日 9:00 - 18:00</em>
</div>
</div>
@@ -53,7 +53,7 @@ import { siteInfo } from "@/config/site";
</template>
<style scoped lang="less">
.hero {
.banner {
position: relative;
overflow: hidden;
background: @navy-900;
@@ -194,7 +194,7 @@ import { siteInfo } from "@/config/site";
letter-spacing: 0.02em;
}
.hero__stat-label {
.banner__stat-label {
margin-top: 0;
}
@@ -232,7 +232,7 @@ import { siteInfo } from "@/config/site";
/* ---------- 响应式 ---------- */
@media (max-width: 1200px) {
.hero {
.banner {
&__title {
font-size: 58px;
}
@@ -254,7 +254,7 @@ import { siteInfo } from "@/config/site";
}
@media (max-width: 768px) {
.hero {
.banner {
&__inner {
min-height: auto;
padding-top: 64px;
+24 -14
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { ContentItem } from "@/types/content";
import { categoryName, sections } from "@/config/sections";
import { fetchCategoryCounts, fetchList } from "@/api/content";
import type { Category, ContentItem } from "@/types/content";
import { sections } from "@/config/sections";
import { fetchCategories, fetchCategoryCounts, fetchList } from "@/api/content";
import PageHero from "@/components/PageHero.vue";
import FilterBar from "@/components/FilterBar.vue";
import ContentCard from "@/components/ContentCard.vue";
@@ -35,27 +35,35 @@ const counts = ref<Record<string, number>>({});
const total = ref(0);
const totalPages = ref(1);
const loading = ref(true);
/**
* 分类列表:已接入后端真实数据的栏目由接口返回,其余取 config/sections 静态配置。
* 初始为空,避免先闪出静态分类再被接口数据替换。
*/
const categories = ref<Category[]>([]);
/** 瀑布式列表每页 6 条,卡片网格每页 9 条 */
const pageSize = computed(() => (section.value.layout === "grid" ? 9 : 6));
/** 当前选中的分类(来自接口或静态配置) */
const activeCategoryItem = computed(() =>
activeCategory.value
? categories.value.find((item) => item.slug === activeCategory.value)
: undefined
);
const heroTitle = computed(() =>
activeCategory.value
? categoryName(section.value.key, activeCategory.value)
? activeCategoryItem.value?.name || section.value.title
: section.value.title
);
const heroSubtitle = computed(() => {
if (!activeCategory.value) return section.value.subtitle;
return (
section.value.categories.find((item) => item.slug === activeCategory.value)
?.desc || section.value.subtitle
);
});
const heroSubtitle = computed(
() => activeCategoryItem.value?.desc || section.value.subtitle
);
const crumbs = computed(() =>
activeCategory.value
? [section.value.title, categoryName(section.value.key, activeCategory.value)]
? [section.value.title, heroTitle.value]
: [section.value.title]
);
@@ -65,7 +73,7 @@ async function load() {
loading.value = true;
const current = section.value;
const [result, categoryCounts] = await Promise.all([
const [result, categoryCounts, categoryList] = await Promise.all([
fetchList({
section: current.key,
category: activeCategory.value || undefined,
@@ -74,12 +82,14 @@ async function load() {
pageSize: pageSize.value,
}),
fetchCategoryCounts(current.key),
fetchCategories(current.key),
]);
items.value = result.list;
total.value = result.total;
totalPages.value = result.totalPages;
counts.value = categoryCounts;
categories.value = categoryList;
loading.value = false;
}
@@ -108,7 +118,7 @@ function handleKeywordChange(value: string) {
<div class="container list-page__body">
<FilterBar
:categories="section.categories"
:categories="categories"
:active="activeCategory"
:counts="counts"
:total="counts.__all__ ?? 0"