feat: render CMS content fields and footer settings

This commit is contained in:
2026-10-11 19:44:54 +08:00
parent 870b9b4282
commit f990df3023
19 changed files with 250 additions and 38 deletions
+20
View File
@@ -42,6 +42,26 @@ Frontend tests: `node --test tests/seo.test.cjs`.
Backend tests (from the backend root):
`python -m pytest apps/core/tests.py -k "page_api_exposes_seo_fields or seo_image_serializer or notify_frontend_revalidate"`.
## CMS Display Fields
Products, solutions, cases and blog columns display the CMS title and introduction.
Lists and detail pages display optional cover images, summaries, industry/client
details, publication dates (Asia/Shanghai) and blog tags where applicable. Missing
images and optional fields are omitted. Detail previews request the same fields.
The footer reads the `footer` navigation menu and site settings for contact details,
WeChat account/QR code and Weibo link. An unconfigured menu falls back to the privacy
policy link; the existing ICP fallback is retained. Feature-grid icons follow the
same text-symbol convention as tech-stack icons.
Deploy the backend field declarations before rebuilding the frontend. These changes
use existing database fields and require no new migrations. Page publication uses
the shared cache tag; menu/site-setting changes retain the 60-second cache fallback.
Display/API regression tests (from the backend root):
`python -m pytest apps/blog/tests.py apps/products/tests.py apps/solutions/tests.py apps/cases/tests.py`.
Frontend display regressions run alongside SEO tests: `node --test tests/seo.test.cjs`.
## Learn More
To learn more about Next.js, take a look at the following resources:
+6 -4
View File
@@ -6,6 +6,7 @@ import { BlockRenderer } from "@/blocks/BlockRenderer";
import type { BlogPageDetail } from "@/types/wagtail";
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
import PageSEO from "@/components/layout/PageSEO";
import { BlogTags, PageCover, PublicationDate } from "@/components/layout/PageContent";
interface BlogDetailPageProps {
params: Promise<{ slug: string }>;
@@ -22,7 +23,7 @@ export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
const { slug } = await params;
const post = await getPreviewOrFallback<BlogPageDetail>(
"blog.blogpage",
"body,intro,published_at",
"body,intro,published_at,cover_image,tags",
() => getBlogBySlug(slug).catch(() => null),
);
@@ -34,9 +35,10 @@ export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
<article className="mx-auto max-w-3xl px-6 py-24">
<PageSEO type="blog.BlogPage" slug={slug} />
<h1 className="mb-4 text-4xl font-bold text-slate-900">{post.title}</h1>
{post.published_at && (
<p className="mb-12 text-sm text-slate-500">{post.published_at}</p>
)}
<PublicationDate value={post.published_at} />
<BlogTags tags={post.tags} />
{post.intro && <p className="my-6 text-lg text-slate-600">{post.intro}</p>}
<PageCover image={post.cover_image} />
<BlockRenderer blocks={post.body} />
</article>
);
+5 -1
View File
@@ -3,6 +3,7 @@ import type { Metadata } from "next";
import { getBlogList } from "@/services/blog.service";
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
import PageSEO from "@/components/layout/PageSEO";
import { BlogTags, ColumnHeading, PageCover, PublicationDate } from "@/components/layout/PageContent";
export async function generateMetadata(): Promise<Metadata> {
return createPageMetadata(await getPageSEO("blog.BlogIndexPage", "blog").catch(() => null), "技术博客");
@@ -14,7 +15,7 @@ export default async function BlogListPage() {
return (
<div className="mx-auto max-w-4xl px-6 py-24">
<PageSEO type="blog.BlogIndexPage" slug="blog" />
<h1 className="mb-12 text-4xl font-bold text-slate-900">技术博客</h1>
<ColumnHeading type="blog.BlogIndexPage" slug="blog" fallback="技术博客" />
<ul className="space-y-6">
{posts.map((post) => (
<li key={post.id} className="border-b border-slate-100 pb-6">
@@ -24,7 +25,10 @@ export default async function BlogListPage() {
>
{post.title}
</Link>
<PageCover image={post.cover_image} compact />
<PublicationDate value={post.published_at} />
{post.intro && <p className="mt-2 text-slate-600">{post.intro}</p>}
<BlogTags tags={post.tags} />
</li>
))}
{posts.length === 0 && (
+6 -2
View File
@@ -6,6 +6,7 @@ import { BlockRenderer } from "@/blocks/BlockRenderer";
import type { CaseStudyPageDetail } from "@/types/wagtail";
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
import PageSEO from "@/components/layout/PageSEO";
import { PageCover, PublicationDate } from "@/components/layout/PageContent";
interface CaseStudyDetailPageProps {
params: Promise<{ slug: string }>;
@@ -24,7 +25,7 @@ export default async function CaseStudyDetailPage({
const { slug } = await params;
const item = await getPreviewOrFallback<CaseStudyPageDetail>(
"cases.casestudypage",
"body,client_name,industry,published_at,summary",
"body,client_name,industry,published_at,summary,cover_image",
() => getCaseStudyBySlug(slug).catch(() => null),
);
@@ -36,10 +37,13 @@ export default async function CaseStudyDetailPage({
<article className="mx-auto max-w-3xl px-6 py-24">
<PageSEO type="cases.CaseStudyPage" slug={slug} />
<h1 className="mb-2 text-4xl font-bold text-slate-900">{item.title}</h1>
<p className="mb-12 text-sm text-slate-500">
<p className="mb-4 text-sm text-slate-500">
{item.client_name}
{item.industry ? ` · ${item.industry}` : ""}
</p>
<PublicationDate value={item.published_at} />
{item.summary && <p className="my-6 text-lg text-slate-600">{item.summary}</p>}
<PageCover image={item.cover_image} />
<BlockRenderer blocks={item.body} />
</article>
);
+4 -1
View File
@@ -3,6 +3,7 @@ import type { Metadata } from "next";
import { getCaseStudyList } from "@/services/case.service";
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
import PageSEO from "@/components/layout/PageSEO";
import { ColumnHeading, PageCover, PublicationDate } from "@/components/layout/PageContent";
export async function generateMetadata(): Promise<Metadata> {
return createPageMetadata(await getPageSEO("cases.CaseStudyIndexPage", "cases").catch(() => null), "客户案例");
@@ -14,7 +15,7 @@ export default async function CaseStudyListPage() {
return (
<div className="mx-auto max-w-4xl px-6 py-24">
<PageSEO type="cases.CaseStudyIndexPage" slug="cases" />
<h1 className="mb-12 text-4xl font-bold text-slate-900">客户案例</h1>
<ColumnHeading type="cases.CaseStudyIndexPage" slug="cases" fallback="客户案例" />
<ul className="space-y-6">
{caseStudies.map((item) => (
<li key={item.id} className="border-b border-slate-100 pb-6">
@@ -24,10 +25,12 @@ export default async function CaseStudyListPage() {
>
{item.title}
</Link>
<PageCover image={item.cover_image} compact />
<p className="mt-1 text-sm text-slate-500">
{item.client_name}
{item.industry ? ` · ${item.industry}` : ""}
</p>
<PublicationDate value={item.published_at} />
{item.summary && (
<p className="mt-2 text-slate-600">{item.summary}</p>
)}
+3 -1
View File
@@ -6,6 +6,7 @@ import { BlockRenderer } from "@/blocks/BlockRenderer";
import type { ProductPageDetail } from "@/types/wagtail";
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
import PageSEO from "@/components/layout/PageSEO";
import { PageCover } from "@/components/layout/PageContent";
interface ProductDetailPageProps {
params: Promise<{ slug: string }>;
@@ -24,7 +25,7 @@ export default async function ProductDetailPage({
const { slug } = await params;
const product = await getPreviewOrFallback<ProductPageDetail>(
"products.productpage",
"body,summary",
"body,summary,cover_image",
() => getProductBySlug(slug).catch(() => null),
);
@@ -41,6 +42,7 @@ export default async function ProductDetailPage({
{product.summary && (
<p className="mb-12 text-lg text-slate-600">{product.summary}</p>
)}
<PageCover image={product.cover_image} />
<BlockRenderer blocks={product.body} />
</article>
);
+3 -1
View File
@@ -3,6 +3,7 @@ import type { Metadata } from "next";
import { getProductList } from "@/services/product.service";
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
import PageSEO from "@/components/layout/PageSEO";
import { ColumnHeading, PageCover } from "@/components/layout/PageContent";
export async function generateMetadata(): Promise<Metadata> {
return createPageMetadata(await getPageSEO("products.ProductIndexPage", "products").catch(() => null), "产品");
@@ -14,7 +15,7 @@ export default async function ProductListPage() {
return (
<div className="mx-auto max-w-4xl px-6 py-24">
<PageSEO type="products.ProductIndexPage" slug="products" />
<h1 className="mb-12 text-4xl font-bold text-slate-900">产品</h1>
<ColumnHeading type="products.ProductIndexPage" slug="products" fallback="产品" />
<ul className="space-y-6">
{products.map((product) => (
<li key={product.id} className="border-b border-slate-100 pb-6">
@@ -24,6 +25,7 @@ export default async function ProductListPage() {
>
{product.title}
</Link>
<PageCover image={product.cover_image} compact />
{product.summary && (
<p className="mt-2 text-slate-600">{product.summary}</p>
)}
+5 -2
View File
@@ -6,6 +6,7 @@ import { BlockRenderer } from "@/blocks/BlockRenderer";
import type { SolutionPageDetail } from "@/types/wagtail";
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
import PageSEO from "@/components/layout/PageSEO";
import { PageCover } from "@/components/layout/PageContent";
interface SolutionDetailPageProps {
params: Promise<{ slug: string }>;
@@ -24,7 +25,7 @@ export default async function SolutionDetailPage({
const { slug } = await params;
const solution = await getPreviewOrFallback<SolutionPageDetail>(
"solutions.solutionpage",
"body,industry,summary",
"body,industry,summary,cover_image",
() => getSolutionBySlug(slug).catch(() => null),
);
@@ -39,8 +40,10 @@ export default async function SolutionDetailPage({
{solution.title}
</h1>
{solution.industry && (
<p className="mb-12 text-sm text-slate-500">{solution.industry}</p>
<p className="mb-4 text-sm text-slate-500">{solution.industry}</p>
)}
{solution.summary && <p className="mb-6 text-lg text-slate-600">{solution.summary}</p>}
<PageCover image={solution.cover_image} />
<BlockRenderer blocks={solution.body} />
</article>
);
+3 -1
View File
@@ -3,6 +3,7 @@ import type { Metadata } from "next";
import { getSolutionList } from "@/services/solution.service";
import { createPageMetadata, getPageSEO } from "@/services/seo.service";
import PageSEO from "@/components/layout/PageSEO";
import { ColumnHeading, PageCover } from "@/components/layout/PageContent";
export async function generateMetadata(): Promise<Metadata> {
return createPageMetadata(await getPageSEO("solutions.SolutionIndexPage", "solutions").catch(() => null), "解决方案");
@@ -14,7 +15,7 @@ export default async function SolutionListPage() {
return (
<div className="mx-auto max-w-4xl px-6 py-24">
<PageSEO type="solutions.SolutionIndexPage" slug="solutions" />
<h1 className="mb-12 text-4xl font-bold text-slate-900">解决方案</h1>
<ColumnHeading type="solutions.SolutionIndexPage" slug="solutions" fallback="解决方案" />
<ul className="space-y-6">
{solutions.map((solution) => (
<li key={solution.id} className="border-b border-slate-100 pb-6">
@@ -24,6 +25,7 @@ export default async function SolutionListPage() {
>
{solution.title}
</Link>
<PageCover image={solution.cover_image} compact />
{solution.industry && (
<p className="mt-1 text-sm text-slate-500">{solution.industry}</p>
)}
+1
View File
@@ -13,6 +13,7 @@ export default function FeatureGrid({ value }: { value: FeatureGridBlockValue })
key={`${item.title}-${index}`}
className="rounded-3xl bg-white p-8 shadow-sm"
>
{item.icon && <span aria-hidden="true" className="mb-4 block break-words text-3xl">{item.icon}</span>}
<h3 className="mb-3 text-2xl font-semibold text-slate-900">
{item.title}
</h3>
+21 -6
View File
@@ -1,4 +1,6 @@
import { getSiteSettings } from "@/services/site-settings.service";
import { getNavigationMenu } from "@/services/navigation.service";
import Image from "next/image";
// 合规要求:中国大陆网站需展示 ICP 备案号并链接至工信部备案查询。
// 详见 documents/设计方案分析与完善版.md §2.15。
@@ -8,22 +10,35 @@ const FALLBACK_ICP_URL = "https://beian.miit.gov.cn/";
export default async function Footer() {
// 优先使用后台配置的站点设置(apps.core.SiteSettings),
// 接口不可用时回退到硬编码的备案信息,保证构建/渲染始终可用。
const siteSettings = await getSiteSettings();
const [siteSettings, menu] = await Promise.all([getSiteSettings(), getNavigationMenu("footer")]);
const icpNumber = siteSettings?.icp_number || FALLBACK_ICP_NUMBER;
const icpUrl = siteSettings?.icp_url || FALLBACK_ICP_URL;
const qrcode = siteSettings?.wechat_qrcode;
const qrcodeUrl = qrcode?.url ? new URL(qrcode.url, process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000").href : null;
const navItems = menu?.items.length ? menu.items : [{ label: "隐私政策", url: "/privacy-policy", open_in_new_tab: false }];
return (
<footer className="mt-auto border-t border-slate-100 bg-slate-950 py-10 text-slate-400">
<div className="mx-auto max-w-7xl px-6 text-center text-sm">
<nav aria-label="页脚导航" className="mb-6 flex flex-wrap justify-center gap-x-6 gap-y-3">
{navItems.map((item, index) => <a key={`${item.url}-${index}`} href={item.url} target={item.open_in_new_tab ? "_blank" : undefined} rel={item.open_in_new_tab ? "noreferrer" : undefined} className="break-words hover:text-white">{item.label}</a>)}
</nav>
<address className="mb-6 space-y-2 break-words not-italic">
{siteSettings?.contact_phone && <p><a href={`tel:${siteSettings.contact_phone}`} className="hover:text-white">{siteSettings.contact_phone}</a></p>}
{siteSettings?.contact_email && <p><a href={`mailto:${siteSettings.contact_email}`} className="break-all hover:text-white">{siteSettings.contact_email}</a></p>}
{siteSettings?.address && <p>{siteSettings.address}</p>}
</address>
{(siteSettings?.wechat_account || qrcodeUrl || siteSettings?.weibo_url) && (
<div className="mb-6 space-y-3">
{qrcodeUrl && <Image src={qrcodeUrl} alt={qrcode?.title || "微信公众号二维码"} width={128} height={128} className="mx-auto h-32 w-32 object-contain" />}
{siteSettings?.wechat_account && <p className="break-words">{siteSettings.wechat_account}</p>}
{siteSettings?.weibo_url && <a href={siteSettings.weibo_url} target="_blank" rel="noreferrer" className="inline-block hover:text-white">微博</a>}
</div>
)}
<p>
&copy; {new Date().getFullYear()}{" "}
{siteSettings?.company_name || "企业官网"}. All rights reserved.
</p>
<p className="mt-2">
<a href="/privacy-policy" className="hover:text-white">
隐私政策
</a>
</p>
<p className="mt-2">
<a
href={icpUrl}
+33
View File
@@ -0,0 +1,33 @@
import Image from "next/image";
import { getColumnPage } from "@/services/page.service";
export async function ColumnHeading({ type, slug, fallback }: { type: string; slug: string; fallback: string }) {
const page = await getColumnPage(type, slug).catch(() => null);
return (
<header className="mb-12">
<h1 className="break-words text-4xl font-bold text-slate-900">{page?.title || fallback}</h1>
{page?.intro && <p className="mt-4 whitespace-pre-line break-words text-lg leading-relaxed text-slate-600">{page.intro}</p>}
</header>
);
}
export function PageCover({ image, compact = false }: { image?: { url: string; title: string } | null; compact?: boolean }) {
if (!image?.url) return null;
return (
<div className={`relative mt-4 mb-6 w-full ${compact ? "h-48 sm:h-64" : "aspect-[16/10]"}`}>
<Image src={image.url} alt={image.title || ""} fill sizes="(max-width: 768px) 100vw, 896px" className="object-contain" />
</div>
);
}
export function PublicationDate({ value }: { value?: string }) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return <time dateTime={value} className="mt-2 block text-sm text-slate-500">{new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", year: "numeric", month: "long", day: "numeric" }).format(date)}</time>;
}
export function BlogTags({ tags }: { tags?: string[] }) {
if (!tags?.length) return null;
return <ul aria-label="文章标签" className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm text-slate-500">{tags.map((tag) => <li key={tag} className="break-all">#{tag}</li>)}</ul>;
}
+2 -2
View File
@@ -10,7 +10,7 @@ import type {
*/
export async function getBlogList(): Promise<BlogPageSummary[]> {
const res = await fetchAPI<WagtailListResponse<BlogPageSummary>>(
"/api/v2/pages/?type=blog.BlogPage&fields=intro,published_at&order=-published_at",
"/api/v2/pages/?type=blog.BlogPage&fields=intro,published_at,cover_image,tags&order=-published_at",
{ tags: ["blog-list"] },
);
return res.items;
@@ -21,7 +21,7 @@ export async function getBlogList(): Promise<BlogPageSummary[]> {
*/
export async function getBlogBySlug(slug: string): Promise<BlogPageDetail | null> {
const res = await fetchAPI<WagtailListResponse<BlogPageDetail>>(
`/api/v2/pages/?type=blog.BlogPage&slug=${encodeURIComponent(slug)}&fields=body,intro,published_at&limit=1`,
`/api/v2/pages/?type=blog.BlogPage&slug=${encodeURIComponent(slug)}&fields=body,intro,published_at,cover_image,tags&limit=1`,
{ tags: [`blog:${slug}`] },
);
return res.items[0] ?? null;
+2 -2
View File
@@ -8,7 +8,7 @@ import type {
/** 获取客户案例列表(按发布时间倒序)。 */
export async function getCaseStudyList(): Promise<CaseStudyPageSummary[]> {
const res = await fetchAPI<WagtailListResponse<CaseStudyPageSummary>>(
"/api/v2/pages/?type=cases.CaseStudyPage&fields=client_name,industry,published_at,summary&order=-published_at",
"/api/v2/pages/?type=cases.CaseStudyPage&fields=client_name,industry,published_at,summary,cover_image&order=-published_at",
{ tags: ["case-study-list"] },
);
return res.items;
@@ -19,7 +19,7 @@ export async function getCaseStudyBySlug(
slug: string,
): Promise<CaseStudyPageDetail | null> {
const res = await fetchAPI<WagtailListResponse<CaseStudyPageDetail>>(
`/api/v2/pages/?type=cases.CaseStudyPage&slug=${encodeURIComponent(slug)}&fields=body,client_name,industry,published_at,summary&limit=1`,
`/api/v2/pages/?type=cases.CaseStudyPage&slug=${encodeURIComponent(slug)}&fields=body,client_name,industry,published_at,summary,cover_image&limit=1`,
{ tags: [`case-study:${slug}`] },
);
return res.items[0] ?? null;
+7 -1
View File
@@ -1,5 +1,11 @@
import { fetchAPI } from "./api-client";
import type { ContactPageDetail, HomePageDetail, WagtailListResponse } from "@/types/wagtail";
import type { ColumnPageDetail, ContactPageDetail, HomePageDetail, WagtailListResponse } from "@/types/wagtail";
export async function getColumnPage(type: string, slug: string): Promise<ColumnPageDetail | null> {
const query = new URLSearchParams({ type, slug, fields: "intro", limit: "1" });
const res = await fetchAPI<WagtailListResponse<ColumnPageDetail>>(`/api/v2/pages/?${query}`);
return res.items[0] ?? null;
}
/**
* 获取首页内容(取页面树中第一条 home.HomePage)。
+2 -2
View File
@@ -8,7 +8,7 @@ import type {
/** 获取产品列表。 */
export async function getProductList(): Promise<ProductPageSummary[]> {
const res = await fetchAPI<WagtailListResponse<ProductPageSummary>>(
"/api/v2/pages/?type=products.ProductPage&fields=summary",
"/api/v2/pages/?type=products.ProductPage&fields=summary,cover_image",
{ tags: ["product-list"] },
);
return res.items;
@@ -19,7 +19,7 @@ export async function getProductBySlug(
slug: string,
): Promise<ProductPageDetail | null> {
const res = await fetchAPI<WagtailListResponse<ProductPageDetail>>(
`/api/v2/pages/?type=products.ProductPage&slug=${encodeURIComponent(slug)}&fields=body,summary&limit=1`,
`/api/v2/pages/?type=products.ProductPage&slug=${encodeURIComponent(slug)}&fields=body,summary,cover_image&limit=1`,
{ tags: [`product:${slug}`] },
);
return res.items[0] ?? null;
+2 -2
View File
@@ -8,7 +8,7 @@ import type {
/** 获取解决方案列表。 */
export async function getSolutionList(): Promise<SolutionPageSummary[]> {
const res = await fetchAPI<WagtailListResponse<SolutionPageSummary>>(
"/api/v2/pages/?type=solutions.SolutionPage&fields=industry,summary",
"/api/v2/pages/?type=solutions.SolutionPage&fields=industry,summary,cover_image",
{ tags: ["solution-list"] },
);
return res.items;
@@ -19,7 +19,7 @@ export async function getSolutionBySlug(
slug: string,
): Promise<SolutionPageDetail | null> {
const res = await fetchAPI<WagtailListResponse<SolutionPageDetail>>(
`/api/v2/pages/?type=solutions.SolutionPage&slug=${encodeURIComponent(slug)}&fields=body,industry,summary&limit=1`,
`/api/v2/pages/?type=solutions.SolutionPage&slug=${encodeURIComponent(slug)}&fields=body,industry,summary,cover_image&limit=1`,
{ tags: [`solution:${slug}`] },
);
return res.items[0] ?? null;
+116 -10
View File
@@ -12,7 +12,7 @@ function loadModule(relativePath, mocks = {}, globals = {}) {
}).outputText;
const loadedModule = { exports: {} };
vm.runInNewContext(compiled, {
module: loadedModule, exports: loadedModule.exports, process, URLSearchParams,
module: loadedModule, exports: loadedModule.exports, process, URLSearchParams, URL,
require: (name) => name in mocks ? mocks[name] : require(name),
...globals,
});
@@ -21,7 +21,16 @@ function loadModule(relativePath, mocks = {}, globals = {}) {
const page = {
title: "Page title",
meta: { seo_title: "Standard title", search_description: "Standard description" },
id: 1,
meta: { slug: "test-slug", seo_title: "Standard title", search_description: "Standard description" },
cover_image: { url: "https://cms.shiytech.cn/media/cover.png", title: "Cover" },
summary: "Detail summary",
intro: "Blog introduction",
published_at: "2026-10-10T23:00:00Z",
client_name: "Client",
industry: "Manufacturing",
tags: ["Django"],
body: [],
seo_title_override: "Custom title | Shiytech",
seo_description_override: "Custom description",
canonical_url: "https://www.shiytech.cn/products",
@@ -29,6 +38,46 @@ const page = {
schema_json: { "@type": "CollectionPage" },
};
test("content services request covers, tags and column intros", async () => {
for (const [name, list, detail] of [
["product", "getProductList", "getProductBySlug"],
["solution", "getSolutionList", "getSolutionBySlug"],
["case", "getCaseStudyList", "getCaseStudyBySlug"],
["blog", "getBlogList", "getBlogBySlug"],
]) {
const calls = [];
const service = loadModule(`services/${name}.service.ts`, { "./api-client": { fetchAPI: async (url) => { calls.push(url); return { items: [page] }; } } });
await service[list]();
await service[detail]("a&b");
for (const url of calls) {
const query = new URLSearchParams(url.split("?")[1]);
assert.ok(query.get("fields").split(",").includes("cover_image"));
if (name === "blog") assert.ok(query.get("fields").split(",").includes("tags"));
}
assert.equal(new URLSearchParams(calls[1].split("?")[1]).get("slug"), "a&b");
}
let query;
const { getColumnPage } = loadModule("services/page.service.ts", { "./api-client": { fetchAPI: async (url) => { query = new URLSearchParams(url.split("?")[1]); return { items: [] }; } } });
assert.equal(await getColumnPage("blog.BlogIndexPage", "a&b"), null);
assert.equal(query.get("fields"), "intro");
assert.equal(query.get("slug"), "a&b");
});
test("content presentation handles covers, dates, tags and column fallback", async () => {
const content = loadModule("components/layout/PageContent.tsx", { "@/services/page.service": { getColumnPage: async () => ({ title: "Column", intro: "Introduction" }) } });
assert.equal(content.PageCover({ image: null }), null);
assert.equal(content.PageCover({ image: { url: "/cover.png", title: "Cover" } }).props.children.props.alt, "Cover");
assert.equal(content.PublicationDate({ value: "invalid" }), null);
assert.equal(content.PublicationDate({ value: "2026-10-10T23:00:00Z" }).props.children, "2026年10月11日");
assert.equal(content.BlogTags({ tags: [] }), null);
assert.equal(content.BlogTags({ tags: ["Django"] }).props.children[0].props.children[1], "Django");
const header = await content.ColumnHeading({ type: "blog.BlogIndexPage", slug: "blog", fallback: "Fallback" });
assert.equal(header.props.children[0].props.children, "Column");
assert.equal(header.props.children[1].props.children, "Introduction");
const empty = loadModule("components/layout/PageContent.tsx", { "@/services/page.service": { getColumnPage: async () => { throw new Error("offline"); } } });
assert.equal((await empty.ColumnHeading({ fallback: "Fallback" })).props.children[0].props.children, "Fallback");
});
test("SEO metadata includes absolute title, canonical and social image", () => {
const { createPageMetadata } = loadModule("services/seo.service.ts", { "./api-client": {} });
const metadata = createPageMetadata(page, "Fallback");
@@ -120,28 +169,31 @@ test("all CMS page routes wire metadata and JSON-LD to the correct page model",
];
const { createPageMetadata } = loadModule("services/seo.service.ts", { "./api-client": {} });
const PageSEO = () => null;
const content = { ColumnHeading: () => null, PageCover: () => null, PublicationDate: () => null, BlogTags: () => null };
const previews = [];
const service = {
getHomePage: async () => page, getContactPage: async () => page,
getProductList: async () => [], getSolutionList: async () => [],
getCaseStudyList: async () => [], getBlogList: async () => [],
getProductList: async () => [page], getSolutionList: async () => [page],
getCaseStudyList: async () => [page], getBlogList: async () => [page],
getProductBySlug: async () => page, getSolutionBySlug: async () => page,
getCaseStudyBySlug: async () => page, getBlogBySlug: async () => page,
getSimpleContentPageBySlug: async () => page,
};
const mocks = {
"@/components/layout/PageSEO": { default: PageSEO },
"@/components/layout/PageContent": content,
"@/blocks/BlockRenderer": { BlockRenderer: () => null },
"@/services/preview.service": { getPreviewOrFallback: async (type, fields, fallback) => fallback() },
"@/services/preview.service": { getPreviewOrFallback: async (type, fields, fallback) => { previews.push(fields); return fallback(); } },
};
for (const name of ["page", "product", "solution", "case", "blog", "legal"]) {
mocks[`@/services/${name}.service`] = service;
}
function findSEO(element) {
function findElement(element, type) {
if (!element || typeof element !== "object") return null;
if (element.type === PageSEO) return element;
const children = element.props?.children;
if (element.type === type) return element;
const children = Array.isArray(element) ? element : element.props?.children;
for (const child of Array.isArray(children) ? children : [children]) {
const found = findSEO(child);
const found = findElement(child, type);
if (found) return found;
}
return null;
@@ -159,9 +211,63 @@ test("all CMS page routes wire metadata and JSON-LD to the correct page model",
assert.equal((await route.generateMetadata(props)).title.absolute, page.seo_title_override, file);
assert.equal(calls[0].type, expectedType, file);
assert.equal(calls[0].slug, expectedSlug, file);
const element = findSEO(await route.default(props));
const tree = await route.default(props);
const element = findElement(tree, PageSEO);
assert.ok(element, file);
assert.equal(element.props.type, expectedType, file);
assert.equal(element.props.slug, expectedSlug, file);
if (/^(products|solutions|cases|blog)\./.test(expectedType)) {
assert.equal(findElement(tree, content.PageCover).props.image, page.cover_image, file);
if (expectedType.endsWith("IndexPage")) {
const heading = findElement(tree, content.ColumnHeading);
assert.equal(heading.props.type, expectedType, file);
assert.equal(heading.props.slug, expectedSlug, file);
} else {
assert.ok(previews.at(-1).split(",").includes("cover_image"), file);
}
if (/^(blog|cases)\./.test(expectedType)) assert.equal(findElement(tree, content.PublicationDate).props.value, page.published_at, file);
if (expectedType.startsWith("blog.")) {
assert.equal(findElement(tree, content.BlogTags).props.tags, page.tags, file);
if (!expectedType.endsWith("IndexPage")) assert.ok(previews.at(-1).split(",").includes("tags"), file);
}
}
}
});
test("feature grid displays configured icon text", () => {
const { default: FeatureGrid } = loadModule("blocks/feature-grid/FeatureGrid.tsx");
const tree = FeatureGrid({ value: { items: [{ icon: "S", title: "Speed", description: "Fast" }] } });
const item = tree.props.children.props.children[1].props.children[0];
assert.equal(item.props.children[0].props.children, "S");
});
test("footer renders configured navigation, contact and social fields with CMS QR URL", async () => {
const settings = { company_name: "Company", contact_phone: "123456", contact_email: "hello@example.com", address: "Address", wechat_account: "Account", weibo_url: "https://weibo.com/example", wechat_qrcode: { url: "/media/qr.png", title: "QR" } };
const { default: Footer } = loadModule("components/layout/Footer.tsx", {
"@/services/site-settings.service": { getSiteSettings: async () => settings },
"@/services/navigation.service": { getNavigationMenu: async (name) => { assert.equal(name, "footer"); return { items: [{ label: "Contact", url: "/contact", open_in_new_tab: true }] }; } },
}, { process: { env: { NEXT_PUBLIC_API_URL: "https://cms.example.com" } } });
const elements = [];
function collect(element) {
if (Array.isArray(element)) { element.forEach(collect); return; }
if (!element || typeof element !== "object") return;
elements.push(element);
collect(element.props?.children);
}
collect(await Footer());
for (const href of ["/contact", "tel:123456", "mailto:hello@example.com", settings.weibo_url]) assert.ok(elements.some((element) => element.props?.href === href));
const contact = elements.find((element) => element.props?.href === "/contact");
assert.equal(contact.props.target, "_blank");
assert.equal(contact.props.rel, "noreferrer");
assert.ok(elements.some((element) => element.props?.src === "https://cms.example.com/media/qr.png"));
assert.ok(elements.some((element) => element.props?.children === settings.address));
assert.ok(elements.some((element) => element.props?.children === settings.wechat_account));
const fallback = loadModule("components/layout/Footer.tsx", {
"@/services/site-settings.service": { getSiteSettings: async () => null },
"@/services/navigation.service": { getNavigationMenu: async () => null },
});
elements.length = 0;
collect(await fallback.default());
assert.ok(elements.some((element) => element.props?.href === "/privacy-policy"));
assert.ok(elements.some((element) => element.props?.children === "冀ICP备2025130506号-1"));
});
+9
View File
@@ -200,6 +200,8 @@ export interface HomePageDetail extends WagtailPageSummary {
export interface BlogPageSummary extends WagtailPageSummary {
intro?: string;
published_at?: string;
cover_image?: { url: string; title: string } | null;
tags?: string[];
}
export interface BlogPageDetail extends BlogPageSummary {
@@ -208,6 +210,7 @@ export interface BlogPageDetail extends BlogPageSummary {
export interface ProductPageSummary extends WagtailPageSummary {
summary?: string;
cover_image?: { url: string; title: string } | null;
}
export interface ProductPageDetail extends ProductPageSummary {
@@ -219,6 +222,7 @@ export interface CaseStudyPageSummary extends WagtailPageSummary {
industry?: string;
published_at?: string;
summary?: string;
cover_image?: { url: string; title: string } | null;
}
export interface CaseStudyPageDetail extends CaseStudyPageSummary {
@@ -228,6 +232,11 @@ export interface CaseStudyPageDetail extends CaseStudyPageSummary {
export interface SolutionPageSummary extends WagtailPageSummary {
industry?: string;
summary?: string;
cover_image?: { url: string; title: string } | null;
}
export interface ColumnPageDetail extends WagtailPageSummary {
intro?: string;
}
export interface SolutionPageDetail extends SolutionPageSummary {