更新website

This commit is contained in:
2026-09-17 23:42:41 +08:00
parent 67e819c629
commit 1a4471e34d
18 changed files with 1922 additions and 187 deletions
+103 -6
View File
@@ -76,6 +76,8 @@ interface RemoteListData {
interface RemoteCategoryItem {
id: number;
/** 父级分类ID,0 为顶级 */
cid?: number;
name: string;
desc?: string;
image?: string;
@@ -262,7 +264,37 @@ export async function fetchCategoryCounts(
}
/**
* 分类列表:已接入后端的栏目取后端分类,其余取 config/sections 的静态配置。
* 后端分类是扁平列表(带 cid),这里在前端按 cid 组树。
* 顶级分类作为筛选条第一行,子分类作为第二行。
* 父级不存在(数据异常)时视为顶级,避免分类丢失。
*/
function buildCategoryTree(raw: RemoteCategoryItem[]): Category[] {
const nodes: Category[] = raw.map((item) => ({
slug: String(item.id),
name: item.name,
desc: item.desc ?? "",
parent: item.cid ? String(item.cid) : "",
children: [],
}));
const bySlug = new Map(nodes.map((node) => [node.slug, node]));
const roots: Category[] = [];
nodes.forEach((node) => {
const parent = node.parent ? bySlug.get(node.parent) : undefined;
if (parent) {
parent.children?.push(node);
} else {
roots.push(node);
}
});
return roots;
}
/**
* 分类列表:已接入后端的栏目取后端分类(返回父子树),
* 其余取 config/sections 的静态配置。
* 分类 slug 统一用字符串,便于路由参数传递。
*/
export async function fetchCategories(section: SectionKey): Promise<Category[]> {
@@ -270,11 +302,7 @@ export async function fetchCategories(section: SectionKey): Promise<Category[]>
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 buildCategoryTree(data?.list ?? []);
}
return sections[section].categories;
}
@@ -286,3 +314,72 @@ export async function fetchSinglePage(
await tick();
return singlePages[key];
}
/* ============================================================
* 站点公告(yz_platform_website_notice)
* ============================================================ */
/** /site/notices 返回的公告结构 */
interface RemoteNotice {
id: number;
title: string;
summary?: string;
type?: number;
link_url?: string;
is_popup?: number;
top?: number;
date?: string | null;
}
export interface NoticeItem {
id: string;
title: string;
summary: string;
type: number;
typeLabel: string;
/** 跳转地址:站内相对路径或完整 URL,为空表示不可点击 */
linkUrl: string;
/** linkUrl 是否站外地址 */
external: boolean;
top: boolean;
/** YYYY-MM-DD,无发布时间时为空串 */
date: string;
}
/** 公告类型:与平台端「官网管理 - 站点公告」保持一致 */
const NOTICE_TYPE_LABELS: Record<number, string> = {
1: "通知公告",
2: "活动公告",
3: "系统维护",
};
/**
* 站点公告列表(首页 banner 下方的「弹窗公告」区)
*
* 后端已按「已发布 + 处于有效期内」过滤,已过期、未发布、已下线的公告
* 不会返回 —— 因此调用方只需判断返回是否为空,为空即隐藏公告区。
* 接口异常时同样返回空数组,由页面降级隐藏,不影响新闻中心展示。
*/
export async function fetchNotices(
options: { popup?: boolean; limit?: number } = {}
): Promise<NoticeItem[]> {
const data = await getJSON<{ list: RemoteNotice[] }>("/site/notices", {
popup: options.popup ? 1 : undefined,
limit: options.limit ?? 3,
});
return (data?.list ?? []).map((raw) => {
const linkUrl = raw.link_url || "";
return {
id: String(raw.id),
title: raw.title || "",
summary: raw.summary || "",
type: raw.type ?? 1,
typeLabel: NOTICE_TYPE_LABELS[raw.type ?? 1] ?? "通知公告",
linkUrl,
external: /^https?:\/\//i.test(linkUrl),
top: raw.top === 1,
date: formatDate(raw.date),
};
});
}
+82 -5
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from "vue";
import type { Category } from "@/types/content";
import { findCategory, findTopCategory } from "@/utils/category";
import AppIcon from "./AppIcon.vue";
const props = defineProps<{
@@ -18,11 +19,46 @@ const emit = defineEmits<{
(e: "update:keyword", value: string): void;
}>();
/** 当前选中分类的描述 */
const activeDesc = computed(() => {
if (!props.active) return "";
return props.categories.find((item) => item.slug === props.active)?.desc ?? "";
/** 当前选中分类(可能是二级分类) */
const activeItem = computed(() =>
props.active ? findCategory(props.categories, props.active) : undefined
);
/** 当前所属的一级分类 */
const activeTop = computed(() => {
const item = activeItem.value;
if (!item) return undefined;
return props.categories.some((top) => top.slug === item.slug)
? item
: findTopCategory(props.categories, item.slug);
});
/**
* 二级分类行:首位是「全部」(选中它等于选中整个一级分类),其后是各子分类。
* 返回空数组表示当前一级分类没有子分类,此时整行隐藏。
*/
const subTabs = computed(() => {
const top = activeTop.value;
const children = top?.children ?? [];
if (!top || children.length === 0) return [];
const toTab = (slug: string, name: string) => ({
slug,
name,
count: props.counts[slug] ?? 0,
});
return [
toTab(top.slug, "全部"),
...children.map((child) => toTab(child.slug, child.name)),
];
});
/** 一级分类高亮:自身被选中,或它的某个子分类被选中 */
const isTopActive = (item: Category) =>
props.active === item.slug || activeTop.value?.slug === item.slug;
/** 当前选中分类的描述 */
const activeDesc = computed(() => activeItem.value?.desc ?? "");
</script>
<template>
@@ -44,7 +80,7 @@ const activeDesc = computed(() => {
<button
type="button"
class="filter-tab"
:class="{ 'is-active': active === item.slug }"
:class="{ 'is-active': isTopActive(item) }"
@click="emit('update:active', item.slug)"
>
{{ item.name }}
@@ -66,6 +102,23 @@ const activeDesc = computed(() => {
</label>
</div>
<!-- 二级分类:仅当当前一级分类存在子分类时出现 -->
<div v-if="subTabs.length" class="filter-bar__row filter-bar__row--sub">
<ul class="filter-bar__tabs">
<li v-for="item in subTabs" :key="item.slug">
<button
type="button"
class="filter-tab filter-tab--sub"
:class="{ 'is-active': active === item.slug }"
@click="emit('update:active', item.slug)"
>
{{ item.name }}
<em>{{ item.count }}</em>
</button>
</li>
</ul>
</div>
<p v-if="activeDesc" class="filter-bar__desc">{{ activeDesc }}</p>
</div>
</template>
@@ -93,6 +146,13 @@ const activeDesc = computed(() => {
flex-wrap: wrap;
}
/* 二级分类行:缩进 + 左侧引导线,体现层级 */
&__row--sub {
margin-left: 6px;
padding-left: 14px;
border-left: 2px solid @line;
}
&__desc {
font-size: 13px;
line-height: 1.8;
@@ -132,6 +192,23 @@ const activeDesc = computed(() => {
color: rgba(255, 255, 255, 0.6);
}
}
/* 二级分类:更小的胶囊 + 虚线边框,与一级分类区分层级 */
&--sub {
padding: 6px 14px;
font-size: 13px;
border-style: dashed;
em {
font-size: 11px;
}
&.is-active {
background: @blue-600;
border-color: @blue-600;
border-style: solid;
}
}
}
.filter-search {
+2 -105
View File
@@ -44,7 +44,7 @@ import { footerColumns, isExternalLink, policyLinks, siteInfo } from "@/config/s
</div>
<!-- 链接分组 -->
<div class="footer-links">
<!-- <div class="footer-links">
<div
v-for="column in footerColumns"
:key="column.title"
@@ -57,7 +57,7 @@ import { footerColumns, isExternalLink, policyLinks, siteInfo } from "@/config/s
</li>
</ul>
</div>
</div>
</div> -->
</div>
<div class="site-footer__bottom">
@@ -80,109 +80,6 @@ import { footerColumns, isExternalLink, policyLinks, siteInfo } from "@/config/s
<RouterLink v-else :to="link.path">{{ link.label }}</RouterLink>
</li>
</ul>
<ul class="social-links">
<li>
<a href="#" aria-label="微博" title="微博">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M10.5 20c-4 0-7.5-2-7.5-4.6 0-1.6 1-3.4 2.7-5C7.6 9 9.7 8 11.4 8c.8 0 1.4.2 1.7.6.4.5.2 1.2-.1 1.9 0 0 .9-.4 1.5-.4 1 0 1.4.6 1.4 1.4 0 .5-.2 1-.6 1.5 1.3.4 2.2 1.3 2.2 2.4C17.5 17.6 14.6 20 10.5 20Z"
fill="none"
stroke="currentColor"
stroke-width="1.6"
/>
</svg>
</a>
</li>
<li>
<a href="#" aria-label="微信" title="微信">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M9 4.5c-3.6 0-6.5 2.4-6.5 5.4 0 1.7.9 3.2 2.4 4.2l-.6 2 2.3-1.2c.7.2 1.5.3 2.4.3h.5"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M21.5 15.2c0-2.6-2.5-4.7-5.6-4.7s-5.6 2.1-5.6 4.7 2.5 4.7 5.6 4.7c.7 0 1.4-.1 2-.3l2 1-.5-1.7c1.3-.9 2.1-2.2 2.1-3.7Z"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linejoin="round"
/>
</svg>
</a>
</li>
<li>
<a href="#" aria-label="哔哩哔哩" title="哔哩哔哩">
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect
x="3"
y="7"
width="18"
height="13"
rx="3.5"
fill="none"
stroke="currentColor"
stroke-width="1.6"
/>
<path
d="M7.5 4.5 9.5 7M16.5 4.5 14.5 7M8.5 12v3M15.5 12v3"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
/>
</svg>
</a>
</li>
<li>
<a href="#" aria-label="抖音" title="抖音">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M14 3.5v10.2a3.6 3.6 0 1 1-3.6-3.6c.3 0 .6 0 .9.1"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
/>
<path
d="M14 3.5c.5 2.3 2.1 3.8 4.4 4"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
/>
</svg>
</a>
</li>
<li>
<a href="#" aria-label="邮箱" title="邮箱">
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect
x="3"
y="5.5"
width="18"
height="13"
rx="2.5"
fill="none"
stroke="currentColor"
stroke-width="1.6"
/>
<path
d="m4.5 8 7.5 5 7.5-5"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</a>
</li>
</ul>
</div>
</div>
</div>
+5 -1
View File
@@ -7,11 +7,15 @@
/** 栏目标识 */
export type SectionKey = "news" | "products" | "solutions" | "cases" | "events";
/** 分类 */
/** 分类(支持父子树:顶级分类的 parent 为 "" 或 "0") */
export interface Category {
slug: string;
name: string;
desc?: string;
/** 父级分类 slug,顶级为空串 */
parent?: string;
/** 子分类,叶子节点为空数组 */
children?: Category[];
}
/** 封面配色(暂用渐变替代实拍图) */
+34
View File
@@ -0,0 +1,34 @@
/**
* 分类树查找工具
*
* 后台「官网管理 - 分类管理」的分组支持父子两级(cid 指向父级),
* 前台筛选条按「一级 / 二级」两行渲染,列表页还要根据路由里的分类 slug
* 反查分类名与描述,因此把递归查找统一收在这里,避免各处重复实现。
*/
import type { Category } from "@/types/content";
/** 递归查找分类(子分类也能命中),未找到返回 undefined */
export function findCategory(
list: Category[],
slug: string
): Category | undefined {
for (const item of list) {
if (item.slug === slug) return item;
const hit = findCategory(item.children ?? [], slug);
if (hit) return hit;
}
return undefined;
}
/** 递归查找某分类所属的一级分类,找不到返回 undefined */
export function findTopCategory(
list: Category[],
slug: string
): Category | undefined {
for (const item of list) {
if ((item.children ?? []).some((child) => child.slug === slug)) return item;
const hit = findTopCategory(item.children ?? [], slug);
if (hit) return hit;
}
return undefined;
}
@@ -2,66 +2,42 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import type { ContentItem } from "@/types/content";
import { categoryName } from "@/config/sections";
import { fetchList } from "@/api/content";
interface Popup {
tags: string[];
title: string;
rows: [string, string][];
}
import { fetchList, fetchNotices, type NoticeItem } from "@/api/content";
/**
* 首页取新闻中心最新 4 条,
* 首页取新闻中心最新若干条,
* 卡片文案与详情页共用同一数据源,点击标题/卡片可直接进入详情。
*/
const notices = ref<ContentItem[]>([]);
const NOTICE_LIMIT = 4;
/** 新闻卡片池:无弹窗公告时新闻中心整行显示,每页多展示一张 */
const NOTICE_POOL = 6;
const popups: Popup[] = [
{
tags: ["市场活动", "线上直播"],
title: "数据中台实战公开课",
rows: [
["主办单位", "云泽科技"],
["主 讲 人", "产品总监 · 李明"],
["直播时间", "9 月 25 日 20:00"],
["参与方式", "扫码预约直播"],
],
},
{
tags: ["产品发布", "新版本"],
title: "云泽数据中台 V3.0 发布",
rows: [
["发布时间", "9 月 10 日"],
["版本亮点", "实时计算 · 指标管理"],
["试用方式", "官网申请免费试用"],
["咨询电话", "400-000-0000"],
],
},
{
tags: ["展会日程", "线下活动"],
title: "2026 云泽数智大会",
rows: [
["举办城市", "上海"],
["举办时间", "10 月 18 日 09:30"],
["举办地点", "国家会展中心 3 号馆"],
["报名方式", "官网在线报名"],
],
},
];
/**
* 站点公告(弹窗公告区)。
* 后端只回「已发布 + 处于有效期内」的弹窗公告,列表为空即代表无公告或
* 公告已过期,页面据此隐藏该区域、让新闻中心整行显示。
*/
const popups = ref<NoticeItem[]>([]);
const POPUP_LIMIT = 3;
/* ---------------- 公告轮播 ---------------- */
const pageSize = 2;
/** 是否存在可展示的弹窗公告,决定本区块的整体布局 */
const hasPopup = computed(() => popups.value.length > 0);
/** 每页卡片数:整行显示时放宽到 3 张,避免两张被拉得过宽 */
const pageSize = computed(() => (hasPopup.value ? 2 : 3));
const page = ref(0);
const progress = ref(0);
const playing = ref(true);
const pageCount = computed(() =>
Math.max(1, Math.ceil(notices.value.length / pageSize))
Math.max(1, Math.ceil(notices.value.length / pageSize.value))
);
const currentNotices = computed(() =>
notices.value.slice(page.value * pageSize, page.value * pageSize + pageSize)
notices.value.slice(
page.value * pageSize.value,
page.value * pageSize.value + pageSize.value
)
);
let timer: number | undefined;
@@ -101,18 +77,27 @@ const togglePlay = () => {
/* ---------------- 弹窗公告轮播 ---------------- */
const popupIndex = ref(0);
const currentPopup = computed(() => popups[popupIndex.value]);
const currentPopup = computed<NoticeItem | null>(
() => popups.value[popupIndex.value] ?? popups.value[0] ?? null
);
const stepPopup = (delta: number) => {
popupIndex.value = (popupIndex.value + delta + popups.length) % popups.length;
if (popups.value.length === 0) return;
popupIndex.value =
(popupIndex.value + delta + popups.value.length) % popups.value.length;
};
onMounted(async () => {
const result = await fetchList({
section: "news",
page: 1,
pageSize: NOTICE_LIMIT,
});
// 新闻与公告并行拉取;公告为空时只隐藏弹窗区,不影响新闻中心
const [result, noticeList] = await Promise.all([
fetchList({
section: "news",
page: 1,
pageSize: NOTICE_POOL,
}),
fetchNotices({ popup: true, limit: POPUP_LIMIT }),
]);
notices.value = result.list;
popups.value = noticeList;
startTimer();
});
@@ -120,8 +105,9 @@ onBeforeUnmount(stopTimer);
</script>
<template>
<section class="notice">
<div class="container notice__grid">
<section class="notice" :class="{ 'notice--nopopup': !hasPopup }">
<!-- 无弹窗公告(未配置 / 已过期 / 已下线)时整行显示新闻中心 -->
<div class="container notice__grid" :class="{ 'is-single': !hasPopup }">
<!-- 通知公告 -->
<div class="notice__main">
<header class="notice__head">
@@ -141,7 +127,7 @@ onBeforeUnmount(stopTimer);
</RouterLink>
</header>
<div class="notice__cards">
<div class="notice__cards" :class="{ 'is-wide': !hasPopup }">
<RouterLink
v-for="item in currentNotices"
:key="item.slug"
@@ -220,10 +206,10 @@ onBeforeUnmount(stopTimer);
</div>
<!-- 弹窗公告 -->
<aside class="popup">
<aside v-if="currentPopup" class="popup">
<header class="popup__head">
<h2>弹窗公告</h2>
<div class="popup__tools">
<div v-if="popups.length > 1" class="popup__tools">
<button type="button" aria-label="上一条弹窗" @click="stepPopup(-1)">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
@@ -253,17 +239,33 @@ onBeforeUnmount(stopTimer);
</header>
<transition name="popup-fade" mode="out-in">
<div :key="popupIndex" class="popup__card">
<div :key="currentPopup.id" class="popup__card">
<ul class="popup__tags">
<li v-for="tag in currentPopup.tags" :key="tag">{{ tag }}</li>
<li>{{ currentPopup.typeLabel }}</li>
<li v-if="currentPopup.top">置顶</li>
</ul>
<h3>{{ currentPopup.title }}</h3>
<p v-if="currentPopup.summary" class="popup__summary">
{{ currentPopup.summary }}
</p>
<ul class="popup__rows">
<li v-for="[label, value] in currentPopup.rows" :key="label">
<span>{{ label }}</span>
<em>{{ value }}</em>
<li>
<span>发布时间</span>
<em>{{ currentPopup.date || "-" }}</em>
</li>
</ul>
<!-- 公告配置了跳转地址才给出入口,避免指向不存在的页面 -->
<div v-if="currentPopup.linkUrl" class="popup__action">
<a
v-if="currentPopup.external"
:href="currentPopup.linkUrl"
target="_blank"
rel="noopener"
>
查看详情
</a>
<RouterLink v-else :to="currentPopup.linkUrl">查看详情</RouterLink>
</div>
</div>
</transition>
</aside>
@@ -279,10 +281,20 @@ onBeforeUnmount(stopTimer);
padding: 74px 0 96px;
height: 650px;
/* 无弹窗公告时按内容自适应高度(固定 650px 是为右侧弹窗卡片预留的) */
&--nopopup {
height: auto;
}
&__grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 470px;
gap: 70px;
/* 无弹窗公告:新闻中心整行显示 */
&.is-single {
grid-template-columns: minmax(0, 1fr);
}
}
&__head {
@@ -333,6 +345,11 @@ onBeforeUnmount(stopTimer);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
/* 整行显示时放宽到三列,避免两张卡被拉得过宽 */
&.is-wide {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
&__controls {
@@ -537,6 +554,39 @@ onBeforeUnmount(stopTimer);
}
}
/* 公告摘要:超过三行截断,避免卡片高度失控 */
&__summary {
margin-bottom: 20px;
font-size: 13.5px;
line-height: 1.85;
color: rgba(255, 255, 255, 0.82);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 仅在公告配置了跳转地址时出现 */
&__action {
margin-top: 22px;
a {
display: inline-flex;
align-items: center;
padding: 9px 22px;
border-radius: 100px;
background: @white;
font-size: 13px;
font-weight: 500;
color: @navy-800;
transition: opacity 0.22s ease;
&:hover {
opacity: 0.86;
}
}
}
&__tags {
display: flex;
gap: 8px;
@@ -601,6 +651,11 @@ onBeforeUnmount(stopTimer);
grid-template-columns: minmax(0, 1fr) 380px;
gap: 44px;
}
/* is-single 特异性更高,需在此断点同样覆盖,否则中屏会退回两列 */
&__grid.is-single {
grid-template-columns: minmax(0, 1fr);
}
}
}
@@ -621,7 +676,8 @@ onBeforeUnmount(stopTimer);
font-size: 22px;
}
&__cards {
&__cards,
&__cards.is-wide {
grid-template-columns: minmax(0, 1fr);
}
}
+3 -2
View File
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from "vue-router";
import type { Category, ContentItem } from "@/types/content";
import { sections } from "@/config/sections";
import { fetchCategories, fetchCategoryCounts, fetchList } from "@/api/content";
import { findCategory } from "@/utils/category";
import PageHero from "@/components/PageHero.vue";
import FilterBar from "@/components/FilterBar.vue";
import ContentCard from "@/components/ContentCard.vue";
@@ -44,10 +45,10 @@ 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)
? findCategory(categories.value, activeCategory.value)
: undefined
);