feat: render CMS content fields and footer settings
This commit is contained in:
@@ -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,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
@@ -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,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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,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>
|
||||
)}
|
||||
|
||||
@@ -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,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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
© {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}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user