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:
@@ -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('新闻未找到');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user