feat(methodology): add CMS-driven methodology page skeleton

- 新增 methodology 内容模型(content-types + seed),内容由 CMS 后台可编辑
- 新建 /methodology 页面:Hero / 四阶段 Cards / 空态占位 / 轻量 CTA
- 首页「了解我们的方法论」CTA 改指向 /methodology;sitemap 收录
- 单元测试 4/4 + 视觉基线通过,type-check / lint 0 errors
This commit is contained in:
2026-08-19 15:28:15 +08:00
parent c195768281
commit 6573ab449e
9 changed files with 421 additions and 3 deletions
+3 -2
View File
@@ -119,7 +119,7 @@
- 方向:官网品牌宣传为主,产品矩阵页保留为聚合入口——每产品一句话定位 + 指标证据 + 外链独立站
- 成熟产品走 `externalUrl` 外链(NovaVis → novavis.p.novalon.cn 已实践);未成熟产品官网详情页占位,独立站上线后切换外链
- 参照案例(已核实):IHG 品牌组合页、字节跳动 bytedance.com/products、腾讯 tencent.com/business.html(模式 B:品牌+品牌矩阵聚合页);对照金蝶/用友(模式 C:产品为主,官网承载详情与转化)
- **实施状态**Phase 1 机制(externalUrl 外链)已就绪;Phase 2 产品矩阵页品牌化改造已完成(`30018bf`);Phase 3 全站对齐核查通过(导航产品入口 / 首页 CTA / 方案页产品组合均已符合品牌化,无需改动);Phase 4 SEO 机制就绪(standalone 外链 redirect、sitemap 已排除外链产品);**信任证据(案例/方法论)为内容待办,待真实素材补齐**
- **实施状态**Phase 1 机制(externalUrl 外链)已就绪;Phase 2 产品矩阵页品牌化改造已完成(`30018bf`);Phase 3 全站对齐核查通过(导航产品入口 / 首页 CTA / 方案页产品组合均已符合品牌化,无需改动);Phase 4 SEO 机制就绪(standalone 外链 redirect、sitemap 收录 /methodology);**信任证据**:方法论已 CMS 化并上线 /methodology 页面骨架(methodology 内容模型 + 首页 CTA 指向),案例与量化成果待真实素材补齐
## 项目进度
@@ -173,7 +173,8 @@
- [x] **最终验收确认 (2026-08-02)**type-check ✅ / lint 0 errors ✅ / 单元测试 90 suites / 1217 tests ✅ / 集成测试 11 suites / 96 tests ✅ / 覆盖率阈值达标 ✅
- [x] **文档同步 (2026-08-02)**test-strategy-plan.md 版本 1.5,已标记已实现项,保留真实缺口
- [x] **官网产品模块定位决策(2026-08-19)**:确认 IHG/字节式品牌矩阵方向(品牌宣传为主 + 产品矩阵聚合页保留 + 成熟产品外链独立站),已写入 CONTEXT.md / README.mdPhase 0 文档化 + Phase 2 矩阵页品牌化改造完成,Phase 3 全站核查通过,Phase 4 SEO 机制就绪
- [ ] **信任证据补齐(Phase 4 内容待办)**:首批客户共创案例、方法论/白皮书内容、可量化成果——需提供真实素材后实施(零编造原则)
- [x] **方法论 CMS 化(Phase 4 信任证据·方法论骨架)**:新增 `methodology` 内容模型 + `/methodology` 页面骨架(Hero / 四阶段 / 空态 / CTA),首页 CTA「了解我们的方法论」指向 /methodologysitemap 收录,视觉基线已生成
- [ ] **信任证据补齐(Phase 4 内容待办)**:首批客户共创案例与可量化成果——需提供真实素材后实施(零编造原则)
## 技术栈
+1
View File
@@ -15,6 +15,7 @@ const VISUAL_TEST_PAGES = [
{ path: '/news', name: 'news', label: '新闻列表' },
{ path: '/news/company-founded', name: 'news-detail', label: '新闻详情' },
{ path: '/team', name: 'team', label: '团队介绍' },
{ path: '/methodology', name: 'methodology', label: '方法论' },
];
async function waitForPageStable(page: Page) {
+49 -1
View File
@@ -12,6 +12,7 @@ import { STANDALONE_PRODUCTS } from './seeds/standalone-products';
import { SOLUTIONS } from './seeds/solutions';
import { STATS } from '../src/lib/constants/stats';
import { COMPANY_INFO } from '../src/lib/constants/company';
import { METHODOLOGY } from '../src/lib/constants/methodology';
import { NAVIGATION_V2, MEGA_DROPDOWN_DATA } from '../src/lib/constants/navigation';
const prisma = new PrismaClient();
@@ -231,6 +232,7 @@ async function main() {
'stat-item',
'standalone-product',
'media',
'methodology',
];
// 为 super_admin 之外的角色创建权限
@@ -518,6 +520,32 @@ async function main() {
],
},
},
// === methodology ===
{
model: {
code: 'methodology',
name: '方法论',
description: '实施方法论 — 四阶段模型(诊断→规划→实施→优化)',
isPageType: true,
urlPattern: '/methodology',
hasVersions: false,
hasDraft: true,
icon: 'route',
fields: [
{ name: 'heroSubtitle', type: 'text', label: 'Hero 标签', required: true },
{ name: 'heroTitle', type: 'textarea', label: 'Hero 主标题', required: true },
{ name: 'heroDescription', type: 'textarea', label: 'Hero 描述', required: true },
{ name: 'phases', type: 'array', label: '方法论阶段', required: true, fields: [
{ name: 'number', type: 'number', label: '序号' },
{ name: 'title', type: 'text', label: '阶段标题', required: true },
{ name: 'subtitle', type: 'text', label: '副标题' },
{ name: 'description', type: 'textarea', label: '阶段描述', required: true },
{ name: 'activities', type: 'json', label: '核心活动', description: 'JSON 字符串数组' },
{ name: 'deliverables', type: 'json', label: '交付物', description: 'JSON 字符串数组' },
]},
],
},
},
];
for (let i = 0; i < modelConfigs.length; i++) {
@@ -565,7 +593,7 @@ async function main() {
ctaLabel: '预约咨询',
ctaHref: '/contact',
secondaryCtaLabel: '了解我们的方法论',
secondaryCtaHref: '/services/consulting',
secondaryCtaHref: '/methodology',
statValue: '2026',
statSubtext: '年正式成立',
ctaTitleLine1: '每一次合作,',
@@ -582,6 +610,26 @@ async function main() {
await seedItems('case-study', CASE_STUDIES as unknown as Record<string, unknown>[]);
console.log(`${CASE_STUDIES.length} 个案例`);
console.log('📝 导入方法论...');
await seedItems('methodology', [
{
slug: 'default',
title: '实施方法论',
heroSubtitle: '方法论',
heroTitle: '从诊断到优化\n四阶段推进\n数字化转型',
heroDescription: '基于行业通用框架与核心团队实践,我们以「诊断评估 → 战略规划 → 落地实施 → 持续优化」四阶段推进每个项目,确保每个环节都有可衡量的交付。',
phases: METHODOLOGY.map((p) => ({
number: p.number,
title: p.title,
subtitle: p.subtitle,
description: p.description,
activities: p.activities,
deliverables: p.deliverables,
})),
},
]);
console.log(' ✅ 方法论(四阶段模型)');
console.log('📝 导入新闻...');
await seedItems('news', NEWS as unknown as Record<string, unknown>[]);
console.log(`${NEWS.length} 条新闻`);
@@ -0,0 +1,121 @@
import { describe, it, expect, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
// ─── Mocks ───────────────────────────────────────────────────────────────
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, className, style, ...props }: any) => (
<div data-testid="motion-div" className={className} style={style} {...props}>{children}</div>
),
h1: ({ children, className, ...props }: any) => (
<h1 data-testid="motion-h1" className={className} {...props}>{children}</h1>
),
p: ({ children, className, ...props }: any) => (
<p data-testid="motion-p" className={className} {...props}>{children}</p>
),
},
useInView: jest.fn(() => true),
AnimatePresence: ({ children }: any) => <>{children}</>,
}));
jest.mock('@/components/ui/scroll-reveal', () => ({
ScrollReveal: ({ children, className }: any) => (
<div data-testid="scroll-reveal" className={className}>{children}</div>
),
StaggerReveal: ({ children, className }: any) => (
<div data-testid="stagger-reveal" className={className}>{children}</div>
),
}));
jest.mock('@/components/ui/button', () => ({
Button: ({ children, className, ...props }: any) => (
<button className={className} data-testid="mock-button" {...props}>
{children}
</button>
),
}));
jest.mock('@/components/ui/page-decoration', () => ({
EASE_OUT: [0.22, 1, 0.36, 1],
}));
jest.mock('lucide-react', () => {
const mockIcon = (name: string) => {
const Icon = (props: any) => <svg data-testid={`icon-${name.toLowerCase()}`} className={props.className} />;
Icon.displayName = name;
return Icon;
};
return {
ArrowRight: mockIcon('arrow-right'),
CheckCircle2: mockIcon('check-circle2'),
};
});
// ─── Mock Data ────────────────────────────────────────────────────────────
const mockData = {
heroSubtitle: '方法论',
heroTitle: '四阶段推进数字化转型',
heroDescription: '从诊断到优化,步步可衡量。',
phases: [
{
number: 1,
title: '诊断评估',
subtitle: '了解现状,找准痛点',
description: '深入调研企业现状,识别关键痛点。',
activities: ['业务流程调研', 'IT基础设施评估'],
deliverables: ['成熟度评估报告', '关键痛点清单'],
},
{
number: 2,
title: '战略规划',
subtitle: '明确方向,制定路线',
description: '制定匹配企业战略的转型路线图。',
activities: ['IT战略对齐'],
deliverables: ['战略规划书'],
},
],
};
// ─── Tests ────────────────────────────────────────────────────────────────
describe('MethodologyContent - 方法论页面', () => {
it('should render hero section', async () => {
const { MethodologyContent } = await import('./methodology-content');
render(<MethodologyContent data={mockData} />);
expect(screen.getByText('方法论')).toBeInTheDocument();
expect(screen.getByText('四阶段推进数字化转型')).toBeInTheDocument();
expect(screen.getByText('从诊断到优化,步步可衡量。')).toBeInTheDocument();
});
it('should render all methodology phases with activities and deliverables', async () => {
const { MethodologyContent } = await import('./methodology-content');
render(<MethodologyContent data={mockData} />);
expect(screen.getByText('诊断评估')).toBeInTheDocument();
expect(screen.getByText('战略规划')).toBeInTheDocument();
expect(screen.getAllByText('核心活动').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('交付物').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('业务流程调研')).toBeInTheDocument();
expect(screen.getByText('成熟度评估报告')).toBeInTheDocument();
});
it('should render empty state when phases are missing', async () => {
const { MethodologyContent } = await import('./methodology-content');
render(<MethodologyContent data={{ heroTitle: '方法论' }} />);
expect(screen.getByText('方法论内容即将发布')).toBeInTheDocument();
});
it('should render CTA links to services and contact', async () => {
const { MethodologyContent } = await import('./methodology-content');
render(<MethodologyContent data={mockData} />);
const links = screen.getAllByRole('link');
expect(links.some((l) => l.getAttribute('href') === '/services')).toBe(true);
expect(links.some((l) => l.getAttribute('href') === '/contact')).toBe(true);
});
});
@@ -0,0 +1,171 @@
'use client';
import { motion } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { EASE_OUT } from '@/components/ui/page-decoration';
import { Button } from '@/components/ui/button';
import { ArrowRight, CheckCircle2 } from 'lucide-react';
interface MethodologyPhase {
number?: number;
title: string;
subtitle?: string;
description?: string;
activities?: string[];
deliverables?: string[];
}
interface MethodologyContentProps {
data: Record<string, any> | null;
}
export function MethodologyContent({ data }: MethodologyContentProps) {
const heroSubtitle = data?.heroSubtitle || '方法论';
const heroTitle = data?.heroTitle || '从诊断到优化\n四阶段推进数字化转型';
const heroDescription = data?.heroDescription || '';
const phases: MethodologyPhase[] = Array.isArray(data?.phases) ? data.phases : [];
return (
<main>
{/* Hero:品牌化定位,轻量 CTA */}
<section className="relative min-h-[60vh] flex items-center overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-24 sm:py-28 lg:py-32">
<div className="max-w-4xl">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.1, ease: EASE_OUT }}
className="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-bold text-brand bg-brand/10 mb-8"
>
{heroSubtitle}
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold text-ink leading-[1.05] tracking-tight mb-8 whitespace-pre-line"
>
{heroTitle}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.35, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary max-w-2xl leading-relaxed"
>
{heroDescription}
</motion.p>
</div>
</div>
</section>
{/* 阶段骨架:四阶段 Cards,空态占位 */}
<section className="relative py-20 sm:py-28 lg:py-32 overflow-hidden bg-bg-secondary">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-border-primary" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<ScrollReveal className="max-w-3xl mb-14 sm:mb-16">
<h2 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-ink tracking-tight leading-tight">
<span className="text-brand"></span>
</h2>
<p className="text-text-secondary text-lg mt-6 leading-relaxed">
</p>
</ScrollReveal>
{phases.length > 0 ? (
<div className="relative">
<div className="hidden lg:block absolute top-16 left-[12.5%] right-[12.5%] h-px bg-border-primary" />
<StaggerReveal
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8"
staggerDelay={0.08}
delayChildren={0.05}
>
{phases.map((phase, idx) => (
<motion.div
key={phase.title || idx}
className="relative overflow-hidden bg-white border border-border-primary transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
>
<div className="p-7 md:p-8">
<div className="w-11 h-11 rounded-full flex items-center justify-center mb-5 text-base font-bold bg-brand/10 text-brand">
{phase.number ?? idx + 1}
</div>
<h3 className="text-lg font-bold text-ink mb-1">{phase.title}</h3>
{phase.subtitle && (
<p className="text-xs text-brand font-medium mb-3">{phase.subtitle}</p>
)}
{phase.description && (
<p className="text-sm text-text-secondary leading-relaxed mb-5">{phase.description}</p>
)}
{phase.activities?.length ? (
<div className="mb-4">
<p className="text-xs font-semibold text-ink mb-2 tracking-wide"></p>
<ul className="space-y-1.5">
{phase.activities.map((activity, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-text-secondary">
<CheckCircle2 className="w-3.5 h-3.5 text-brand mt-0.5 shrink-0" />
{activity}
</li>
))}
</ul>
</div>
) : null}
{phase.deliverables?.length ? (
<div>
<p className="text-xs font-semibold text-ink mb-2 tracking-wide"></p>
<ul className="space-y-1.5">
{phase.deliverables.map((deliverable, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-text-muted">
<span className="w-1.5 h-1.5 bg-brand/40 rounded-full mt-1.5 shrink-0" />
{deliverable}
</li>
))}
</ul>
</div>
) : null}
</div>
</motion.div>
))}
</StaggerReveal>
</div>
) : (
<div className="bg-white border border-border-primary p-10 sm:p-16 text-center">
<h3 className="text-xl font-bold text-ink mb-3"></h3>
<p className="text-text-secondary max-w-xl mx-auto leading-relaxed">
</p>
</div>
)}
</div>
</section>
{/* CTA:轻量导向服务与联系 */}
<section className="relative py-20 sm:py-28 lg:py-32 overflow-hidden bg-white">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="max-w-container mx-auto px-6 lg:px-10">
<ScrollReveal className="text-center max-w-3xl mx-auto">
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6 sm:mb-8">
<span className="text-brand"></span>
</h2>
<p className="text-xl text-text-secondary mb-12 max-w-2xl mx-auto leading-relaxed">
穿
</p>
<div className="flex flex-wrap justify-center gap-6">
<Button size="xl" asChild>
<a href="/services">
<ArrowRight className="w-5 h-5 ml-2" />
</a>
</Button>
<Button size="xl" variant="outline" asChild>
<a href="/contact"></a>
</Button>
</div>
</ScrollReveal>
</div>
</section>
</main>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Metadata } from 'next';
import { COMPANY_INFO } from '@/lib/constants';
import { getPublishedItems } from '@/lib/cms/data-server';
import { MethodologyContent } from './methodology-content';
export const metadata: Metadata = {
title: `实施方法论 - ${COMPANY_INFO.displayName}`,
description: `了解${COMPANY_INFO.name}的实施方法论:诊断评估、战略规划、落地实施、持续优化四阶段模型,确保数字化项目可衡量落地。`,
};
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
export default async function MethodologyPage() {
const items = await getPublishedItems('methodology');
const data = items[0]?.data ?? null;
return <MethodologyContent data={data} />;
}
+1
View File
@@ -13,6 +13,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
{ url: `${BASE_URL}/cases`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8 },
{ url: `${BASE_URL}/news`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.7 },
{ url: `${BASE_URL}/team`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.6 },
{ url: `${BASE_URL}/methodology`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.6 },
{ url: `${BASE_URL}/contact`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.7 },
];
+57
View File
@@ -1536,6 +1536,42 @@ const legalPageFields: FieldDefinition[] = [
},
];
const methodologyFields: FieldDefinition[] = [
{
name: 'heroSubtitle',
type: 'text',
label: 'Hero 标签',
required: true,
},
{
name: 'heroTitle',
type: 'textarea',
label: 'Hero 主标题',
required: true,
},
{
name: 'heroDescription',
type: 'textarea',
label: 'Hero 描述',
required: true,
},
{
name: 'phases',
type: 'array',
label: '方法论阶段',
description: '四阶段推进模型,如 诊断评估 → 战略规划 → 落地实施 → 持续优化',
required: true,
fields: [
{ name: 'number', type: 'number', label: '序号' },
{ name: 'title', type: 'text', label: '阶段标题', required: true },
{ name: 'subtitle', type: 'text', label: '副标题' },
{ name: 'description', type: 'textarea', label: '阶段描述', required: true },
{ name: 'activities', type: 'json', label: '核心活动', description: 'JSON 字符串数组' },
{ name: 'deliverables', type: 'json', label: '交付物', description: 'JSON 字符串数组' },
],
},
];
const newsConfig: ContentTypeConfig = {
model: {
id: 'model-news',
@@ -1770,6 +1806,26 @@ const legalPageConfig: ContentTypeConfig = {
},
};
const methodologyConfig: ContentTypeConfig = {
model: {
id: 'model-methodology',
code: 'methodology',
name: '方法论',
description: '实施方法论 — 四阶段模型(诊断→规划→实施→优化)',
fields: methodologyFields,
isPageType: true,
urlPattern: '/methodology',
hasVersions: false,
hasDraft: true,
icon: 'route',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
listPage: {
route: '/methodology',
},
};
/** @deprecated 旧客户端 CMS 的注册函数,已不再需要 */
export function registerAllContentTypes(): void {
// no-op — 数据模型定义已全部移至 CONTENT_TYPE_CONFIGS
@@ -1788,4 +1844,5 @@ export const CONTENT_TYPE_CONFIGS = {
'team-page': teamPageConfig,
'contact-page': contactPageConfig,
'legal-page': legalPageConfig,
methodology: methodologyConfig,
};