增加网站管理功能
This commit is contained in:
+175
-7
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(.*)*",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user