chore: sync marketing pages, CMS extensions, tests and project docs

同步工作区剩余变更,主要包括:
- 营销页面组件与布局持续优化(about/news/services/solutions/team 等)
- 详情页四层叙事组件、布局组件、UI 组件调整
- CMS 数据模型、API 路由、权限、工作流、站内通知、媒体管理扩展
- 新增/补充单元测试与 E2E 测试(cms-workflow.spec.ts 等)
- ESLint 9 迁移、jest/tsconfig 配置更新、依赖调整
- 新增 ADR、CMS 评估文档、Release Review / Acceptance 报告
- 移除水墨装饰组件与大体积未使用字体文件
This commit is contained in:
张翔
2026-07-25 08:04:01 +08:00
parent e35090b914
commit 10404dbb36
208 changed files with 18107 additions and 8330 deletions
@@ -3,7 +3,7 @@
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { InkCard } from '@/components/ui/ink-wash';
import { Card } from '@/components/ui/card';
import { Calendar, ArrowLeft, Newspaper } from 'lucide-react';
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
@@ -85,14 +85,14 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
)}
{/* Excerpt */}
<InkCard padding="md" className="mb-10">
<Card className="mb-10 p-6">
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
{news.excerpt}
</p>
</InkCard>
</Card>
{/* Article Content */}
<div className="prose-ink text-[var(--color-text-primary)] text-base sm:text-lg leading-[1.8] whitespace-pre-line">
<div className="prose text-[var(--color-text-primary)] text-base sm:text-lg leading-[1.8] whitespace-pre-line">
{news.content}
</div>
@@ -103,7 +103,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
<div className="grid md:grid-cols-3 gap-6">
{relatedNews.map((related) => (
<StaticLink key={related.id} href={`/news/${related.id}`}>
<InkCard padding="none" interactive className="overflow-hidden group h-full">
<Card className="overflow-hidden group h-full transition-all duration-300 hover:-translate-y-1 hover:shadow-lg">
<div className="aspect-video overflow-hidden">
{related.image ? (
<img
@@ -124,7 +124,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
</h3>
<p className="text-sm text-[var(--color-text-muted)] line-clamp-2 leading-relaxed">{related.excerpt}</p>
</div>
</InkCard>
</Card>
</StaticLink>
))}
</div>
@@ -0,0 +1,147 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import NewsDetailPage, { generateMetadata } from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItemBySlug: jest.fn(),
getPublishedItems: jest.fn(),
getAllPublishedSlugs: jest.fn(),
}));
jest.mock('../news-detail-content-v3', () => ({
__esModule: true,
default: function MockNewsDetailContentV3({ news, relatedNews }: { news: { title: string }; relatedNews?: Array<{ title: string }> }) {
return (
<div data-testid="news-detail">
<h1>{news.title}</h1>
{relatedNews && relatedNews.length > 0 && (
<div data-testid="related-news">
{relatedNews.map((item, idx) => (
<div key={idx}>{item.title}</div>
))}
</div>
)}
</div>
);
},
}));
import { getPublishedItemBySlug, getPublishedItems, getAllPublishedSlugs } from '@/lib/cms/data-server';
const mockedGetPublishedItemBySlug = getPublishedItemBySlug as jest.MockedFunction<typeof getPublishedItemBySlug>;
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const mockedGetAllPublishedSlugs = getAllPublishedSlugs as jest.MockedFunction<typeof getAllPublishedSlugs>;
const createMockNews = (
topOverrides: Record<string, unknown> = {},
dataOverrides: Record<string, unknown> = {},
) => ({
id: 'item-1',
modelId: 'model-news',
modelCode: 'news',
title: '测试新闻',
slug: 'test-news',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-news',
title: '测试新闻',
excerpt: '测试摘要',
date: '2026-07-17',
category: '公司新闻',
image: '/images/news/test.webp',
content: '测试正文',
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('NewsDetailPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItemBySlug.mockResolvedValue(null);
mockedGetPublishedItems.mockResolvedValue([]);
mockedGetAllPublishedSlugs.mockResolvedValue([]);
});
it('returns 404 when news item is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
// next/navigation notFound() throws in test environment; verify it is called by catching
await expect(
NewsDetailPage({ params: Promise.resolve({ slug: 'missing' }) }),
).rejects.toThrow();
});
it('renders news detail with data from CMS', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(createMockNews() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>);
const page = await NewsDetailPage({ params: Promise.resolve({ slug: 'test-news' }) });
render(page);
expect(screen.getByTestId('news-detail')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('测试新闻');
});
it('filters related news by same category and excludes current slug', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockNews() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
mockedGetPublishedItems.mockResolvedValue([
createMockNews(),
createMockNews(
{ id: 'item-related', slug: 'related-1' },
{ id: 'related-1', slug: 'related-1', title: '相关新闻 1' },
),
createMockNews(
{ id: 'item-other', slug: 'other-cat' },
{ id: 'other-cat', slug: 'other-cat', title: '其他分类', category: '研发动态' },
),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await NewsDetailPage({ params: Promise.resolve({ slug: 'test-news' }) });
render(page);
expect(screen.getByText('相关新闻 1')).toBeInTheDocument();
expect(screen.queryByText('其他分类')).not.toBeInTheDocument();
});
it('generateStaticParams returns slugs from CMS', async () => {
mockedGetAllPublishedSlugs.mockResolvedValue([
{ slug: 'company-founded' },
{ slug: 'digital-transformation-solution' },
] as unknown as Awaited<ReturnType<typeof getAllPublishedSlugs>>);
const { generateStaticParams } = await import('./page');
const params = await generateStaticParams();
expect(params).toEqual([
{ slug: 'company-founded' },
{ slug: 'digital-transformation-solution' },
]);
});
describe('generateMetadata', () => {
it('returns news title and excerpt as metadata', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(createMockNews() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>);
const metadata = await generateMetadata({ params: Promise.resolve({ slug: 'test-news' }) });
expect(metadata.title).toContain('测试新闻');
expect(metadata.description).toBe('测试摘要');
});
it('returns fallback metadata when news is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
const metadata = await generateMetadata({ params: Promise.resolve({ slug: 'missing' }) });
expect(metadata.title).toBe('新闻未找到');
});
});
});
+3
View File
@@ -5,6 +5,9 @@ import { getPublishedItemBySlug, getPublishedItems, getAllPublishedSlugs } from
import type { NewsItem } from '@/lib/constants/news';
import NewsDetailContentV3 from '../news-detail-content-v3';
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
export async function generateStaticParams() {
const slugs = await getAllPublishedSlugs('news');
return slugs.map((s) => ({ slug: s.slug }));
+2 -2
View File
@@ -53,9 +53,9 @@ function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
</div>
</div>
<h3 className="text-lg sm:text-xl font-semibold text-ink mb-3 line-clamp-2 group-hover:text-brand transition-colors duration-300 leading-snug">
<h2 className="text-lg sm:text-xl font-semibold text-ink mb-3 line-clamp-2 group-hover:text-brand transition-colors duration-300 leading-snug">
{newsItem.title}
</h3>
</h2>
<p className="text-sm text-text-secondary line-clamp-3 mb-5 leading-relaxed">
{newsItem.excerpt}
+105
View File
@@ -0,0 +1,105 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import NewsListPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
}));
jest.mock('./news-content-v3', () => ({
__esModule: true,
default: function MockNewsContentV3({ news }: { news: Array<{ id: string; title: string }> }) {
return (
<div data-testid="news-content">
{news.map((item) => (
<div key={item.id} data-testid="news-item">{item.title}</div>
))}
</div>
);
},
}));
import { getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
describe('NewsListPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItems.mockResolvedValue([]);
});
it('renders empty state when no CMS news items exist', async () => {
const page = await NewsListPage();
render(page);
expect(screen.getByTestId('news-content')).toBeInTheDocument();
expect(screen.queryByTestId('news-item')).not.toBeInTheDocument();
});
it('maps CMS items to NewsItem shape and passes to NewsContentV3', async () => {
mockedGetPublishedItems.mockResolvedValue([
{
id: 'item-1',
modelId: 'model-news',
modelCode: 'news',
title: 'CMS 新闻标题',
slug: 'cms-news',
locale: 'zh-CN',
status: 'published',
data: {
id: 'cms-news',
title: 'CMS 新闻标题',
excerpt: 'CMS 摘要',
date: '2026-07-17',
category: '公司新闻',
image: '/images/news/cms.webp',
content: 'CMS 正文',
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await NewsListPage();
render(page);
expect(screen.getByText('CMS 新闻标题')).toBeInTheDocument();
});
it('falls back to item.title when data.title is missing', async () => {
mockedGetPublishedItems.mockResolvedValue([
{
id: 'item-2',
modelId: 'model-news',
modelCode: 'news',
title: 'Fallback Title',
slug: 'fallback-news',
locale: 'zh-CN',
status: 'published',
data: {
id: 'fallback-news',
excerpt: '摘要',
date: '2026-07-17',
category: '研发动态',
image: '/images/news/fallback.webp',
content: '正文',
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await NewsListPage();
render(page);
expect(screen.getByText('Fallback Title')).toBeInTheDocument();
});
});
+3
View File
@@ -9,6 +9,9 @@ export const metadata: Metadata = {
description: `了解${COMPANY_INFO.displayName}的最新动态、行业洞察与技术创新。`,
};
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
export default async function NewsListPage() {
const items = await getPublishedItems('news');
const news = items.map((item) => ({ ...item.data, title: item.data.title || item.title } as unknown as NewsItem));