chore(cms): 清理旧版 CMS 客户端代码,完成 CMS 迁移
- 删除已废弃的 CMS 客户端代码:mock-data、registry、client、storage、renderers 等 - 删除已归档的 CMS 页面组件:home-content-cms、news-content-cms、services-content-cms 等 - 删除 CMS Studio 页面 - 将 CaseStudyData 类型移至 types.ts,统一类型定义 - 移除 content-types 对 registry 的依赖,内联 ContentTypeConfig - 修复测试文件中对已删除 mock-data 的引用 - 添加 site-config 和 navigation 内容模型种子数据 - 更新 revalidate API 路由使用 CONTENT_TYPE_CONFIGS 共修改 34 个文件,+132 / -5993 行
This commit is contained in:
+78
-2
@@ -8,6 +8,8 @@ import { SERVICES } from '../src/lib/constants/services';
|
|||||||
import { PRODUCTS } from '../src/lib/constants/products';
|
import { PRODUCTS } from '../src/lib/constants/products';
|
||||||
import { SOLUTIONS } from '../src/lib/constants/solutions';
|
import { SOLUTIONS } from '../src/lib/constants/solutions';
|
||||||
import { STATS } from '../src/lib/constants/stats';
|
import { STATS } from '../src/lib/constants/stats';
|
||||||
|
import { COMPANY_INFO } from '../src/lib/constants/company';
|
||||||
|
import { NAVIGATION_V2, MEGA_DROPDOWN_DATA } from '../src/lib/constants/navigation';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ async function seedItems(
|
|||||||
const item = items[i]!;
|
const item = items[i]!;
|
||||||
const slug = String(item[slugField] || item.id || `${modelCode}-${i}`);
|
const slug = String(item[slugField] || item.id || `${modelCode}-${i}`);
|
||||||
const title = String(item[titleField] || `未命名 ${i + 1}`);
|
const title = String(item[titleField] || `未命名 ${i + 1}`);
|
||||||
const { id, slug: _s, title: _t, ...data } = item as Record<string, unknown>;
|
const { id: _id, slug: _s, title: _t, ...data } = item as Record<string, unknown>;
|
||||||
|
|
||||||
await prisma.contentItem.upsert({
|
await prisma.contentItem.upsert({
|
||||||
where: { modelCode_slug: { modelCode, slug } },
|
where: { modelCode_slug: { modelCode, slug } },
|
||||||
@@ -216,6 +218,49 @@ async function main() {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// === site-config ===
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
code: 'site-config',
|
||||||
|
name: '站点配置',
|
||||||
|
description: '公司基本信息、SEO 元数据等全局配置',
|
||||||
|
isPageType: false,
|
||||||
|
urlPattern: '',
|
||||||
|
hasVersions: false,
|
||||||
|
hasDraft: true,
|
||||||
|
icon: 'settings',
|
||||||
|
fields: [
|
||||||
|
{ name: 'name', type: 'text', label: '公司全称', required: true },
|
||||||
|
{ name: 'shortName', type: 'text', label: '公司简称', required: true },
|
||||||
|
{ name: 'displayName', type: 'text', label: '展示名称', required: true },
|
||||||
|
{ name: 'slogan', type: 'text', label: '品牌标语', required: true },
|
||||||
|
{ name: 'description', type: 'textarea', label: '公司简介', required: true },
|
||||||
|
{ name: 'founded', type: 'text', label: '成立年份' },
|
||||||
|
{ name: 'location', type: 'text', label: '所在地' },
|
||||||
|
{ name: 'email', type: 'text', label: '联系邮箱', required: true },
|
||||||
|
{ name: 'address', type: 'text', label: '办公地址', required: true },
|
||||||
|
{ name: 'icp', type: 'text', label: 'ICP 备案号' },
|
||||||
|
{ name: 'police', type: 'text', label: '公安备案号' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// === navigation ===
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
code: 'navigation',
|
||||||
|
name: '导航菜单',
|
||||||
|
description: '主导航项和下拉菜单内容',
|
||||||
|
isPageType: false,
|
||||||
|
urlPattern: '',
|
||||||
|
hasVersions: false,
|
||||||
|
hasDraft: true,
|
||||||
|
icon: 'menu',
|
||||||
|
fields: [
|
||||||
|
{ name: 'mainNav', type: 'json', label: '主导航项', required: true },
|
||||||
|
{ name: 'megaDropdown', type: 'json', label: '下拉菜单数据', required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
// === legal-page ===
|
// === legal-page ===
|
||||||
{
|
{
|
||||||
model: {
|
model: {
|
||||||
@@ -300,7 +345,7 @@ async function main() {
|
|||||||
const s = statList[i] as Record<string, unknown>;
|
const s = statList[i] as Record<string, unknown>;
|
||||||
const slug = `stat-${i}`;
|
const slug = `stat-${i}`;
|
||||||
const title = String(s.label || `指标 ${i + 1}`);
|
const title = String(s.label || `指标 ${i + 1}`);
|
||||||
const { id, ...data } = s;
|
const { id: _id, ...data } = s;
|
||||||
await prisma.contentItem.upsert({
|
await prisma.contentItem.upsert({
|
||||||
where: { modelCode_slug: { modelCode: 'stat-item', slug } },
|
where: { modelCode_slug: { modelCode: 'stat-item', slug } },
|
||||||
update: {
|
update: {
|
||||||
@@ -495,6 +540,37 @@ async function main() {
|
|||||||
]);
|
]);
|
||||||
console.log(' ✅ 法律页面');
|
console.log(' ✅ 法律页面');
|
||||||
|
|
||||||
|
console.log('📝 导入站点配置...');
|
||||||
|
await seedItems('site-config', [
|
||||||
|
{
|
||||||
|
slug: 'default',
|
||||||
|
title: '站点配置',
|
||||||
|
name: COMPANY_INFO.name,
|
||||||
|
shortName: COMPANY_INFO.shortName,
|
||||||
|
displayName: COMPANY_INFO.displayName,
|
||||||
|
slogan: COMPANY_INFO.slogan,
|
||||||
|
description: COMPANY_INFO.description,
|
||||||
|
founded: COMPANY_INFO.founded,
|
||||||
|
location: COMPANY_INFO.location,
|
||||||
|
email: COMPANY_INFO.email,
|
||||||
|
address: COMPANY_INFO.address,
|
||||||
|
icp: COMPANY_INFO.icp,
|
||||||
|
police: COMPANY_INFO.police,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
console.log(' ✅ 站点配置');
|
||||||
|
|
||||||
|
console.log('📝 导入导航菜单...');
|
||||||
|
await seedItems('navigation', [
|
||||||
|
{
|
||||||
|
slug: 'default',
|
||||||
|
title: '导航菜单',
|
||||||
|
mainNav: NAVIGATION_V2,
|
||||||
|
megaDropdown: MEGA_DROPDOWN_DATA,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
console.log(' ✅ 导航菜单');
|
||||||
|
|
||||||
// ============ 5. 创建默认内容区域 ============
|
// ============ 5. 创建默认内容区域 ============
|
||||||
console.log('📝 创建默认内容区域...');
|
console.log('📝 创建默认内容区域...');
|
||||||
const defaultZones = [
|
const defaultZones = [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { notFound } from 'next/navigation';
|
|||||||
import CaseDetailPage from '@/components/sections/case-detail-page';
|
import CaseDetailPage from '@/components/sections/case-detail-page';
|
||||||
import { ScrollProgress } from '@/components/ui/scroll-progress';
|
import { ScrollProgress } from '@/components/ui/scroll-progress';
|
||||||
import type { ContentItem } from '@/lib/cms/types';
|
import type { ContentItem } from '@/lib/cms/types';
|
||||||
import type { CaseStudyData } from '@/lib/cms/mock-data';
|
import type { CaseStudyData } from '@/lib/cms/types';
|
||||||
|
|
||||||
interface CaseDetailClientProps {
|
interface CaseDetailClientProps {
|
||||||
item: ContentItem;
|
item: ContentItem;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { notFound } from 'next/navigation';
|
|||||||
import { CaseDetailClient } from './client';
|
import { CaseDetailClient } from './client';
|
||||||
import { getPublishedItemBySlug, getAllPublishedSlugs } from '@/lib/cms/data-server';
|
import { getPublishedItemBySlug, getAllPublishedSlugs } from '@/lib/cms/data-server';
|
||||||
import { COMPANY_INFO } from '@/lib/constants/company';
|
import { COMPANY_INFO } from '@/lib/constants/company';
|
||||||
import type { CaseStudyData } from '@/lib/cms/mock-data';
|
import type { CaseStudyData } from '@/lib/cms/types';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
interface CaseDetailPageProps {
|
interface CaseDetailPageProps {
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { initCms } from '@/lib/cms';
|
|
||||||
import { ZoneManager } from '@/components/cms/ZoneManager';
|
|
||||||
import ContentEditor from '@/components/cms/ContentEditor';
|
|
||||||
|
|
||||||
type ViewMode = 'content' | 'zones';
|
|
||||||
|
|
||||||
export default function CmsStudioPage() {
|
|
||||||
const [ready, setReady] = useState(false);
|
|
||||||
const [view, setView] = useState<ViewMode>('zones');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initCms();
|
|
||||||
setReady(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!ready) {
|
|
||||||
return (
|
|
||||||
<div className="h-screen bg-ink flex items-center justify-center">
|
|
||||||
<div className="text-white/50">加载中...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen flex bg-ink text-white overflow-hidden" data-theme="dark">
|
|
||||||
<aside className="w-56 flex-shrink-0 border-r border-white/[0.08] bg-ink-light flex flex-col">
|
|
||||||
<div className="p-4 border-b border-white/[0.08]">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand to-brand/70 flex items-center justify-center text-white font-bold text-sm">
|
|
||||||
N
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-white">Novalon CMS</div>
|
|
||||||
<div className="text-xs text-white/40">内容管理控制台</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
|
|
||||||
<div className="px-3 py-2 text-xs text-white/40 font-medium uppercase tracking-wider">
|
|
||||||
管理模式
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setView('zones')}
|
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors text-left ${
|
|
||||||
view === 'zones'
|
|
||||||
? 'bg-brand/20 text-brand font-medium'
|
|
||||||
: 'text-white/60 hover:bg-white/[0.05] hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-lg">🎯</span>
|
|
||||||
<span>内容区管理</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setView('content')}
|
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors text-left ${
|
|
||||||
view === 'content'
|
|
||||||
? 'bg-brand/20 text-brand font-medium'
|
|
||||||
: 'text-white/60 hover:bg-white/[0.05] hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-lg">📄</span>
|
|
||||||
<span>内容管理</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="my-3 border-t border-white/[0.06]" />
|
|
||||||
|
|
||||||
<div className="px-3 py-2 text-xs text-white/40 font-medium uppercase tracking-wider">
|
|
||||||
快捷入口
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
target="_blank"
|
|
||||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white/60 hover:bg-white/[0.05] hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-lg">🏠</span>
|
|
||||||
<span>查看首页</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="/cases"
|
|
||||||
target="_blank"
|
|
||||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white/60 hover:bg-white/[0.05] hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-lg">📁</span>
|
|
||||||
<span>案例中心</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="p-3 border-t border-white/[0.08]">
|
|
||||||
<div className="px-3 py-2 rounded-lg bg-white/[0.03] text-xs text-white/40">
|
|
||||||
<div className="font-medium text-white/60 mb-1">💡 使用提示</div>
|
|
||||||
<div className="leading-relaxed">
|
|
||||||
{view === 'zones'
|
|
||||||
? '拖拽内容排序,配置布局,底部实时预览效果'
|
|
||||||
: '选择内容类型,编辑字段,右侧预览渲染效果'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
|
||||||
<header className="h-14 flex-shrink-0 border-b border-white/[0.08] bg-ink-light flex items-center justify-between px-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<h1 className="text-base font-semibold text-white">
|
|
||||||
{view === 'zones' ? '内容区管理' : '内容管理'}
|
|
||||||
</h1>
|
|
||||||
<span className="text-xs text-white/40">
|
|
||||||
{view === 'zones' ? '配置页面内容区块' : '管理所有内容条目'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex bg-white/[0.05] rounded-lg p-0.5">
|
|
||||||
<button
|
|
||||||
onClick={() => setView('zones')}
|
|
||||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
|
||||||
view === 'zones'
|
|
||||||
? 'bg-white/[0.1] text-white'
|
|
||||||
: 'text-white/50 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
内容区
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setView('content')}
|
|
||||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
|
||||||
view === 'content'
|
|
||||||
? 'bg-white/[0.1] text-white'
|
|
||||||
: 'text-white/50 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
内容条目
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button className="px-4 py-1.5 bg-brand hover:bg-brand-hover text-white text-xs font-medium rounded-lg transition-colors">
|
|
||||||
保存更改
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
|
||||||
{view === 'zones' ? <ZoneManager /> : <ContentEditor />}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useMemo, createElement } from 'react';
|
|
||||||
import {
|
|
||||||
initCms,
|
|
||||||
getItemRenderer,
|
|
||||||
ContentZoneRenderer,
|
|
||||||
getContentZone,
|
|
||||||
getContentItems,
|
|
||||||
} from '@/lib/cms';
|
|
||||||
import { getMockHomeHeroBanner } from '@/lib/cms/mock-home';
|
|
||||||
import type { ContentItem, ContentZone } from '@/lib/cms';
|
|
||||||
|
|
||||||
function SectionHeading({ eyebrow, title, description, align = 'center' }: {
|
|
||||||
eyebrow?: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
align?: 'center' | 'left';
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={`mb-12 md:mb-16 ${align === 'center' ? 'text-center' : ''}`}>
|
|
||||||
{eyebrow && (
|
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand/10 text-brand text-xs font-medium mb-4">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-brand" />
|
|
||||||
{eyebrow}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white tracking-tight">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
{description && (
|
|
||||||
<p
|
|
||||||
className="mt-4 text-lg text-white/50 max-w-2xl leading-relaxed"
|
|
||||||
style={{ marginInline: align === 'center' ? 'auto' : undefined }}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomeContentCms() {
|
|
||||||
const [ready, setReady] = useState(false);
|
|
||||||
const [hero, setHero] = useState<ContentItem | null>(null);
|
|
||||||
const [statsZone, setStatsZone] = useState<ContentZone | null>(null);
|
|
||||||
const [servicesZone, setServicesZone] = useState<ContentZone | null>(null);
|
|
||||||
const [solutionsZone, setSolutionsZone] = useState<ContentZone | null>(null);
|
|
||||||
const [casesZone, setCasesZone] = useState<ContentZone | null>(null);
|
|
||||||
const [newsZone, setNewsZone] = useState<ContentZone | null>(null);
|
|
||||||
|
|
||||||
const loadData = useCallback(() => {
|
|
||||||
const heroItems = getContentItems('hero-banner');
|
|
||||||
setHero(heroItems.length > 0 ? heroItems[0]! : getMockHomeHeroBanner());
|
|
||||||
setStatsZone(getContentZone('home-stats'));
|
|
||||||
setServicesZone(getContentZone('home-services'));
|
|
||||||
setSolutionsZone(getContentZone('home-solutions'));
|
|
||||||
setCasesZone(getContentZone('home-cases'));
|
|
||||||
setNewsZone(getContentZone('home-news'));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initCms();
|
|
||||||
loadData();
|
|
||||||
setReady(true);
|
|
||||||
|
|
||||||
const handleStorage = (e: StorageEvent) => {
|
|
||||||
if (e.key === 'novalon-cms-data') {
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('storage', handleStorage);
|
|
||||||
|
|
||||||
const handleCmsUpdate = () => loadData();
|
|
||||||
window.addEventListener('cms-updated', handleCmsUpdate);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('storage', handleStorage);
|
|
||||||
window.removeEventListener('cms-updated', handleCmsUpdate);
|
|
||||||
};
|
|
||||||
}, [loadData]);
|
|
||||||
|
|
||||||
const HeroRenderer = useMemo(
|
|
||||||
() => (hero ? getItemRenderer(hero.modelCode) : null),
|
|
||||||
[hero]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!ready) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-ink flex items-center justify-center">
|
|
||||||
<div className="text-white/50">加载中...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-ink text-white overflow-x-hidden" data-theme="dark">
|
|
||||||
{hero && HeroRenderer && createElement(HeroRenderer, { item: hero })}
|
|
||||||
|
|
||||||
<section className="relative py-16 sm:py-20 md:py-28 overflow-hidden bg-ink-light">
|
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
|
||||||
<SectionHeading
|
|
||||||
eyebrow="数据说话"
|
|
||||||
title="用数字证明实力"
|
|
||||||
description="我们的价值,体现在每一个可量化的成果中。"
|
|
||||||
/>
|
|
||||||
{statsZone && <ContentZoneRenderer zone={statsZone} />}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-ink">
|
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
|
||||||
<SectionHeading
|
|
||||||
eyebrow="核心服务"
|
|
||||||
title="端到端的数字化服务能力"
|
|
||||||
description="从战略咨询到技术实施,从平台建设到运营运维,覆盖企业数字化转型的全生命周期。"
|
|
||||||
/>
|
|
||||||
{servicesZone && <ContentZoneRenderer zone={servicesZone} />}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-ink-light">
|
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
|
||||||
<SectionHeading
|
|
||||||
eyebrow="解决方案"
|
|
||||||
title="行业场景化解决方案"
|
|
||||||
description="深入理解不同行业的业务特性,提供量身定制的数字化解决方案。"
|
|
||||||
/>
|
|
||||||
{solutionsZone && <ContentZoneRenderer zone={solutionsZone} />}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-ink">
|
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between mb-12 md:mb-16">
|
|
||||||
<SectionHeading
|
|
||||||
eyebrow="客户案例"
|
|
||||||
title="成果验证 · 客户信赖"
|
|
||||||
description="每一个项目,都是我们与客户共同成长的见证。"
|
|
||||||
align="left"
|
|
||||||
/>
|
|
||||||
<a href="/cases" className="inline-flex items-center gap-2 text-brand font-medium hover:gap-3 transition-all">
|
|
||||||
查看全部案例
|
|
||||||
<span>→</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{casesZone && <ContentZoneRenderer zone={casesZone} />}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-ink-light">
|
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between mb-12 md:mb-16">
|
|
||||||
<SectionHeading
|
|
||||||
eyebrow="新闻动态"
|
|
||||||
title="最新动态"
|
|
||||||
description="了解公司最新动态、行业洞察和技术分享。"
|
|
||||||
align="left"
|
|
||||||
/>
|
|
||||||
<a href="/news" className="inline-flex items-center gap-2 text-brand font-medium hover:gap-3 transition-all">
|
|
||||||
查看全部
|
|
||||||
<span>→</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{newsZone && <ContentZoneRenderer zone={newsZone} />}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-ink">
|
|
||||||
<div className="max-w-4xl mx-auto px-6 lg:px-8 text-center">
|
|
||||||
<SectionHeading
|
|
||||||
eyebrow="准备好开始了吗"
|
|
||||||
title="开启您的数字化转型之旅"
|
|
||||||
description="无论您处于数字化的哪个阶段,我们都能提供量身定制的解决方案。"
|
|
||||||
/>
|
|
||||||
<div className="mt-12 flex flex-wrap justify-center gap-4">
|
|
||||||
<a
|
|
||||||
href="/contact"
|
|
||||||
className="inline-flex items-center gap-2 px-8 py-4 bg-brand hover:bg-brand-hover text-white font-medium rounded-xl transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-brand/20"
|
|
||||||
>
|
|
||||||
免费咨询
|
|
||||||
<span>→</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/solutions"
|
|
||||||
className="inline-flex items-center gap-2 px-8 py-4 bg-white/[0.05] hover:bg-white/[0.1] text-white font-medium rounded-xl border border-white/[0.1] hover:border-white/[0.2] transition-all duration-300"
|
|
||||||
>
|
|
||||||
了解方案
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useMemo, useEffect } from 'react';
|
|
||||||
import { initCms, getItemRenderer } from '@/lib/cms';
|
|
||||||
import type { ContentItem } from '@/lib/cms';
|
|
||||||
import { NEWS } from '@/lib/constants';
|
|
||||||
|
|
||||||
const categories = ['全部', '公司新闻', '研发动态'];
|
|
||||||
const ITEMS_PER_PAGE = 9;
|
|
||||||
|
|
||||||
function newsToContentItem(news: unknown, index: number): ContentItem {
|
|
||||||
const n = news as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
id: String(n.id),
|
|
||||||
modelId: 'news',
|
|
||||||
modelCode: 'news',
|
|
||||||
title: String(n.title),
|
|
||||||
slug: String(n.id),
|
|
||||||
status: 'published',
|
|
||||||
version: 1,
|
|
||||||
sortOrder: index,
|
|
||||||
data: n,
|
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
|
||||||
publishedAt: '2024-01-01T00:00:00Z',
|
|
||||||
createdBy: 'system',
|
|
||||||
updatedBy: 'system',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NewsContentCms() {
|
|
||||||
const [ready, setReady] = useState(false);
|
|
||||||
const [items, setItems] = useState<ContentItem[]>([]);
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState('全部');
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initCms();
|
|
||||||
setItems(NEWS.map(newsToContentItem));
|
|
||||||
setReady(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
|
||||||
return items.filter((item) => {
|
|
||||||
const data = item.data as Record<string, unknown>;
|
|
||||||
const category = (data.category as string) || '';
|
|
||||||
const matchesCategory = selectedCategory === '全部' || category === selectedCategory;
|
|
||||||
const matchesSearch =
|
|
||||||
!searchQuery ||
|
|
||||||
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
((data.excerpt as string) || '').toLowerCase().includes(searchQuery.toLowerCase());
|
|
||||||
return matchesCategory && matchesSearch;
|
|
||||||
});
|
|
||||||
}, [items, selectedCategory, searchQuery]);
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredItems.length / ITEMS_PER_PAGE);
|
|
||||||
const paginatedItems = filteredItems.slice(
|
|
||||||
(currentPage - 1) * ITEMS_PER_PAGE,
|
|
||||||
currentPage * ITEMS_PER_PAGE
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, [selectedCategory, searchQuery]);
|
|
||||||
|
|
||||||
if (!ready) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-ink flex items-center justify-center">
|
|
||||||
<div className="text-white/50">加载中...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const NewsCardRenderer = getItemRenderer('news');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-ink text-white overflow-x-hidden" data-theme="dark">
|
|
||||||
<section className="relative min-h-[50vh] flex items-center overflow-hidden bg-ink pt-24">
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<div className="absolute top-1/4 -left-32 w-96 h-96 rounded-full bg-brand/20 blur-[120px]" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10 w-full max-w-7xl mx-auto px-6 lg:px-8 py-20">
|
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/[0.05] border border-white/[0.1] text-sm text-white/70 mb-6">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-brand animate-pulse" />
|
|
||||||
新闻动态
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-white tracking-tight leading-[1.1]">
|
|
||||||
最新动态
|
|
||||||
</h1>
|
|
||||||
<p className="mt-6 text-lg text-white/60 max-w-2xl leading-relaxed">
|
|
||||||
了解公司最新动态、行业洞察和技术分享,与我们一起成长。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="relative py-16 sm:py-20 md:py-28 overflow-hidden bg-ink-light">
|
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6 mb-12">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<button
|
|
||||||
key={cat}
|
|
||||||
onClick={() => setSelectedCategory(cat)}
|
|
||||||
className={`px-5 py-2 text-sm font-medium rounded-xl transition-all ${
|
|
||||||
selectedCategory === cat
|
|
||||||
? 'bg-brand text-white'
|
|
||||||
: 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white border border-white/[0.08]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{cat}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="relative w-full md:w-80">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索新闻..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="w-full pl-10 pr-4 py-2.5 bg-white/[0.05] border border-white/[0.1] rounded-xl text-white text-sm placeholder-white/30 focus:outline-none focus:border-brand/50 focus:ring-1 focus:ring-brand/30 transition-colors"
|
|
||||||
/>
|
|
||||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{paginatedItems.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
|
||||||
{paginatedItems.map((item, i) =>
|
|
||||||
NewsCardRenderer ? (
|
|
||||||
<NewsCardRenderer key={item.id} item={item} index={i} variant="featured" />
|
|
||||||
) : (
|
|
||||||
<div key={item.id} className="p-4 border border-white/10 rounded-lg">
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-20 text-center">
|
|
||||||
<div className="text-5xl mb-4 opacity-30">📭</div>
|
|
||||||
<div className="text-white/50">暂无相关新闻</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="mt-16 flex items-center justify-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="p-2 rounded-lg bg-white/[0.05] text-white/60 hover:bg-white/[0.1] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
|
||||||
<button
|
|
||||||
key={page}
|
|
||||||
onClick={() => setCurrentPage(page)}
|
|
||||||
className={`w-10 h-10 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
currentPage === page
|
|
||||||
? 'bg-brand text-white'
|
|
||||||
: 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="p-2 rounded-lg bg-white/[0.05] text-white/60 hover:bg-white/[0.1] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
→
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useMemo, ChangeEvent } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Search, Calendar, ArrowRight, Newspaper, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
||||||
import { COMPANY_INFO } from '@/lib/constants';
|
|
||||||
import { type NewsItem } from '@/lib/constants/news';
|
|
||||||
import { getMockItems } from '@/lib/cms/mock-data';
|
|
||||||
|
|
||||||
// CMS 数据源:优先使用 prop,回退到 mock-data
|
|
||||||
function getNewsData(newsProp?: NewsItem[]): NewsItem[] {
|
|
||||||
if (newsProp?.length) return newsProp;
|
|
||||||
return getMockItems('news').map(
|
|
||||||
(item) => ({ ...item.data, title: item.data.title || item.title } as unknown as NewsItem),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
|
|
||||||
|
|
||||||
const categories = ['全部', '公司新闻', '研发动态'];
|
|
||||||
const ITEMS_PER_PAGE = 9;
|
|
||||||
|
|
||||||
function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true, margin: '-80px' }}
|
|
||||||
transition={{ duration: 0.5, delay: index * 0.08, ease: EASE_OUT }}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`/news/${newsItem.id}`}
|
|
||||||
className="group relative block border border-border-primary hover:border-brand/30 bg-white transition-all duration-500 hover:-translate-y-2 overflow-hidden h-full"
|
|
||||||
>
|
|
||||||
<div className="absolute top-0 left-0 bottom-0 w-1 bg-brand scale-y-0 group-hover:scale-y-100 transition-transform duration-500 origin-top" />
|
|
||||||
|
|
||||||
{newsItem.image ? (
|
|
||||||
<div className="aspect-video bg-bg-secondary overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={newsItem.image}
|
|
||||||
alt={newsItem.title}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="aspect-video bg-gradient-to-br from-brand/[0.08] to-bg-secondary flex items-center justify-center">
|
|
||||||
<Newspaper className="w-12 h-12 text-brand/30" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="p-6 sm:p-7 lg:p-8">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<Badge variant="outline" className="text-xs border-brand/30 text-brand">
|
|
||||||
{newsItem.category}
|
|
||||||
</Badge>
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-text-muted">
|
|
||||||
<Calendar className="w-3.5 h-3.5" />
|
|
||||||
{newsItem.date}
|
|
||||||
</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">
|
|
||||||
{newsItem.title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="text-sm text-text-secondary line-clamp-3 mb-5 leading-relaxed">
|
|
||||||
{newsItem.excerpt}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center text-brand text-sm font-medium">
|
|
||||||
阅读全文
|
|
||||||
<ArrowRight className="ml-2 w-4 h-4 group-hover:translate-x-1 transition-transform duration-300" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NewsContentV2({ news }: { news?: NewsItem[] }) {
|
|
||||||
const NEWS = getNewsData(news);
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState('全部');
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
|
|
||||||
const filteredNews = useMemo(() => {
|
|
||||||
return NEWS.filter((newsItem) => {
|
|
||||||
const matchesCategory = selectedCategory === '全部' || newsItem.category === selectedCategory;
|
|
||||||
const matchesSearch =
|
|
||||||
newsItem.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
newsItem.excerpt.toLowerCase().includes(searchQuery.toLowerCase());
|
|
||||||
return matchesCategory && matchesSearch;
|
|
||||||
});
|
|
||||||
}, [selectedCategory, searchQuery]);
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredNews.length / ITEMS_PER_PAGE);
|
|
||||||
const paginatedNews = useMemo(() => {
|
|
||||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
|
||||||
return filteredNews.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
|
||||||
}, [filteredNews, currentPage]);
|
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
setCurrentPage(page);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCategoryChange = (category: string) => {
|
|
||||||
setSelectedCategory(category);
|
|
||||||
setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setSearchQuery(e.target.value);
|
|
||||||
setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-white text-ink overflow-x-hidden">
|
|
||||||
{/* Hero Section */}
|
|
||||||
<section className="relative min-h-[75vh] 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-28 sm:py-36 md:py-44 lg:py-56">
|
|
||||||
<div className="max-w-4xl">
|
|
||||||
<ScrollReveal>
|
|
||||||
<SectionLabel>News & Insights</SectionLabel>
|
|
||||||
</ScrollReveal>
|
|
||||||
|
|
||||||
<ScrollReveal delay={0.1}>
|
|
||||||
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-bold leading-[0.92] tracking-tighter mb-10 sm:mb-12 md:mb-16">
|
|
||||||
新闻与
|
|
||||||
<span className="block mt-4 text-brand">洞察</span>
|
|
||||||
</h1>
|
|
||||||
</ScrollReveal>
|
|
||||||
|
|
||||||
<ScrollReveal delay={0.2}>
|
|
||||||
<p className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed">
|
|
||||||
{COMPANY_INFO.displayName}最新动态、产品更新与行业洞察。了解数字化趋势,把握技术脉搏。
|
|
||||||
</p>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Filter + News List Section */}
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-bg-secondary">
|
|
||||||
<div className="absolute top-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 relative z-10">
|
|
||||||
{/* Filters */}
|
|
||||||
<ScrollReveal className="mb-12 sm:mb-16 space-y-6">
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{categories.map((category) => (
|
|
||||||
<Button
|
|
||||||
key={category}
|
|
||||||
variant={selectedCategory === category ? 'primary' : 'outline'}
|
|
||||||
onClick={() => handleCategoryChange(category)}
|
|
||||||
>
|
|
||||||
{category}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative max-w-md">
|
|
||||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-text-muted w-5 h-5 pointer-events-none" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索新闻..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={handleSearchChange}
|
|
||||||
className="pl-12 h-12 bg-white border-border-primary text-ink placeholder:text-text-muted focus:border-brand/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ScrollReveal>
|
|
||||||
|
|
||||||
{/* News Grid */}
|
|
||||||
{paginatedNews.length === 0 ? (
|
|
||||||
<ScrollReveal>
|
|
||||||
<div className="text-center py-24">
|
|
||||||
<Newspaper className="w-16 h-16 text-text-muted/20 mx-auto mb-6" />
|
|
||||||
<p className="text-xl text-text-secondary">没有找到相关新闻</p>
|
|
||||||
<p className="mt-2 text-text-muted">试试其他关键词或分类</p>
|
|
||||||
</div>
|
|
||||||
</ScrollReveal>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<StaggerReveal
|
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8"
|
|
||||||
delayChildren={0.05}
|
|
||||||
>
|
|
||||||
{paginatedNews.map((newsItem, index) => (
|
|
||||||
<NewsCard key={newsItem.id} newsItem={newsItem} index={index} />
|
|
||||||
))}
|
|
||||||
</StaggerReveal>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<ScrollReveal>
|
|
||||||
<div className="flex justify-center items-center gap-2 mt-16">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => handlePageChange(currentPage - 1)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
|
||||||
<Button
|
|
||||||
key={page}
|
|
||||||
variant={currentPage === page ? 'primary' : 'outline'}
|
|
||||||
size="icon"
|
|
||||||
onClick={() => handlePageChange(page)}
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => handlePageChange(currentPage + 1)}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ScrollReveal>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* CTA Section */}
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 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-4 sm:px-6 lg:px-10 relative z-10">
|
|
||||||
<ScrollReveal className="text-center max-w-3xl mx-auto">
|
|
||||||
<SectionLabel className="justify-center">Get in Touch</SectionLabel>
|
|
||||||
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight leading-tight">
|
|
||||||
想了解更多?
|
|
||||||
</h2>
|
|
||||||
<p className="mt-6 text-lg text-text-secondary leading-relaxed">
|
|
||||||
无论您有合作意向还是想探讨技术趋势,我们都欢迎与您交流。
|
|
||||||
</p>
|
|
||||||
<div className="mt-12 flex flex-col sm:flex-row justify-center gap-4 sm:gap-6">
|
|
||||||
<a
|
|
||||||
href="/contact"
|
|
||||||
className="group inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90"
|
|
||||||
>
|
|
||||||
联系我们
|
|
||||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/about"
|
|
||||||
className="inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base text-ink border border-border-primary hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300"
|
|
||||||
>
|
|
||||||
了解公司
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -67,48 +67,6 @@ jest.mock('@/lib/constants', () => ({
|
|||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@/lib/cms/mock-data', () => {
|
|
||||||
const testProduct = {
|
|
||||||
id: 'test-product',
|
|
||||||
title: '测试产品',
|
|
||||||
category: '企业旗舰系列',
|
|
||||||
categoryId: 'enterprise',
|
|
||||||
tags: ['测试', '示例'],
|
|
||||||
status: '研发中',
|
|
||||||
description: '这是测试产品描述',
|
|
||||||
overview: '这是测试产品概述',
|
|
||||||
features: ['功能1', '功能2'],
|
|
||||||
benefits: ['优势1', '优势2'],
|
|
||||||
process: ['步骤1', '步骤2'],
|
|
||||||
specs: ['规格1', '规格2'],
|
|
||||||
};
|
|
||||||
const testItem = {
|
|
||||||
id: 'test-product',
|
|
||||||
modelId: 'model-product',
|
|
||||||
modelCode: 'product',
|
|
||||||
title: '测试产品',
|
|
||||||
slug: 'test-product',
|
|
||||||
status: 'published',
|
|
||||||
version: 1,
|
|
||||||
sortOrder: 0,
|
|
||||||
data: testProduct,
|
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2024-06-01T00:00:00Z',
|
|
||||||
publishedAt: '2024-06-01T00:00:00Z',
|
|
||||||
createdBy: 'system',
|
|
||||||
updatedBy: 'system',
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
getMockItems: jest.fn(() => [testItem]),
|
|
||||||
getMockItemById: jest.fn((_modelCode: string, id: string) =>
|
|
||||||
id === 'test-product' ? testItem : undefined,
|
|
||||||
),
|
|
||||||
getMockItemBySlug: jest.fn((_modelCode: string, slug: string) =>
|
|
||||||
slug === 'test-product' ? testItem : undefined,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ProductDetailPage', () => {
|
describe('ProductDetailPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useMemo, useEffect } from 'react';
|
|
||||||
import { initCms, getItemRenderer } from '@/lib/cms';
|
|
||||||
import type { ContentItem } from '@/lib/cms';
|
|
||||||
import { PRODUCTS, PRODUCT_CATEGORIES } from '@/lib/constants';
|
|
||||||
|
|
||||||
function productToContentItem(product: unknown, index: number): ContentItem {
|
|
||||||
const p = product as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
id: String(p.id),
|
|
||||||
modelId: 'product',
|
|
||||||
modelCode: 'product',
|
|
||||||
title: String(p.title),
|
|
||||||
slug: String(p.id),
|
|
||||||
status: 'published',
|
|
||||||
version: 1,
|
|
||||||
sortOrder: index,
|
|
||||||
data: p,
|
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
|
||||||
publishedAt: '2024-01-01T00:00:00Z',
|
|
||||||
createdBy: 'system',
|
|
||||||
updatedBy: 'system',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProductsContentCms() {
|
|
||||||
const [ready, setReady] = useState(false);
|
|
||||||
const [items, setItems] = useState<ContentItem[]>([]);
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initCms();
|
|
||||||
setItems(PRODUCTS.map(productToContentItem));
|
|
||||||
setReady(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
|
||||||
if (selectedCategory === 'all') return items;
|
|
||||||
return items.filter((item) => {
|
|
||||||
const data = item.data as Record<string, unknown>;
|
|
||||||
return data.categoryId === selectedCategory;
|
|
||||||
});
|
|
||||||
}, [items, selectedCategory]);
|
|
||||||
|
|
||||||
if (!ready) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-ink flex items-center justify-center">
|
|
||||||
<div className="text-white/50">加载中...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProductCardRenderer = getItemRenderer('product');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-ink text-white overflow-x-hidden" data-theme="dark">
|
|
||||||
<section className="relative min-h-[50vh] flex items-center overflow-hidden bg-ink pt-24">
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<div className="absolute top-1/3 -right-32 w-96 h-96 rounded-full bg-blue-500/20 blur-[120px]" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10 w-full max-w-7xl mx-auto px-6 lg:px-8 py-20">
|
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/[0.05] border border-white/[0.1] text-sm text-white/70 mb-6">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-brand animate-pulse" />
|
|
||||||
产品矩阵
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-white tracking-tight leading-[1.1]">
|
|
||||||
产品中心
|
|
||||||
</h1>
|
|
||||||
<p className="mt-6 text-lg text-white/60 max-w-2xl leading-relaxed">
|
|
||||||
自主研发的企业级产品矩阵,覆盖数字化转型全场景需求。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="relative py-16 sm:py-20 md:py-28 overflow-hidden bg-ink-light">
|
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
|
||||||
<div className="flex flex-wrap gap-3 mb-12">
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedCategory('all')}
|
|
||||||
className={`px-5 py-2 text-sm font-medium rounded-xl transition-all ${
|
|
||||||
selectedCategory === 'all'
|
|
||||||
? 'bg-brand text-white'
|
|
||||||
: 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white border border-white/[0.08]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
全部产品
|
|
||||||
</button>
|
|
||||||
{PRODUCT_CATEGORIES.map((cat) => (
|
|
||||||
<button
|
|
||||||
key={cat.id}
|
|
||||||
onClick={() => setSelectedCategory(cat.id)}
|
|
||||||
className={`px-5 py-2 text-sm font-medium rounded-xl transition-all ${
|
|
||||||
selectedCategory === cat.id
|
|
||||||
? 'bg-brand text-white'
|
|
||||||
: 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white border border-white/[0.08]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{cat.title}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
|
||||||
{filteredItems.map((item, i) =>
|
|
||||||
ProductCardRenderer ? (
|
|
||||||
<ProductCardRenderer key={item.id} item={item} index={i} />
|
|
||||||
) : (
|
|
||||||
<div key={item.id} className="p-4 border border-white/10 rounded-lg">
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,484 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRef, useState, useEffect } from 'react';
|
|
||||||
import { motion, useInView } from 'framer-motion';
|
|
||||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
|
||||||
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ArrowRight, Package, Cpu, BarChart3, Users, Settings, FileText, CheckCircle2 } from 'lucide-react';
|
|
||||||
import { PRODUCT_CATEGORIES, type Product } from '@/lib/constants/products';
|
|
||||||
import { getMockItems } from '@/lib/cms/mock-data';
|
|
||||||
|
|
||||||
// CMS 数据源:从 mock-data 读取产品数据
|
|
||||||
const FALLBACK_PRODUCTS: Product[] = getMockItems('product').map(
|
|
||||||
(item) => item.data as unknown as Product,
|
|
||||||
);
|
|
||||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const EASE_SPRING = { type: 'spring', stiffness: 300, damping: 30 } as const;
|
|
||||||
|
|
||||||
const PRODUCT_ICONS: Record<string, React.ReactNode> = {
|
|
||||||
'睿新ERP管理系统': <Package className="w-7 h-7" />,
|
|
||||||
'睿视商业智能分析平台': <BarChart3 className="w-7 h-7" />,
|
|
||||||
'睿新客户关系管理系统': <Users className="w-7 h-7" />,
|
|
||||||
'睿新协同办公平台': <Settings className="w-7 h-7" />,
|
|
||||||
'睿新内容管理系统': <FileText className="w-7 h-7" />,
|
|
||||||
'睿新供应链数字化平台': <Cpu className="w-7 h-7" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
function useCountUp(target: number, start: boolean, duration: number = 2000) {
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
const prefersReducedMotion = useReducedMotion();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!start || prefersReducedMotion) {
|
|
||||||
setCount(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startTime = performance.now();
|
|
||||||
let animationId: number;
|
|
||||||
|
|
||||||
const animate = (currentTime: number) => {
|
|
||||||
const elapsed = currentTime - startTime;
|
|
||||||
const progress = Math.min(elapsed / duration, 1);
|
|
||||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
|
||||||
setCount(Math.floor(target * easeOutQuart));
|
|
||||||
|
|
||||||
if (progress < 1) {
|
|
||||||
animationId = requestAnimationFrame(animate);
|
|
||||||
} else {
|
|
||||||
setCount(target);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
animationId = requestAnimationFrame(animate);
|
|
||||||
return () => cancelAnimationFrame(animationId);
|
|
||||||
}, [target, start, duration, prefersReducedMotion]);
|
|
||||||
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatItem({ value, suffix, label, start, delay }: { value: number; suffix: string; label: string; start: boolean; delay: number }) {
|
|
||||||
const count = useCountUp(value, start, 2000);
|
|
||||||
const shouldReduceMotion = useReducedMotion();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
|
||||||
animate={start ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ delay, duration: 0.6, ease: EASE_OUT }}
|
|
||||||
>
|
|
||||||
<div className="text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-none mb-2">
|
|
||||||
{count}{suffix}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-text-muted">{label}</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HeroSection() {
|
|
||||||
const statsRef = useRef(null);
|
|
||||||
const isInView = useInView(statsRef, { once: true, margin: '-100px' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="relative min-h-[85vh] 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 md:py-32 lg:py-40">
|
|
||||||
<div className="max-w-4xl">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: -50 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 1.1, ease: EASE_OUT }}
|
|
||||||
className="mb-10"
|
|
||||||
>
|
|
||||||
<SectionLabel>Product Matrix</SectionLabel>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.h1
|
|
||||||
initial={{ opacity: 0, y: 70 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 1.2, delay: 0.15, ease: EASE_OUT }}
|
|
||||||
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold text-ink leading-[0.92] tracking-tighter mb-8 sm:mb-10 md:mb-12"
|
|
||||||
>
|
|
||||||
<div className="block">覆盖企业数字化</div>
|
|
||||||
<div className="block mt-5">
|
|
||||||
<span className="relative inline-block">
|
|
||||||
<span className="text-brand font-extrabold">全场景的</span>
|
|
||||||
<motion.span
|
|
||||||
className="absolute -bottom-3 left-0 w-full h-[3px] bg-brand"
|
|
||||||
style={{ transformOrigin: 'left' }}
|
|
||||||
initial={{ scaleX: 0 }}
|
|
||||||
animate={{ scaleX: 1 }}
|
|
||||||
transition={{ duration: 0.8, delay: 1.2, ease: EASE_OUT }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="block mt-5">产品矩阵</div>
|
|
||||||
</motion.h1>
|
|
||||||
|
|
||||||
<motion.p
|
|
||||||
initial={{ opacity: 0, y: 50 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 1, delay: 0.45, ease: EASE_OUT }}
|
|
||||||
className="text-lg sm:text-xl lg:text-2xl text-text-secondary max-w-2xl leading-relaxed mb-8 sm:mb-10 md:mb-12"
|
|
||||||
>
|
|
||||||
从数据智能到业务协同,每一款产品都经过实战验证。
|
|
||||||
6 大核心产品互为补充,常以组合形式出现在行业解决方案中。
|
|
||||||
</motion.p>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 50 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 1, delay: 0.6, ease: EASE_OUT }}
|
|
||||||
className="flex flex-wrap gap-6"
|
|
||||||
>
|
|
||||||
<Button size="xl" asChild>
|
|
||||||
<a href="/contact">
|
|
||||||
预约产品演示
|
|
||||||
<ArrowRight className="w-5 h-5 ml-2" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<Button size="xl" variant="outline" asChild>
|
|
||||||
<a href="/solutions">查看行业方案</a>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div ref={statsRef} className="grid grid-cols-3 gap-12 mt-24 pt-16 border-t border-border-primary max-w-2xl">
|
|
||||||
<StatItem value={6} suffix="+" label="产品线" start={isInView} delay={0.1} />
|
|
||||||
<StatItem value={100} suffix="%" label="自研率" start={isInView} delay={0.2} />
|
|
||||||
<div>
|
|
||||||
<div className="text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-none mb-2">
|
|
||||||
全<span className="text-brand">栈</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-text-muted">技术覆盖</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProductCard({ product, index }: { product: Product; index: number }) {
|
|
||||||
const productIcon = PRODUCT_ICONS[product.title] || <Package className="w-7 h-7" />;
|
|
||||||
|
|
||||||
const colorMap: Record<string, { color: string; bg: string; border: string }> = {
|
|
||||||
enterprise: {
|
|
||||||
color: '#C41E3A',
|
|
||||||
bg: 'bg-brand-soft',
|
|
||||||
border: 'group-hover:border-brand/30',
|
|
||||||
},
|
|
||||||
specialized: {
|
|
||||||
color: '#3b82f6',
|
|
||||||
bg: 'bg-accent-blue-soft',
|
|
||||||
border: 'group-hover:border-accent-blue/30',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const colors = colorMap[product.categoryId] ?? colorMap.enterprise!;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={`/products/${product.id}`}
|
|
||||||
className="group relative block transition-all duration-600 hover:bg-bg-secondary overflow-hidden bg-white border border-border-primary hover:border-brand/30"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute top-0 left-0 h-full w-1 scale-y-0 group-hover:scale-y-100 transition-transform duration-700 origin-top"
|
|
||||||
style={{ backgroundColor: colors.color }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative z-10 p-8 lg:p-10">
|
|
||||||
<div className="flex items-start justify-between mb-8">
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ scale: 1.08, y: -3 }}
|
|
||||||
transition={EASE_SPRING}
|
|
||||||
className={cn('w-14 h-14 flex items-center justify-center rounded-2xl', colors.bg)}
|
|
||||||
style={{ color: colors.color }}
|
|
||||||
>
|
|
||||||
{productIcon}
|
|
||||||
</motion.div>
|
|
||||||
<span className="text-7xl font-bold text-text-muted/10 group-hover:text-text-muted/20 transition-all duration-500 tracking-tighter leading-none">
|
|
||||||
{String(index + 1).padStart(2, '0')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-6">
|
|
||||||
{product.tags.slice(0, 2).map(tag => (
|
|
||||||
<span key={tag} className="px-2.5 py-1 text-xs text-text-muted border border-border-primary group-hover:border-border-secondary group-hover:text-text-secondary transition-all duration-300">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-xl lg:text-2xl font-bold text-ink mb-4 group-hover:translate-x-1.5 transition-transform duration-500">
|
|
||||||
{product.title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="text-text-secondary leading-relaxed mb-8 text-[15px]">
|
|
||||||
{product.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-8">
|
|
||||||
{product.features.slice(0, 3).map((feature) => (
|
|
||||||
<span key={feature} className="flex items-center gap-1.5 text-xs text-text-muted">
|
|
||||||
<CheckCircle2 className="w-3.5 h-3.5 text-brand/70" />
|
|
||||||
{feature}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="inline-flex items-center gap-2.5 text-sm font-bold"
|
|
||||||
style={{ color: colors.color }}
|
|
||||||
whileHover={{ x: 6 }}
|
|
||||||
transition={EASE_SPRING}
|
|
||||||
>
|
|
||||||
<span>了解详情</span>
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EnterpriseProductsSection({ products }: { products: Product[] }) {
|
|
||||||
const enterpriseProducts = products.filter(p => p.categoryId === 'enterprise');
|
|
||||||
const category = PRODUCT_CATEGORIES.find(c => c.id === 'enterprise');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 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">
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-8 sm:gap-10 md:gap-12 mb-16 sm:mb-20 md:mb-20">
|
|
||||||
<ScrollReveal className="lg:max-w-3xl">
|
|
||||||
<div className="flex items-center gap-5 mb-8">
|
|
||||||
<div className="w-16 h-16 rounded-2xl bg-brand-soft flex items-center justify-center text-brand">
|
|
||||||
<Package className="w-8 h-8" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<SectionLabel>Enterprise Suite</SectionLabel>
|
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight">
|
|
||||||
企业套装
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-text-secondary text-lg leading-relaxed max-w-2xl">
|
|
||||||
{category?.description}
|
|
||||||
</p>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StaggerReveal
|
|
||||||
className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-primary"
|
|
||||||
staggerDelay={0.08}
|
|
||||||
delayChildren={0.05}
|
|
||||||
>
|
|
||||||
{enterpriseProducts.map((product, index) => (
|
|
||||||
<ProductCard key={product.id} product={product} index={index} />
|
|
||||||
))}
|
|
||||||
</StaggerReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SpecializedProductsSection({ products }: { products: Product[] }) {
|
|
||||||
const specializedProducts = products.filter(p => p.categoryId === 'specialized');
|
|
||||||
const category = PRODUCT_CATEGORIES.find(c => c.id === 'specialized');
|
|
||||||
|
|
||||||
if (specializedProducts.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-white">
|
|
||||||
<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">
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-8 sm:gap-10 md:gap-12 mb-16 sm:mb-20 md:mb-20">
|
|
||||||
<ScrollReveal className="lg:max-w-3xl">
|
|
||||||
<div className="flex items-center gap-5 mb-8">
|
|
||||||
<div className="w-16 h-16 rounded-2xl bg-accent-blue-soft flex items-center justify-center text-accent-blue">
|
|
||||||
<Cpu className="w-8 h-8" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<SectionLabel>Specialized Products</SectionLabel>
|
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight">
|
|
||||||
专业产品
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-text-secondary text-lg leading-relaxed max-w-2xl">
|
|
||||||
{category?.description}
|
|
||||||
</p>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StaggerReveal
|
|
||||||
className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-primary"
|
|
||||||
staggerDelay={0.08}
|
|
||||||
delayChildren={0.05}
|
|
||||||
>
|
|
||||||
{specializedProducts.map((product, index) => (
|
|
||||||
<ProductCard key={product.id} product={product} index={index} />
|
|
||||||
))}
|
|
||||||
</StaggerReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SuiteCombosSection({ products }: { products: Product[] }) {
|
|
||||||
const SUITE_COMBOS = [
|
|
||||||
{ products: ['erp', 'bi'], label: 'ERP + BI 数据驱动组合', description: '打通业务数据,实现数据驱动决策' },
|
|
||||||
{ products: ['crm', 'bi'], label: 'CRM + BI 客户洞察组合', description: '深度客户洞察,提升销售转化' },
|
|
||||||
{ products: ['erp', 'sds'], label: 'ERP + SDS 供应链优化组合', description: '供应链全链路数字化,降本增效' },
|
|
||||||
{ products: ['cms', 'oa'], label: 'CMS + OA 协同运营组合', description: '内容管理与办公协同一体化' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 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="text-center max-w-3xl mx-auto mb-16 sm:mb-20 md:mb-20">
|
|
||||||
<div className="flex justify-center mb-7">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<div className="w-14 h-px bg-brand" />
|
|
||||||
<span className="text-[11px] tracking-[0.4em] uppercase font-bold text-brand">
|
|
||||||
Recommended Combos
|
|
||||||
</span>
|
|
||||||
<div className="w-14 h-px bg-brand" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight">
|
|
||||||
推荐产品组合
|
|
||||||
</h2>
|
|
||||||
<p className="text-text-secondary text-lg mt-6 leading-relaxed">
|
|
||||||
经过实战验证的产品组合,发挥 1+1 > 2 的协同效应
|
|
||||||
</p>
|
|
||||||
</ScrollReveal>
|
|
||||||
|
|
||||||
<StaggerReveal
|
|
||||||
className="grid md:grid-cols-2 gap-px bg-border-primary"
|
|
||||||
staggerDelay={0.1}
|
|
||||||
delayChildren={0.05}
|
|
||||||
>
|
|
||||||
{SUITE_COMBOS.map((combo, idx) => {
|
|
||||||
const comboProducts = combo.products
|
|
||||||
.map(pid => products.find(p => p.id === pid))
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={idx}
|
|
||||||
href="/contact"
|
|
||||||
className="group relative p-10 lg:p-12 bg-white hover:bg-bg-secondary transition-all duration-500 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-brand scale-x-0 group-hover:scale-x-100 transition-transform duration-700 origin-left" />
|
|
||||||
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<div className="flex -space-x-3">
|
|
||||||
{comboProducts.map((p, pIdx) => p && (
|
|
||||||
<div
|
|
||||||
key={pIdx}
|
|
||||||
className="w-10 h-10 rounded-xl bg-brand-soft flex items-center justify-center text-brand border-2 border-white"
|
|
||||||
style={{ zIndex: 10 - pIdx }}
|
|
||||||
>
|
|
||||||
{PRODUCT_ICONS[p.title] || <Package className="w-5 h-5" />}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<span className="px-3 py-1 text-[11px] font-bold text-brand bg-brand-soft/50">
|
|
||||||
推荐组合
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-xl lg:text-2xl font-bold text-ink mb-3 group-hover:translate-x-1.5 transition-transform duration-500">
|
|
||||||
{combo.label}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="text-text-secondary leading-relaxed mb-6">
|
|
||||||
{combo.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="inline-flex items-center gap-2.5 text-sm font-bold text-brand"
|
|
||||||
whileHover={{ x: 6 }}
|
|
||||||
transition={EASE_SPRING}
|
|
||||||
>
|
|
||||||
<span>咨询组合方案</span>
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</StaggerReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CTASection() {
|
|
||||||
return (
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-white">
|
|
||||||
<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-6 lg:px-10 relative z-10">
|
|
||||||
<ScrollReveal className="text-center max-w-3xl mx-auto">
|
|
||||||
<div className="flex justify-center mb-7">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<div className="w-14 h-px bg-brand" />
|
|
||||||
<span className="text-[11px] tracking-[0.4em] uppercase font-bold text-brand">
|
|
||||||
Get Started
|
|
||||||
</span>
|
|
||||||
<div className="w-14 h-px bg-brand" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
找到适合你的<br />
|
|
||||||
<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="/contact">
|
|
||||||
预约免费咨询
|
|
||||||
<ArrowRight className="w-5 h-5 ml-2" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<Button size="xl" variant="outline" asChild>
|
|
||||||
<a href="/solutions">浏览行业方案</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProductsContentV2({ products: productsProp }: { products?: Product[] }) {
|
|
||||||
const products = productsProp ?? FALLBACK_PRODUCTS;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<HeroSection />
|
|
||||||
<EnterpriseProductsSection products={products} />
|
|
||||||
<SpecializedProductsSection products={products} />
|
|
||||||
<SuiteCombosSection products={products} />
|
|
||||||
<CTASection />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { initCms, getItemRenderer } from '@/lib/cms';
|
|
||||||
import type { ContentItem } from '@/lib/cms';
|
|
||||||
import { SERVICES } from '@/lib/constants';
|
|
||||||
|
|
||||||
function serviceToContentItem(service: unknown, index: number): ContentItem {
|
|
||||||
const s = service as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
id: String(s.id),
|
|
||||||
modelId: 'service',
|
|
||||||
modelCode: 'service',
|
|
||||||
title: String(s.title),
|
|
||||||
slug: String(s.id),
|
|
||||||
status: 'published',
|
|
||||||
version: 1,
|
|
||||||
sortOrder: index,
|
|
||||||
data: s,
|
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
|
||||||
publishedAt: '2024-01-01T00:00:00Z',
|
|
||||||
createdBy: 'system',
|
|
||||||
updatedBy: 'system',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ServicesContentCms() {
|
|
||||||
const [ready, setReady] = useState(false);
|
|
||||||
const [items, setItems] = useState<ContentItem[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initCms();
|
|
||||||
setItems(SERVICES.map(serviceToContentItem));
|
|
||||||
setReady(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!ready) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-ink flex items-center justify-center">
|
|
||||||
<div className="text-white/50">加载中...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ServiceCardRenderer = getItemRenderer('service');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-ink text-white overflow-x-hidden" data-theme="dark">
|
|
||||||
<section className="relative min-h-[50vh] flex items-center overflow-hidden bg-ink pt-24">
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<div className="absolute top-1/3 -left-32 w-96 h-96 rounded-full bg-teal-500/20 blur-[120px]" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10 w-full max-w-7xl mx-auto px-6 lg:px-8 py-20">
|
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/[0.05] border border-white/[0.1] text-sm text-white/70 mb-6">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-brand animate-pulse" />
|
|
||||||
服务能力
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-white tracking-tight leading-[1.1]">
|
|
||||||
服务体系
|
|
||||||
</h1>
|
|
||||||
<p className="mt-6 text-lg text-white/60 max-w-2xl leading-relaxed">
|
|
||||||
端到端的数字化服务能力,从战略咨询到落地实施,陪伴企业成长每一步。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="relative py-16 sm:py-20 md:py-28 overflow-hidden bg-ink-light">
|
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
|
||||||
{items.map((item, i) =>
|
|
||||||
ServiceCardRenderer ? (
|
|
||||||
<ServiceCardRenderer key={item.id} item={item} index={i} />
|
|
||||||
) : (
|
|
||||||
<div key={item.id} className="p-4 border border-white/10 rounded-lg">
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,510 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRef } from 'react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ArrowRight, Code, BarChart3, Lightbulb, Puzzle, ClipboardList, PencilRuler, Rocket, HeartHandshake, Briefcase, RefreshCcw, Handshake, CheckCircle2 } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
|
|
||||||
|
|
||||||
const SERVICES = [
|
|
||||||
{
|
|
||||||
number: '01',
|
|
||||||
title: '软件开发',
|
|
||||||
subtitle: 'Software Development',
|
|
||||||
desc: '从需求分析到上线运维,全栈定制化开发。用扎实的工程能力交付高质量软件,确保每个项目都能创造真实业务价值。',
|
|
||||||
href: '/services/software',
|
|
||||||
icon: <Code className="w-7 h-7" />,
|
|
||||||
colorClass: 'text-brand',
|
|
||||||
bgClass: 'bg-brand-soft',
|
|
||||||
highlights: ['定制化开发', '全栈技术栈', '敏捷开发', '质量保证', '持续支持'],
|
|
||||||
metrics: [
|
|
||||||
{ value: '95%+', label: '准时交付率' },
|
|
||||||
{ value: 'A+', label: '代码质量' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: '02',
|
|
||||||
title: '数据分析',
|
|
||||||
subtitle: 'Data Analytics',
|
|
||||||
desc: '让数据从"存着"变成"用着"。多源数据整合、可视化看板、预测模型,为经营决策提供可量化的洞察支撑。',
|
|
||||||
href: '/services/data',
|
|
||||||
icon: <BarChart3 className="w-7 h-7" />,
|
|
||||||
colorClass: 'text-accent-blue',
|
|
||||||
bgClass: 'bg-accent-blue-soft',
|
|
||||||
highlights: ['数据整合', '可视化分析', '预测模型', '实时分析', '洞察挖掘'],
|
|
||||||
metrics: [
|
|
||||||
{ value: '100+', label: '分析模型' },
|
|
||||||
{ value: '80%+', label: '洞察转化率' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: '03',
|
|
||||||
title: '技术咨询',
|
|
||||||
subtitle: 'Tech Consulting',
|
|
||||||
desc: 'IT 战略规划、技术选型评估、数字化转型路线图——帮您理清方向,规避技术风险,减少试错成本。',
|
|
||||||
href: '/services/consulting',
|
|
||||||
icon: <Lightbulb className="w-7 h-7" />,
|
|
||||||
colorClass: 'text-accent-teal',
|
|
||||||
bgClass: 'bg-accent-teal-soft',
|
|
||||||
highlights: ['IT战略规划', '技术选型评估', '转型路线图', '架构评审', '团队建设'],
|
|
||||||
metrics: [
|
|
||||||
{ value: '90%+', label: '方案落地率' },
|
|
||||||
{ value: '50+', label: '咨询项目' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: '04',
|
|
||||||
title: '行业方案实施',
|
|
||||||
subtitle: 'Industry Solutions',
|
|
||||||
desc: '深耕制造、零售、金融、医疗等行业,提供从咨询到落地的端到端交付。确保方案不只是PPT。',
|
|
||||||
href: '/services/solutions',
|
|
||||||
icon: <Puzzle className="w-7 h-7" />,
|
|
||||||
colorClass: 'text-accent-amber',
|
|
||||||
bgClass: 'bg-accent-amber-soft',
|
|
||||||
highlights: ['行业深耕', '场景化方案', '快速交付', '生态整合', '持续迭代'],
|
|
||||||
metrics: [
|
|
||||||
{ value: '30+', label: '行业方案' },
|
|
||||||
{ value: '85%', label: '客户续约率' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const SERVICE_PROCESS = [
|
|
||||||
{ icon: ClipboardList, step: '01', title: '需求分析', desc: '深入了解业务场景与真实痛点,而非凭空假设' },
|
|
||||||
{ icon: PencilRuler, step: '02', title: '方案设计', desc: '量身定制技术路线,拒绝千篇一律的模板' },
|
|
||||||
{ icon: Rocket, step: '03', title: '敏捷交付', desc: '快速迭代,每个冲刺周期都有可交付成果' },
|
|
||||||
{ icon: HeartHandshake, step: '04', title: '持续支持', desc: '上线不是终点,长期陪伴才是真正的承诺' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SERVICE_MODES = [
|
|
||||||
{
|
|
||||||
icon: Briefcase,
|
|
||||||
title: '项目制',
|
|
||||||
description: '针对明确的需求和交付目标,以项目为单位进行合作。适合有清晰边界的一次性建设类项目。',
|
|
||||||
highlight: '固定范围 · 固定周期',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: RefreshCcw,
|
|
||||||
title: '订阅制',
|
|
||||||
description: '按月或按年持续提供技术支持和迭代优化。适合需要长期维护、持续演进的产品和系统。',
|
|
||||||
highlight: '持续迭代 · 弹性调整',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Handshake,
|
|
||||||
title: '长期陪跑',
|
|
||||||
description: '以合作伙伴身份深度参与您的数字化进程,从规划到执行全程陪伴。适合正在系统性转型的企业。',
|
|
||||||
highlight: '深度参与 · 共同成长',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function GrainOverlay() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 pointer-events-none opacity-[0.03] mix-blend-overlay"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
|
|
||||||
backgroundRepeat: 'repeat',
|
|
||||||
backgroundSize: '300px 300px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionLabel({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
|
||||||
return (
|
|
||||||
<div className={cn('flex items-center gap-5 mb-7', className)}>
|
|
||||||
<div className="w-14 h-px bg-brand" />
|
|
||||||
<span className="text-[11px] tracking-[0.4em] uppercase font-bold text-brand">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HeroSection() {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
ref={containerRef}
|
|
||||||
className="relative min-h-[85vh] flex items-center overflow-hidden bg-ink"
|
|
||||||
>
|
|
||||||
<GrainOverlay />
|
|
||||||
|
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
|
||||||
<div
|
|
||||||
className="absolute top-0 right-0 w-[42%] h-full"
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(135deg, transparent 25%, rgba(196, 30, 58, 0.094) 100%)',
|
|
||||||
clipPath: 'polygon(35% 0, 100% 0, 100% 100%, 0% 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 w-full h-2/5 bg-gradient-to-t from-ink to-transparent" />
|
|
||||||
|
|
||||||
<div className="absolute top-24 right-[22%] w-[350px] h-[350px] rounded-full opacity-15 blur-3xl bg-brand" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-container mx-auto px-6 lg:px-10 relative z-10 w-full py-32 lg:py-40">
|
|
||||||
<div className="max-w-4xl">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: -50 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 1.1, ease: EASE_OUT }}
|
|
||||||
className="mb-10"
|
|
||||||
>
|
|
||||||
<SectionLabel>Professional Services</SectionLabel>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.h1
|
|
||||||
initial={{ opacity: 0, y: 70 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 1.2, delay: 0.15, ease: EASE_OUT }}
|
|
||||||
className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl font-bold text-white leading-[0.85] tracking-tighter mb-12"
|
|
||||||
>
|
|
||||||
<div className="block">从规划到运维的</div>
|
|
||||||
<div className="block mt-4">
|
|
||||||
<span className="relative">
|
|
||||||
<span className="text-brand">全程陪伴</span>
|
|
||||||
<motion.span
|
|
||||||
className="absolute -bottom-2 left-0 w-full h-1 bg-brand"
|
|
||||||
style={{ transformOrigin: 'left' }}
|
|
||||||
initial={{ scaleX: 0 }}
|
|
||||||
animate={{ scaleX: 1 }}
|
|
||||||
transition={{ duration: 0.8, delay: 1.2, ease: EASE_OUT }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</motion.h1>
|
|
||||||
|
|
||||||
<motion.p
|
|
||||||
initial={{ opacity: 0, y: 50 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 1, delay: 0.45, ease: EASE_OUT }}
|
|
||||||
className="text-xl lg:text-2xl text-dark-text-secondary max-w-2xl leading-relaxed mb-12"
|
|
||||||
>
|
|
||||||
不只是技术供应商,更是您数字化转型的同行者。
|
|
||||||
12 年行业深耕,我们深知每个项目背后都是真实的业务诉求。从第一次沟通到系统稳定运行,我们始终在您身边。
|
|
||||||
</motion.p>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 50 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 1, delay: 0.6, ease: EASE_OUT }}
|
|
||||||
className="flex flex-wrap gap-6"
|
|
||||||
>
|
|
||||||
<Button size="xl" asChild>
|
|
||||||
<a href="/contact">
|
|
||||||
聊聊您的需求
|
|
||||||
<ArrowRight className="w-5 h-5 ml-2" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<Button size="xl" variant="outline" asChild>
|
|
||||||
<a href="/products">查看产品矩阵</a>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.9, ease: EASE_OUT }}
|
|
||||||
className="grid grid-cols-3 gap-12 mt-24 pt-16 border-t border-white/10 max-w-2xl"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="text-5xl font-bold text-white mb-3">4<span className="text-brand">大</span></div>
|
|
||||||
<div className="text-sm text-dark-text-muted">服务领域</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-5xl font-bold text-white mb-3">3<span className="text-brand">种</span></div>
|
|
||||||
<div className="text-sm text-dark-text-muted">合作模式</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-5xl font-bold text-white mb-3">全<span className="text-brand">流程</span></div>
|
|
||||||
<div className="text-sm text-dark-text-muted">服务保障</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServicesGridSection() {
|
|
||||||
return (
|
|
||||||
<section className="relative py-36 lg:py-44 overflow-hidden bg-ink-light">
|
|
||||||
<GrainOverlay />
|
|
||||||
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/12 to-transparent" />
|
|
||||||
|
|
||||||
<div className="max-w-container mx-auto px-6 lg:px-10">
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-12 mb-28">
|
|
||||||
<ScrollReveal className="lg:max-w-3xl">
|
|
||||||
<SectionLabel>Service Capabilities</SectionLabel>
|
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white tracking-tight leading-tight">
|
|
||||||
四大核心服务领域
|
|
||||||
</h2>
|
|
||||||
</ScrollReveal>
|
|
||||||
<ScrollReveal delay={0.1}>
|
|
||||||
<p className="text-dark-text-secondary max-w-md leading-relaxed text-lg">
|
|
||||||
覆盖企业数字化全生命周期的专业能力,每一步都有资深专家陪伴。
|
|
||||||
</p>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StaggerReveal
|
|
||||||
className="grid md:grid-cols-2 gap-px bg-white/10"
|
|
||||||
staggerDelay={0.1}
|
|
||||||
delayChildren={0.05}
|
|
||||||
>
|
|
||||||
{SERVICES.map((service, i) => (
|
|
||||||
<a
|
|
||||||
key={i}
|
|
||||||
href={service.href}
|
|
||||||
className="group relative block p-12 lg:p-16 transition-all duration-600 hover:bg-white/[0.02] overflow-hidden bg-ink-light"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'absolute top-0 left-0 h-full w-[3px] scale-y-0 group-hover:scale-y-100 transition-transform duration-700 origin-top',
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: 'currentColor' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="flex items-start justify-between mb-12">
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ scale: 1.1, y: -4 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
|
||||||
className={cn('w-16 h-16 flex items-center justify-center', service.bgClass, service.colorClass)}
|
|
||||||
>
|
|
||||||
{service.icon}
|
|
||||||
</motion.div>
|
|
||||||
<span className="text-7xl font-bold text-white/[0.04] group-hover:text-white/[0.08] transition-colors duration-500 tracking-tight">
|
|
||||||
{service.number}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="text-[11px] tracking-[0.35em] uppercase text-dark-text-muted mb-3 font-semibold">
|
|
||||||
{service.subtitle}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl lg:text-3xl font-bold text-white group-hover:translate-x-2 transition-transform duration-500">
|
|
||||||
{service.title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-dark-text-secondary leading-relaxed mb-10">
|
|
||||||
{service.desc}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 mb-12">
|
|
||||||
{service.highlights.map((h, j) => (
|
|
||||||
<span
|
|
||||||
key={j}
|
|
||||||
className="px-4 py-2 text-xs text-dark-text-muted border border-white/10 group-hover:border-white/20 group-hover:text-dark-text-secondary transition-all duration-300 font-medium"
|
|
||||||
>
|
|
||||||
{h}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-8 pt-8 border-t border-white/[0.06]">
|
|
||||||
{service.metrics.map((m, j) => (
|
|
||||||
<div key={j}>
|
|
||||||
<div className="text-2xl font-bold text-white">{m.value}</div>
|
|
||||||
<div className="text-xs text-dark-text-muted mt-1.5">{m.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className={cn('inline-flex items-center gap-3 text-sm font-bold mt-12', service.colorClass)}
|
|
||||||
whileHover={{ x: 8 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
|
||||||
>
|
|
||||||
<span>了解详情</span>
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</StaggerReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProcessSection() {
|
|
||||||
return (
|
|
||||||
<section className="relative py-36 lg:py-44 overflow-hidden bg-ink">
|
|
||||||
<GrainOverlay />
|
|
||||||
|
|
||||||
<div className="max-w-container mx-auto px-6 lg:px-10">
|
|
||||||
<ScrollReveal className="mb-28 max-w-3xl">
|
|
||||||
<SectionLabel>Our Process</SectionLabel>
|
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white tracking-tight leading-tight mb-8">
|
|
||||||
我们的做事方式
|
|
||||||
</h2>
|
|
||||||
<p className="text-dark-text-secondary leading-relaxed text-lg">
|
|
||||||
没有复杂的流程,只有清晰的四步:听懂需求、想好方案、快速交付、长期陪伴。
|
|
||||||
</p>
|
|
||||||
</ScrollReveal>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute top-[115px] left-0 right-0 h-px hidden lg:block bg-brand/30" />
|
|
||||||
|
|
||||||
<StaggerReveal
|
|
||||||
className="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-white/10"
|
|
||||||
staggerDelay={0.12}
|
|
||||||
delayChildren={0.1}
|
|
||||||
>
|
|
||||||
{SERVICE_PROCESS.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.step}
|
|
||||||
className="relative h-full p-12 lg:p-14 border-t-2 border-transparent hover:border-brand transition-all duration-500 group bg-ink"
|
|
||||||
>
|
|
||||||
<div className="absolute -top-[2px] left-0 w-full flex justify-center hidden lg:flex">
|
|
||||||
<div className="w-5 h-5 rounded-full -mt-[9px] border-2 border-brand bg-ink" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="mx-auto mb-8 w-20 h-20 rounded-2xl bg-ink-light border border-white/10 flex items-center justify-center relative">
|
|
||||||
<Icon className="w-8 h-8 text-white" strokeWidth={1.5} />
|
|
||||||
<span className="absolute -top-2 -right-2 w-7 h-7 rounded-full bg-brand text-white text-xs font-bold flex items-center justify-center">
|
|
||||||
{item.step}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-bold text-white mb-3">{item.title}</h3>
|
|
||||||
<p className="text-sm text-dark-text-muted leading-relaxed">{item.desc}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</StaggerReveal>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModesSection() {
|
|
||||||
return (
|
|
||||||
<section className="relative py-36 lg:py-44 overflow-hidden bg-ink-light">
|
|
||||||
<GrainOverlay />
|
|
||||||
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/12 to-transparent" />
|
|
||||||
|
|
||||||
<div className="max-w-container mx-auto px-6 lg:px-10">
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-12 mb-28">
|
|
||||||
<ScrollReveal className="lg:max-w-3xl">
|
|
||||||
<SectionLabel>Engagement Models</SectionLabel>
|
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white tracking-tight leading-tight">
|
|
||||||
灵活的合作方式
|
|
||||||
</h2>
|
|
||||||
</ScrollReveal>
|
|
||||||
<ScrollReveal delay={0.1}>
|
|
||||||
<p className="text-dark-text-secondary max-w-md leading-relaxed text-lg">
|
|
||||||
不同企业有不同节奏,我们提供三种合作模式,找到最适合您的那一种。
|
|
||||||
</p>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StaggerReveal
|
|
||||||
className="grid md:grid-cols-3 gap-8"
|
|
||||||
staggerDelay={0.1}
|
|
||||||
delayChildren={0.05}
|
|
||||||
>
|
|
||||||
{SERVICE_MODES.map((mode) => {
|
|
||||||
const Icon = mode.icon;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={mode.title}
|
|
||||||
className="group relative p-10 lg:p-12 border border-white/10 hover:border-brand/30 bg-ink transition-all duration-500 hover:-translate-y-1"
|
|
||||||
>
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-brand scale-x-0 group-hover:scale-x-100 transition-transform duration-700 origin-left" />
|
|
||||||
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="w-14 h-14 rounded-xl bg-brand-soft flex items-center justify-center mb-8 text-brand">
|
|
||||||
<Icon className="w-7 h-7" strokeWidth={1.8} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-2xl font-bold text-white mb-5">{mode.title}</h3>
|
|
||||||
|
|
||||||
<p className="text-dark-text-secondary leading-relaxed mb-8">{mode.description}</p>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-6 border-t border-white/10">
|
|
||||||
<CheckCircle2 className="w-5 h-5 text-brand shrink-0" />
|
|
||||||
<span className="text-sm font-medium text-white">{mode.highlight}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</StaggerReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CTASection() {
|
|
||||||
return (
|
|
||||||
<section className="relative py-36 lg:py-44 overflow-hidden bg-ink">
|
|
||||||
<GrainOverlay />
|
|
||||||
|
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] rounded-full opacity-[0.08] blur-3xl bg-brand" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-container mx-auto px-6 lg:px-10 relative z-10">
|
|
||||||
<ScrollReveal className="max-w-3xl mx-auto text-center">
|
|
||||||
<SectionLabel className="justify-center">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<div className="w-14 h-px bg-brand" />
|
|
||||||
<span className="text-[11px] tracking-[0.4em] uppercase font-bold text-brand">
|
|
||||||
Get Started
|
|
||||||
</span>
|
|
||||||
<div className="w-14 h-px bg-brand" />
|
|
||||||
</div>
|
|
||||||
</SectionLabel>
|
|
||||||
|
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-6xl font-bold text-white tracking-tight leading-tight mb-8">
|
|
||||||
聊聊您的需求
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="text-xl text-dark-text-secondary leading-relaxed mb-12 max-w-2xl mx-auto">
|
|
||||||
无论是一个明确的项目,还是一个模糊的想法,我们都愿意坐下来和您一起理清楚。
|
|
||||||
首次咨询免费,没有任何销售压力。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-6 justify-center">
|
|
||||||
<Button size="xl" asChild>
|
|
||||||
<a href="/contact">
|
|
||||||
预约免费咨询
|
|
||||||
<ArrowRight className="w-5 h-5 ml-2" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<Button size="xl" variant="outline" asChild>
|
|
||||||
<a href="/team">了解我们的团队</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ServicesContentV1() {
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<HeroSection />
|
|
||||||
<ServicesGridSection />
|
|
||||||
<ProcessSection />
|
|
||||||
<ModesSection />
|
|
||||||
<CTASection />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ArrowRight, Code, BarChart3, Lightbulb, Puzzle, ClipboardList, PencilRuler, Rocket, HeartHandshake, Briefcase, RefreshCcw, Handshake, CheckCircle2 } from 'lucide-react';
|
|
||||||
import { GrainOverlay, FloatingInkParticles, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const EASE_SPRING = { type: 'spring', stiffness: 300, damping: 30 } as const;
|
|
||||||
|
|
||||||
const SERVICES_PARTICLES = [
|
|
||||||
{ x: 12, y: 35, scale: 0.8, duration: 10, delay: 0, size: 3 },
|
|
||||||
{ x: 85, y: 55, scale: 0.6, duration: 12, delay: 1.5, size: 2 },
|
|
||||||
{ x: 48, y: 45, scale: 1.0, duration: 9, delay: 0.8, size: 4 },
|
|
||||||
{ x: 70, y: 28, scale: 0.7, duration: 11, delay: 2.2, size: 2.5 },
|
|
||||||
{ x: 30, y: 78, scale: 0.9, duration: 8.5, delay: 1.0, size: 3.5 },
|
|
||||||
{ x: 65, y: 68, scale: 0.55, duration: 13, delay: 2.8, size: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SERVICES = [
|
|
||||||
{
|
|
||||||
number: '01',
|
|
||||||
title: '软件开发',
|
|
||||||
subtitle: 'Software Development',
|
|
||||||
desc: '从需求分析到上线运维,全栈定制化开发。用扎实的工程能力交付高质量软件,确保每个项目都能创造真实业务价值。',
|
|
||||||
href: '/services/software',
|
|
||||||
icon: <Code className="w-7 h-7" />,
|
|
||||||
color: '#C41E3A',
|
|
||||||
colorClass: 'text-brand',
|
|
||||||
bgClass: 'bg-brand-soft',
|
|
||||||
highlights: ['定制化开发', '全栈技术栈', '敏捷开发', '质量保证', '持续支持'],
|
|
||||||
metrics: [
|
|
||||||
{ value: '95%+', label: '准时交付率' },
|
|
||||||
{ value: 'A+', label: '代码质量' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: '02',
|
|
||||||
title: '数据分析',
|
|
||||||
subtitle: 'Data Analytics',
|
|
||||||
desc: '让数据从"存着"变成"用着"。多源数据整合、可视化看板、预测模型,为经营决策提供可量化的洞察支撑。',
|
|
||||||
href: '/services/data',
|
|
||||||
icon: <BarChart3 className="w-7 h-7" />,
|
|
||||||
color: '#3b82f6',
|
|
||||||
colorClass: 'text-accent-blue',
|
|
||||||
bgClass: 'bg-accent-blue-soft',
|
|
||||||
highlights: ['数据整合', '可视化分析', '预测模型', '实时分析', '洞察挖掘'],
|
|
||||||
metrics: [
|
|
||||||
{ value: '100+', label: '分析模型' },
|
|
||||||
{ value: '80%+', label: '洞察转化率' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: '03',
|
|
||||||
title: '技术咨询',
|
|
||||||
subtitle: 'Tech Consulting',
|
|
||||||
desc: 'IT 战略规划、技术选型评估、数字化转型路线图——帮您理清方向,规避技术风险,减少试错成本。',
|
|
||||||
href: '/services/consulting',
|
|
||||||
icon: <Lightbulb className="w-7 h-7" />,
|
|
||||||
color: '#14b8a6',
|
|
||||||
colorClass: 'text-accent-teal',
|
|
||||||
bgClass: 'bg-accent-teal-soft',
|
|
||||||
highlights: ['IT战略规划', '技术选型评估', '转型路线图', '架构评审', '团队建设'],
|
|
||||||
metrics: [
|
|
||||||
{ value: '90%+', label: '方案落地率' },
|
|
||||||
{ value: '50+', label: '咨询项目' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: '04',
|
|
||||||
title: '行业方案实施',
|
|
||||||
subtitle: 'Industry Solutions',
|
|
||||||
desc: '深耕制造、零售、金融、医疗等行业,提供从咨询到落地的端到端交付。确保方案不只是PPT。',
|
|
||||||
href: '/services/solutions',
|
|
||||||
icon: <Puzzle className="w-7 h-7" />,
|
|
||||||
color: '#f59e0b',
|
|
||||||
colorClass: 'text-accent-amber',
|
|
||||||
bgClass: 'bg-accent-amber-soft',
|
|
||||||
highlights: ['行业深耕', '场景化方案', '快速交付', '生态整合', '持续迭代'],
|
|
||||||
metrics: [
|
|
||||||
{ value: '30+', label: '行业方案' },
|
|
||||||
{ value: '85%', label: '客户续约率' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const SERVICE_PROCESS = [
|
|
||||||
{ icon: ClipboardList, step: '01', title: '需求分析', desc: '深入了解业务场景与真实痛点,而非凭空假设' },
|
|
||||||
{ icon: PencilRuler, step: '02', title: '方案设计', desc: '量身定制技术路线,拒绝千篇一律的模板' },
|
|
||||||
{ icon: Rocket, step: '03', title: '敏捷交付', desc: '快速迭代,每个冲刺周期都有可交付成果' },
|
|
||||||
{ icon: HeartHandshake, step: '04', title: '持续支持', desc: '上线不是终点,长期陪伴才是真正的承诺' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SERVICE_MODES = [
|
|
||||||
{
|
|
||||||
icon: Briefcase,
|
|
||||||
title: '项目制',
|
|
||||||
description: '针对明确的需求和交付目标,以项目为单位进行合作。适合有清晰边界的一次性建设类项目。',
|
|
||||||
highlight: '固定范围 · 固定周期',
|
|
||||||
color: '#C41E3A',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: RefreshCcw,
|
|
||||||
title: '订阅制',
|
|
||||||
description: '按月或按年持续提供技术支持和迭代优化。适合需要长期维护、持续演进的产品和系统。',
|
|
||||||
highlight: '持续迭代 · 弹性调整',
|
|
||||||
color: '#3b82f6',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Handshake,
|
|
||||||
title: '长期陪跑',
|
|
||||||
description: '以合作伙伴身份深度参与您的数字化进程,从规划到执行全程陪伴。适合正在系统性转型的企业。',
|
|
||||||
highlight: '深度参与 · 共同成长',
|
|
||||||
color: '#14b8a6',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function HeroSection() {
|
|
||||||
return (
|
|
||||||
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-ink">
|
|
||||||
<GrainOverlay />
|
|
||||||
<FloatingInkParticles particles={SERVICES_PARTICLES} />
|
|
||||||
|
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
|
||||||
<div
|
|
||||||
className="absolute top-0 right-0 w-[42%] h-full"
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(145deg, transparent 25%, rgba(196, 30, 58, 0.03) 100%)',
|
|
||||||
clipPath: 'polygon(35% 0, 100% 0, 100% 100%, 0% 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="absolute bottom-0 left-0 w-[50%] h-full"
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(220deg, transparent 35%, rgba(20, 184, 166, 0.02) 100%)',
|
|
||||||
clipPath: 'polygon(0 0, 65% 0, 100% 100%, 0 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 w-full h-2/5 bg-gradient-to-t from-ink to-transparent" />
|
|
||||||
|
|
||||||
<div className="absolute top-24 right-[22%] w-[360px] h-[360px] rounded-full opacity-[0.04] blur-3xl bg-brand" />
|
|
||||||
<div className="absolute bottom-28 left-[15%] w-[300px] h-[300px] rounded-full opacity-[0.025] blur-3xl bg-teal-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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 md:py-32 lg:py-40">
|
|
||||||
<div className="max-w-4xl">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: -50 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 1.1, ease: EASE_OUT }}
|
|
||||||
className="mb-10"
|
|
||||||
>
|
|
||||||
<SectionLabel>Professional Services</SectionLabel>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.h1
|
|
||||||
initial={{ opacity: 0, y: 70 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 1.2, delay: 0.15, ease: EASE_OUT }}
|
|
||||||
className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl font-bold text-white leading-[0.88] tracking-tighter mb-12"
|
|
||||||
>
|
|
||||||
<div className="block">从规划到运维的</div>
|
|
||||||
<div className="block mt-5">
|
|
||||||
<span className="relative inline-block">
|
|
||||||
<span className="text-brand font-extrabold">全程陪伴</span>
|
|
||||||
<motion.span
|
|
||||||
className="absolute -bottom-3 left-0 w-full h-[3px] bg-brand"
|
|
||||||
style={{ transformOrigin: 'left' }}
|
|
||||||
initial={{ scaleX: 0 }}
|
|
||||||
animate={{ scaleX: 1 }}
|
|
||||||
transition={{ duration: 0.8, delay: 1.2, ease: EASE_OUT }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</motion.h1>
|
|
||||||
|
|
||||||
<motion.p
|
|
||||||
initial={{ opacity: 0, y: 50 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 1, delay: 0.45, ease: EASE_OUT }}
|
|
||||||
className="text-xl lg:text-2xl text-dark-text-secondary max-w-2xl leading-relaxed mb-12"
|
|
||||||
>
|
|
||||||
不只是技术供应商,更是您数字化转型的同行者。
|
|
||||||
我们深知每个项目背后都是真实的业务诉求。从第一次沟通到系统稳定运行,我们始终在您身边。
|
|
||||||
</motion.p>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 50 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 1, delay: 0.6, ease: EASE_OUT }}
|
|
||||||
className="flex flex-wrap gap-6"
|
|
||||||
>
|
|
||||||
<Button size="xl" asChild>
|
|
||||||
<a href="/contact">
|
|
||||||
聊聊您的需求
|
|
||||||
<ArrowRight className="w-5 h-5 ml-2" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<Button size="xl" variant="outline" asChild>
|
|
||||||
<a href="/products">查看产品矩阵</a>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-12 mt-24 pt-16 border-t border-white/10 max-w-2xl">
|
|
||||||
<div>
|
|
||||||
<div className="text-4xl lg:text-5xl font-bold text-white tracking-tight leading-none mb-2">
|
|
||||||
4<span className="text-brand">大</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-dark-text-muted">服务领域</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-4xl lg:text-5xl font-bold text-white tracking-tight leading-none mb-2">
|
|
||||||
3<span className="text-brand">种</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-dark-text-muted">合作模式</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-4xl lg:text-5xl font-bold text-white tracking-tight leading-none mb-2">
|
|
||||||
全<span className="text-brand">流程</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-dark-text-muted">服务保障</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServicesGridSection() {
|
|
||||||
return (
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-ink-light">
|
|
||||||
<GrainOverlay />
|
|
||||||
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
|
||||||
|
|
||||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-8 sm:gap-10 md:gap-12 mb-16 sm:mb-20 md:mb-20">
|
|
||||||
<ScrollReveal className="lg:max-w-3xl">
|
|
||||||
<SectionLabel>Service Capabilities</SectionLabel>
|
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-white tracking-tight leading-tight">
|
|
||||||
四大核心服务领域
|
|
||||||
</h2>
|
|
||||||
</ScrollReveal>
|
|
||||||
<ScrollReveal delay={0.1}>
|
|
||||||
<p className="text-dark-text-secondary max-w-md leading-relaxed text-lg">
|
|
||||||
覆盖企业数字化全生命周期的专业能力,每一步都有资深专家陪伴。
|
|
||||||
</p>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StaggerReveal
|
|
||||||
className="grid md:grid-cols-2 gap-px bg-white/10"
|
|
||||||
staggerDelay={0.1}
|
|
||||||
delayChildren={0.05}
|
|
||||||
>
|
|
||||||
{SERVICES.map((service, i) => (
|
|
||||||
<a
|
|
||||||
key={i}
|
|
||||||
href={service.href}
|
|
||||||
className="group relative block p-10 lg:p-12 transition-all duration-600 hover:bg-white/[0.02] overflow-hidden bg-ink border border-transparent hover:border-white/10"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute top-0 left-0 h-full w-1 scale-y-0 group-hover:scale-y-100 transition-transform duration-700 origin-top"
|
|
||||||
style={{ backgroundColor: service.color }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute top-8 right-8 w-48 h-48 rounded-full opacity-0 group-hover:opacity-100 blur-3xl transition-opacity duration-700" style={{ backgroundColor: service.color + '06' }} />
|
|
||||||
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="flex items-start justify-between mb-10">
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ scale: 1.08, y: -3 }}
|
|
||||||
transition={EASE_SPRING}
|
|
||||||
className={cn('w-16 h-16 flex items-center justify-center rounded-2xl', service.bgClass, service.colorClass)}
|
|
||||||
>
|
|
||||||
{service.icon}
|
|
||||||
</motion.div>
|
|
||||||
<span className="text-7xl font-bold text-white/[0.05] group-hover:text-white/[0.1] transition-all duration-500 tracking-tighter leading-none">
|
|
||||||
{service.number}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="text-[11px] tracking-[0.35em] uppercase text-dark-text-muted mb-3 font-semibold">
|
|
||||||
{service.subtitle}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl lg:text-3xl font-bold text-white group-hover:translate-x-1.5 transition-transform duration-500">
|
|
||||||
{service.title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-dark-text-secondary leading-relaxed mb-8 text-[15px]">
|
|
||||||
{service.desc}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2.5 mb-8">
|
|
||||||
{service.highlights.map((h, j) => (
|
|
||||||
<span
|
|
||||||
key={j}
|
|
||||||
className="px-3 py-1.5 text-xs text-dark-text-muted border border-white/10 group-hover:border-white/20 group-hover:text-dark-text-secondary transition-all duration-300 font-medium"
|
|
||||||
>
|
|
||||||
{h}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-8 pt-6 border-t border-white/[0.06] mb-8">
|
|
||||||
{service.metrics.map((m, j) => (
|
|
||||||
<div key={j}>
|
|
||||||
<div className="text-2xl font-bold text-white">{m.value}</div>
|
|
||||||
<div className="text-xs text-dark-text-muted mt-1.5">{m.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className={cn('inline-flex items-center gap-2.5 text-sm font-bold', service.colorClass)}
|
|
||||||
whileHover={{ x: 6 }}
|
|
||||||
transition={EASE_SPRING}
|
|
||||||
>
|
|
||||||
<span>了解详情</span>
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</StaggerReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProcessSection() {
|
|
||||||
return (
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-ink">
|
|
||||||
<GrainOverlay />
|
|
||||||
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
|
||||||
|
|
||||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
|
||||||
<ScrollReveal className="mb-16 sm:mb-20 md:mb-20 max-w-3xl">
|
|
||||||
<SectionLabel>Our Process</SectionLabel>
|
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white tracking-tight leading-tight mb-8">
|
|
||||||
我们的做事方式
|
|
||||||
</h2>
|
|
||||||
<p className="text-dark-text-secondary leading-relaxed text-lg">
|
|
||||||
没有复杂的流程,只有清晰的四步:听懂需求、想好方案、快速交付、长期陪伴。
|
|
||||||
</p>
|
|
||||||
</ScrollReveal>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute top-[115px] left-0 right-0 h-px hidden lg:block bg-brand/20" />
|
|
||||||
|
|
||||||
<StaggerReveal
|
|
||||||
className="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-white/10"
|
|
||||||
staggerDelay={0.12}
|
|
||||||
delayChildren={0.1}
|
|
||||||
>
|
|
||||||
{SERVICE_PROCESS.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.step}
|
|
||||||
className="relative h-full p-10 lg:p-12 border-t-2 border-transparent hover:border-brand transition-all duration-500 group bg-ink"
|
|
||||||
>
|
|
||||||
<div className="absolute -top-[2px] left-0 w-full flex justify-center hidden lg:flex">
|
|
||||||
<div className="w-5 h-5 rounded-full -mt-[9px] border-2 border-brand bg-ink" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="mx-auto mb-8 w-20 h-20 rounded-2xl bg-ink-light border border-white/10 flex items-center justify-center relative">
|
|
||||||
<Icon className="w-8 h-8 text-white" strokeWidth={1.5} />
|
|
||||||
<span className="absolute -top-2 -right-2 w-7 h-7 rounded-full bg-brand text-white text-xs font-bold flex items-center justify-center">
|
|
||||||
{item.step}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-bold text-white mb-3">{item.title}</h3>
|
|
||||||
<p className="text-sm text-dark-text-muted leading-relaxed">{item.desc}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</StaggerReveal>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModesSection() {
|
|
||||||
return (
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-ink-light">
|
|
||||||
<GrainOverlay />
|
|
||||||
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
|
||||||
|
|
||||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-8 sm:gap-10 md:gap-12 mb-16 sm:mb-20 md:mb-20">
|
|
||||||
<ScrollReveal className="lg:max-w-3xl">
|
|
||||||
<SectionLabel>Engagement Models</SectionLabel>
|
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-white tracking-tight leading-tight">
|
|
||||||
灵活的合作方式
|
|
||||||
</h2>
|
|
||||||
</ScrollReveal>
|
|
||||||
<ScrollReveal delay={0.1}>
|
|
||||||
<p className="text-dark-text-secondary max-w-md leading-relaxed text-lg">
|
|
||||||
不同企业有不同节奏,我们提供三种合作模式,找到最适合您的那一种。
|
|
||||||
</p>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StaggerReveal
|
|
||||||
className="grid md:grid-cols-3 gap-px bg-white/10"
|
|
||||||
staggerDelay={0.1}
|
|
||||||
delayChildren={0.05}
|
|
||||||
>
|
|
||||||
{SERVICE_MODES.map((mode) => {
|
|
||||||
const Icon = mode.icon;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={mode.title}
|
|
||||||
className="group relative p-10 lg:p-12 bg-ink transition-all duration-500 hover:bg-white/[0.02]"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute top-0 left-0 h-full w-1 scale-y-0 group-hover:scale-y-100 transition-transform duration-700 origin-top"
|
|
||||||
style={{ backgroundColor: mode.color }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute top-8 right-8 w-40 h-40 rounded-full opacity-0 group-hover:opacity-100 blur-3xl transition-opacity duration-700" style={{ backgroundColor: mode.color + '06' }} />
|
|
||||||
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div
|
|
||||||
className="w-14 h-14 rounded-xl flex items-center justify-center mb-8"
|
|
||||||
style={{ backgroundColor: mode.color + '12', color: mode.color }}
|
|
||||||
>
|
|
||||||
<Icon className="w-7 h-7" strokeWidth={1.8} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-2xl font-bold text-white mb-5">{mode.title}</h3>
|
|
||||||
|
|
||||||
<p className="text-dark-text-secondary leading-relaxed mb-8 text-[15px]">{mode.description}</p>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-6 border-t border-white/10">
|
|
||||||
<CheckCircle2 className="w-5 h-5 shrink-0" style={{ color: mode.color }} />
|
|
||||||
<span className="text-sm font-medium text-white">{mode.highlight}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</StaggerReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CTASection() {
|
|
||||||
return (
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-ink">
|
|
||||||
<GrainOverlay />
|
|
||||||
<FloatingInkParticles />
|
|
||||||
|
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-brand/30 to-transparent" />
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-brand/30 to-transparent" />
|
|
||||||
|
|
||||||
<div className="absolute top-1/4 right-[10%] w-[380px] h-[380px] rounded-full opacity-[0.03] blur-3xl bg-brand" />
|
|
||||||
<div className="absolute bottom-1/4 left-[10%] w-[320px] h-[320px] rounded-full opacity-[0.02] blur-3xl bg-teal-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-container mx-auto px-6 lg:px-10 relative z-10">
|
|
||||||
<ScrollReveal className="text-center max-w-3xl mx-auto">
|
|
||||||
<div className="flex justify-center mb-7">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<div className="w-14 h-px bg-brand" />
|
|
||||||
<span className="text-[11px] tracking-[0.4em] uppercase font-bold text-brand">
|
|
||||||
Get Started
|
|
||||||
</span>
|
|
||||||
<div className="w-14 h-px bg-brand" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-white tracking-tight leading-tight mb-6 sm:mb-8">
|
|
||||||
聊聊您的<br />
|
|
||||||
<span className="text-brand">需求</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="text-xl text-dark-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="/contact">
|
|
||||||
预约免费咨询
|
|
||||||
<ArrowRight className="w-5 h-5 ml-2" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<Button size="xl" variant="outline" asChild>
|
|
||||||
<a href="/team">了解我们的团队</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ServicesContentV2() {
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<HeroSection />
|
|
||||||
<ServicesGridSection />
|
|
||||||
<ProcessSection />
|
|
||||||
<ModesSection />
|
|
||||||
<CTASection />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { initCms, getItemRenderer } from '@/lib/cms';
|
|
||||||
import type { ContentItem } from '@/lib/cms';
|
|
||||||
import { SOLUTIONS } from '@/lib/constants/solutions';
|
|
||||||
|
|
||||||
function solutionToContentItem(solution: unknown, index: number): ContentItem {
|
|
||||||
const s = solution as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
id: String(s.id),
|
|
||||||
modelId: 'solution',
|
|
||||||
modelCode: 'solution',
|
|
||||||
title: String(s.title),
|
|
||||||
slug: String(s.id),
|
|
||||||
status: 'published',
|
|
||||||
version: 1,
|
|
||||||
sortOrder: index,
|
|
||||||
data: s,
|
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
|
||||||
publishedAt: '2024-01-01T00:00:00Z',
|
|
||||||
createdBy: 'system',
|
|
||||||
updatedBy: 'system',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SolutionsContentCms() {
|
|
||||||
const [ready, setReady] = useState(false);
|
|
||||||
const [items, setItems] = useState<ContentItem[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initCms();
|
|
||||||
setItems(SOLUTIONS.map(solutionToContentItem));
|
|
||||||
setReady(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!ready) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-ink flex items-center justify-center">
|
|
||||||
<div className="text-white/50">加载中...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SolutionCardRenderer = getItemRenderer('solution');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-ink text-white overflow-x-hidden" data-theme="dark">
|
|
||||||
<section className="relative min-h-[50vh] flex items-center overflow-hidden bg-ink pt-24">
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<div className="absolute top-1/3 -right-32 w-96 h-96 rounded-full bg-purple-500/20 blur-[120px]" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10 w-full max-w-7xl mx-auto px-6 lg:px-8 py-20">
|
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/[0.05] border border-white/[0.1] text-sm text-white/70 mb-6">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-brand animate-pulse" />
|
|
||||||
解决方案
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-white tracking-tight leading-[1.1]">
|
|
||||||
行业解决方案
|
|
||||||
</h1>
|
|
||||||
<p className="mt-6 text-lg text-white/60 max-w-2xl leading-relaxed">
|
|
||||||
深入理解不同行业的业务特性,提供量身定制的数字化解决方案。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="relative py-16 sm:py-20 md:py-28 overflow-hidden bg-ink-light">
|
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
|
||||||
{items.map((item, i) =>
|
|
||||||
SolutionCardRenderer ? (
|
|
||||||
<SolutionCardRenderer key={item.id} item={item} index={i} />
|
|
||||||
) : (
|
|
||||||
<div key={item.id} className="p-4 border border-white/10 rounded-lg">
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRef, useState, useEffect } from 'react';
|
|
||||||
import { motion, useInView } from 'framer-motion';
|
|
||||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
|
||||||
import { GrainOverlay, FloatingInkParticles, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ArrowRight, Factory, ShoppingCart, Heart, GraduationCap, Sparkles } from 'lucide-react';
|
|
||||||
import { type Solution } from '@/lib/constants/solutions';
|
|
||||||
import { PRODUCTS } from '@/lib/constants/products';
|
|
||||||
import { getMockItems } from '@/lib/cms/mock-data';
|
|
||||||
|
|
||||||
// CMS 数据源:从 mock-data 读取解决方案数据
|
|
||||||
const SOLUTIONS: Solution[] = getMockItems('solution').map(
|
|
||||||
(item) => item.data as unknown as Solution,
|
|
||||||
);
|
|
||||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const EASE_SPRING = { type: 'spring', stiffness: 300, damping: 30 } as const;
|
|
||||||
|
|
||||||
const INDUSTRY_ICONS: Record<string, React.ReactNode> = {
|
|
||||||
制造业: <Factory className="w-7 h-7" />,
|
|
||||||
零售业: <ShoppingCart className="w-7 h-7" />,
|
|
||||||
医疗: <Heart className="w-7 h-7" />,
|
|
||||||
教育: <GraduationCap className="w-7 h-7" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
const INDUSTRY_COLORS: Record<string, { color: string; bg: string; soft: string }> = {
|
|
||||||
制造业: { color: '#C41E3A', bg: 'bg-brand-soft', soft: 'rgba(196, 30, 58, 0.08)' },
|
|
||||||
零售业: { color: '#3b82f6', bg: 'bg-accent-blue-soft', soft: 'rgba(59, 130, 246, 0.08)' },
|
|
||||||
医疗: { color: '#14b8a6', bg: 'bg-accent-teal-soft', soft: 'rgba(20, 184, 166, 0.08)' },
|
|
||||||
教育: { color: '#f59e0b', bg: 'bg-accent-amber-soft', soft: 'rgba(245, 158, 11, 0.08)' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function useCountUp(target: number, start: boolean, duration: number = 2000) {
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
const prefersReducedMotion = useReducedMotion();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!start || prefersReducedMotion) {
|
|
||||||
setCount(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startTime = performance.now();
|
|
||||||
let animationId: number;
|
|
||||||
|
|
||||||
const animate = (currentTime: number) => {
|
|
||||||
const elapsed = currentTime - startTime;
|
|
||||||
const progress = Math.min(elapsed / duration, 1);
|
|
||||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
|
||||||
setCount(Math.floor(target * easeOutQuart));
|
|
||||||
|
|
||||||
if (progress < 1) {
|
|
||||||
animationId = requestAnimationFrame(animate);
|
|
||||||
} else {
|
|
||||||
setCount(target);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
animationId = requestAnimationFrame(animate);
|
|
||||||
return () => cancelAnimationFrame(animationId);
|
|
||||||
}, [target, start, duration, prefersReducedMotion]);
|
|
||||||
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SOLUTION_PARTICLES = [
|
|
||||||
{ x: 18, y: 28, scale: 0.8, duration: 10, delay: 0, size: 3 },
|
|
||||||
{ x: 78, y: 62, scale: 0.6, duration: 12, delay: 1.5, size: 2 },
|
|
||||||
{ x: 50, y: 48, scale: 1.0, duration: 9, delay: 0.8, size: 4 },
|
|
||||||
{ x: 72, y: 30, scale: 0.7, duration: 11, delay: 2.2, size: 2.5 },
|
|
||||||
{ x: 28, y: 72, scale: 0.9, duration: 8.5, delay: 1.0, size: 3.5 },
|
|
||||||
{ x: 62, y: 70, scale: 0.55, duration: 13, delay: 2.8, size: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
function HeroSection() {
|
|
||||||
const statsRef = useRef(null);
|
|
||||||
const isInView = useInView(statsRef, { once: true, margin: '-100px' });
|
|
||||||
const count4 = useCountUp(4, isInView, 1500);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-ink">
|
|
||||||
<GrainOverlay />
|
|
||||||
<FloatingInkParticles particles={SOLUTION_PARTICLES} />
|
|
||||||
|
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
|
||||||
<div
|
|
||||||
className="absolute top-0 right-0 w-[42%] h-full"
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(145deg, transparent 25%, rgba(196, 30, 58, 0.03) 100%)',
|
|
||||||
clipPath: 'polygon(35% 0, 100% 0, 100% 100%, 0% 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="absolute bottom-0 left-0 w-[50%] h-full"
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(220deg, transparent 35%, rgba(20, 184, 166, 0.02) 100%)',
|
|
||||||
clipPath: 'polygon(0 0, 65% 0, 100% 100%, 0 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 w-full h-2/5 bg-gradient-to-t from-ink to-transparent" />
|
|
||||||
|
|
||||||
<div className="absolute top-24 right-[20%] w-[360px] h-[360px] rounded-full opacity-[0.04] blur-3xl bg-brand" />
|
|
||||||
<div className="absolute bottom-28 left-[18%] w-[300px] h-[300px] rounded-full opacity-[0.025] blur-3xl bg-teal-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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 md:py-32 lg:py-40">
|
|
||||||
<div className="max-w-4xl">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: -50 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 1.1, ease: EASE_OUT }}
|
|
||||||
className="mb-10"
|
|
||||||
>
|
|
||||||
<SectionLabel>Industry Solutions</SectionLabel>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.h1
|
|
||||||
initial={{ opacity: 0, y: 70 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 1.2, delay: 0.15, ease: EASE_OUT }}
|
|
||||||
className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl font-bold text-white leading-[0.88] tracking-tighter mb-12"
|
|
||||||
>
|
|
||||||
<div className="block">深耕行业场景的</div>
|
|
||||||
<div className="block mt-5">
|
|
||||||
<span className="relative inline-block">
|
|
||||||
<span className="text-brand font-extrabold">解决方案</span>
|
|
||||||
<motion.span
|
|
||||||
className="absolute -bottom-3 left-0 w-full h-[3px] bg-brand"
|
|
||||||
style={{ transformOrigin: 'left' }}
|
|
||||||
initial={{ scaleX: 0 }}
|
|
||||||
animate={{ scaleX: 1 }}
|
|
||||||
transition={{ duration: 0.8, delay: 1.2, ease: EASE_OUT }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</motion.h1>
|
|
||||||
|
|
||||||
<motion.p
|
|
||||||
initial={{ opacity: 0, y: 50 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 1, delay: 0.45, ease: EASE_OUT }}
|
|
||||||
className="text-xl lg:text-2xl text-dark-text-secondary max-w-2xl leading-relaxed mb-12"
|
|
||||||
>
|
|
||||||
端到端交付,推荐套装组合 + 配套服务包。
|
|
||||||
基于自研产品矩阵,为制造业、零售业、教育、医疗等行业量身定制最佳实践。
|
|
||||||
</motion.p>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 50 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 1, delay: 0.6, ease: EASE_OUT }}
|
|
||||||
className="flex flex-wrap gap-6"
|
|
||||||
>
|
|
||||||
<Button size="xl" asChild>
|
|
||||||
<a href="/contact">
|
|
||||||
立即咨询
|
|
||||||
<ArrowRight className="w-5 h-5 ml-2" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<Button size="xl" variant="outline" asChild>
|
|
||||||
<a href="/products">浏览产品</a>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div ref={statsRef} className="grid grid-cols-3 gap-12 mt-24 pt-16 border-t border-white/10 max-w-2xl">
|
|
||||||
<div>
|
|
||||||
<div className="text-4xl lg:text-5xl font-bold text-white tracking-tight leading-none mb-2">
|
|
||||||
{count4}<span className="text-brand">+</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-dark-text-muted">行业覆盖</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-4xl lg:text-5xl font-bold text-white tracking-tight leading-none mb-2">
|
|
||||||
端到<span className="text-brand">端</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-dark-text-muted">交付模式</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-4xl lg:text-5xl font-bold text-white tracking-tight leading-none mb-2">
|
|
||||||
定制<span className="text-brand">化</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-dark-text-muted">方案特点</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SolutionCard({ solution, index }: { solution: Solution; index: number }) {
|
|
||||||
const industryIcon = INDUSTRY_ICONS[solution.industry] || <Factory className="w-7 h-7" />;
|
|
||||||
const colors = INDUSTRY_COLORS[solution.industry] ?? INDUSTRY_COLORS['制造业']!;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={`/solutions/${solution.id}`}
|
|
||||||
className="group relative block transition-all duration-600 hover:bg-white/[0.02] overflow-hidden bg-ink border border-white/10 hover:border-white/20"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute top-0 left-0 h-full w-1 scale-y-0 group-hover:scale-y-100 transition-transform duration-700 origin-top"
|
|
||||||
style={{ backgroundColor: colors.color }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute top-10 right-10 w-52 h-52 rounded-full opacity-0 group-hover:opacity-100 blur-3xl transition-opacity duration-700" style={{ backgroundColor: colors.color + '08' }} />
|
|
||||||
|
|
||||||
<div className="relative z-10 p-10 lg:p-12">
|
|
||||||
<div className="flex items-start justify-between mb-8">
|
|
||||||
<div
|
|
||||||
className={cn('w-16 h-16 flex items-center justify-center rounded-2xl', colors.bg)}
|
|
||||||
style={{ color: colors.color }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ scale: 1.08, y: -3 }}
|
|
||||||
transition={EASE_SPRING}
|
|
||||||
>
|
|
||||||
{industryIcon}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
<span className="text-7xl font-bold text-white/[0.05] group-hover:text-white/[0.1] transition-all duration-500 tracking-tighter leading-none">
|
|
||||||
{String(index + 1).padStart(2, '0')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<span
|
|
||||||
className="inline-block px-3.5 py-1.5 text-[11px] font-bold mb-4 backdrop-blur-sm rounded-sm"
|
|
||||||
style={{ backgroundColor: colors.soft, color: colors.color }}
|
|
||||||
>
|
|
||||||
{solution.industry}
|
|
||||||
</span>
|
|
||||||
<div className="text-dark-text-muted text-sm tracking-widest uppercase mb-2">
|
|
||||||
{solution.subtitle}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl lg:text-3xl font-bold text-white group-hover:translate-x-1.5 transition-transform duration-500 leading-tight">
|
|
||||||
{solution.title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-dark-text-secondary leading-relaxed mb-8 text-[15px]">
|
|
||||||
{solution.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2.5 mb-8">
|
|
||||||
{solution.challenges.slice(0, 2).map((challenge, idx) => (
|
|
||||||
<div key={idx} className="flex items-start gap-2.5 text-sm text-dark-text-muted">
|
|
||||||
<span className="text-brand mt-0.5 font-bold">•</span>
|
|
||||||
<span>{challenge}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-8 border-t border-white/10">
|
|
||||||
<p className="text-[11px] text-dark-text-muted mb-4 uppercase tracking-widest font-bold flex items-center gap-2">
|
|
||||||
<Sparkles className="w-3.5 h-3.5" style={{ color: colors.color }} />
|
|
||||||
推荐产品组合
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2 mb-5">
|
|
||||||
{solution.suiteCombination.primaryProducts.map(pid => {
|
|
||||||
const prod = PRODUCTS.find(p => p.id === pid);
|
|
||||||
return prod ? (
|
|
||||||
<span
|
|
||||||
key={pid}
|
|
||||||
className="px-3 py-1.5 text-xs font-medium text-white bg-ink-light border border-white/10 group-hover:border-white/20 transition-colors"
|
|
||||||
>
|
|
||||||
{prod.title.replace('睿新', '').replace('睿视 ', '')}
|
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
})}
|
|
||||||
<span
|
|
||||||
className="px-3 py-1.5 text-xs font-bold"
|
|
||||||
style={{ backgroundColor: colors.soft, color: colors.color }}
|
|
||||||
>
|
|
||||||
+ 配套服务
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-dark-text-muted leading-relaxed">
|
|
||||||
{solution.suiteCombination.rationale}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="inline-flex items-center gap-2.5 text-sm font-bold mt-8"
|
|
||||||
style={{ color: colors.color }}
|
|
||||||
whileHover={{ x: 6 }}
|
|
||||||
transition={EASE_SPRING}
|
|
||||||
>
|
|
||||||
<span>了解详情</span>
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SolutionsGridSection() {
|
|
||||||
return (
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-ink-light">
|
|
||||||
<GrainOverlay />
|
|
||||||
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
|
||||||
|
|
||||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-8 sm:gap-10 md:gap-12 mb-16 sm:mb-20 md:mb-20">
|
|
||||||
<ScrollReveal className="lg:max-w-3xl">
|
|
||||||
<SectionLabel>Industry Expertise</SectionLabel>
|
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-white tracking-tight leading-tight">
|
|
||||||
行业解决方案
|
|
||||||
</h2>
|
|
||||||
</ScrollReveal>
|
|
||||||
<ScrollReveal delay={0.1}>
|
|
||||||
<p className="text-dark-text-secondary max-w-md leading-relaxed text-lg">
|
|
||||||
每个方案都明确推荐产品组合和服务包,确保落地效果
|
|
||||||
</p>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StaggerReveal
|
|
||||||
className="grid md:grid-cols-2 gap-px bg-white/10"
|
|
||||||
staggerDelay={0.1}
|
|
||||||
delayChildren={0.05}
|
|
||||||
>
|
|
||||||
{SOLUTIONS.map((solution, index) => (
|
|
||||||
<SolutionCard key={solution.id} solution={solution} index={index} />
|
|
||||||
))}
|
|
||||||
</StaggerReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CTASection() {
|
|
||||||
return (
|
|
||||||
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-ink">
|
|
||||||
<GrainOverlay />
|
|
||||||
<FloatingInkParticles particles={SOLUTION_PARTICLES} />
|
|
||||||
|
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-brand/30 to-transparent" />
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-brand/30 to-transparent" />
|
|
||||||
|
|
||||||
<div className="absolute top-1/4 right-[10%] w-[380px] h-[380px] rounded-full opacity-[0.03] blur-3xl bg-brand" />
|
|
||||||
<div className="absolute bottom-1/4 left-[10%] w-[320px] h-[320px] rounded-full opacity-[0.02] blur-3xl bg-teal-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-container mx-auto px-6 lg:px-10 relative z-10">
|
|
||||||
<ScrollReveal className="text-center max-w-3xl mx-auto">
|
|
||||||
<div className="flex justify-center mb-7">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<div className="w-14 h-px bg-brand" />
|
|
||||||
<span className="text-[11px] tracking-[0.4em] uppercase font-bold text-brand">
|
|
||||||
Get Started
|
|
||||||
</span>
|
|
||||||
<div className="w-14 h-px bg-brand" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-white tracking-tight leading-tight mb-6 sm:mb-8">
|
|
||||||
告诉我们您的<br />
|
|
||||||
<span className="text-brand">行业场景</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="text-xl text-dark-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="/contact">
|
|
||||||
获取定制方案
|
|
||||||
<ArrowRight className="w-5 h-5 ml-2" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<Button size="xl" variant="outline" asChild>
|
|
||||||
<a href="/products">浏览产品</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ScrollReveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SolutionsContentV2() {
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<HeroSection />
|
|
||||||
<SolutionsGridSection />
|
|
||||||
<CTASection />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { revalidatePath, revalidateTag } from 'next/cache';
|
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||||
import {
|
import { CONTENT_TYPE_CONFIGS } from '@/lib/cms/content-types';
|
||||||
getContentTypeConfig,
|
|
||||||
getAllContentTypeConfigs,
|
function getContentTypeConfig(code: string) {
|
||||||
} from '@/lib/cms/registry';
|
return CONTENT_TYPE_CONFIGS[code as keyof typeof CONTENT_TYPE_CONFIGS] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllContentTypeConfigs() {
|
||||||
|
return Object.values(CONTENT_TYPE_CONFIGS);
|
||||||
|
}
|
||||||
|
|
||||||
interface RevalidateRequestBody {
|
interface RevalidateRequestBody {
|
||||||
event?: string;
|
event?: string;
|
||||||
|
|||||||
@@ -1,473 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useMemo, useEffect, useCallback, createElement } from 'react';
|
|
||||||
import {
|
|
||||||
getAllContentModels,
|
|
||||||
getItemRenderer,
|
|
||||||
ContentZoneRenderer,
|
|
||||||
getContentItems,
|
|
||||||
saveContentItem,
|
|
||||||
getContentZone,
|
|
||||||
} from '@/lib/cms';
|
|
||||||
import type { ContentModel, ContentItem, FieldDefinition } from '@/lib/cms';
|
|
||||||
import { RichTextEditor } from './RichTextEditor';
|
|
||||||
|
|
||||||
const PREVIEW_ZONES: { key: string; name: string }[] = [
|
|
||||||
{ key: 'home-stats', name: '首页 · 数据指标区' },
|
|
||||||
{ key: 'home-services', name: '首页 · 核心服务区' },
|
|
||||||
{ key: 'home-solutions', name: '首页 · 解决方案区' },
|
|
||||||
{ key: 'home-cases', name: '首页 · 客户案例区' },
|
|
||||||
{ key: 'home-news', name: '首页 · 新闻动态区' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function FieldEditor({ field, value, onChange }: {
|
|
||||||
field: FieldDefinition;
|
|
||||||
value: unknown;
|
|
||||||
onChange: (val: unknown) => void;
|
|
||||||
}) {
|
|
||||||
const baseClass = 'w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-brand/50 focus:ring-1 focus:ring-brand/30 transition-colors';
|
|
||||||
|
|
||||||
switch (field.type) {
|
|
||||||
case 'text':
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={baseClass}
|
|
||||||
value={(value as string) || ''}
|
|
||||||
placeholder={field.label}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'textarea':
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
className={`${baseClass} min-h-[100px] resize-y`}
|
|
||||||
value={(value as string) || ''}
|
|
||||||
placeholder={field.label}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'richtext':
|
|
||||||
return (
|
|
||||||
<RichTextEditor
|
|
||||||
value={(value as string) || ''}
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder={field.label}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'number':
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={baseClass}
|
|
||||||
value={(value as number) ?? ''}
|
|
||||||
placeholder={field.label}
|
|
||||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : 0)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'boolean':
|
|
||||||
return (
|
|
||||||
<label className="inline-flex items-center gap-3 cursor-pointer">
|
|
||||||
<div
|
|
||||||
className={`relative w-11 h-6 rounded-full transition-colors ${value ? 'bg-brand' : 'bg-white/10'}`}
|
|
||||||
onClick={() => onChange(!value)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${value ? 'translate-x-6' : 'translate-x-1'}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-white/70">{field.label}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
case 'date':
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className={baseClass}
|
|
||||||
value={(value as string) || ''}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'select':
|
|
||||||
case 'dropdown':
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
className={baseClass}
|
|
||||||
value={String(value ?? '')}
|
|
||||||
onChange={(e) => {
|
|
||||||
const opt = field.options?.find((o) => String(o.value) === e.target.value);
|
|
||||||
onChange(opt ? opt.value : e.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">请选择...</option>
|
|
||||||
{field.options?.map((opt) => (
|
|
||||||
<option key={String(opt.value)} value={String(opt.value)}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
case 'image': {
|
|
||||||
const imgUrl = typeof value === 'string' ? value : '';
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{imgUrl && (
|
|
||||||
<div className="aspect-video rounded-lg overflow-hidden bg-white/5 border border-white/10">
|
|
||||||
<img src={imgUrl} alt="" className="w-full h-full object-cover" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={baseClass}
|
|
||||||
value={imgUrl}
|
|
||||||
placeholder="图片 URL"
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'array':
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(Array.isArray(value) ? value : []).map((item: unknown, idx: number) => (
|
|
||||||
<div key={idx} className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={baseClass}
|
|
||||||
value={typeof item === 'string' ? item : item && typeof item === 'object' ? String((item as Record<string, unknown>).name || (item as Record<string, unknown>).text || '') : ''}
|
|
||||||
placeholder={`第 ${idx + 1} 项`}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newArr = [...(Array.isArray(value) ? value : [])];
|
|
||||||
if (field.fields?.[0]?.name) {
|
|
||||||
newArr[idx] = { ...(item as object), [field.fields[0].name]: e.target.value };
|
|
||||||
} else {
|
|
||||||
newArr[idx] = e.target.value;
|
|
||||||
}
|
|
||||||
onChange(newArr);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const newArr = [...(Array.isArray(value) ? value : [])];
|
|
||||||
newArr.splice(idx, 1);
|
|
||||||
onChange(newArr);
|
|
||||||
}}
|
|
||||||
className="px-3 text-red-400 hover:text-red-300 text-sm"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const newItem = field.fields?.[0]?.name ? { [field.fields[0].name]: '' } : '';
|
|
||||||
onChange([...(Array.isArray(value) ? value : []), newItem]);
|
|
||||||
}}
|
|
||||||
className="text-sm text-brand hover:text-brand/80 font-medium"
|
|
||||||
>
|
|
||||||
+ 添加一项
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={baseClass}
|
|
||||||
value={(value as string) || ''}
|
|
||||||
placeholder={field.label}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContentEditor() {
|
|
||||||
const [models, setModels] = useState<ContentModel[]>([]);
|
|
||||||
const [selectedModelCode, setSelectedModelCode] = useState<string>('case-study');
|
|
||||||
const [items, setItems] = useState<ContentItem[]>([]);
|
|
||||||
const [selectedItem, setSelectedItem] = useState<ContentItem | null>(null);
|
|
||||||
const [editData, setEditData] = useState<Record<string, unknown>>({});
|
|
||||||
const [previewZoneKey, setPreviewZoneKey] = useState('home-cases');
|
|
||||||
|
|
||||||
const loadItems = useCallback(() => {
|
|
||||||
const list = getContentItems(selectedModelCode);
|
|
||||||
setItems(list);
|
|
||||||
if (list.length > 0) {
|
|
||||||
const current = list.find((i) => i.id === selectedItem?.id);
|
|
||||||
if (current) {
|
|
||||||
setSelectedItem(current);
|
|
||||||
} else if (!selectedItem) {
|
|
||||||
setSelectedItem(list[0] || null);
|
|
||||||
setEditData({ ...(list[0]?.data || {}) });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSelectedItem(null);
|
|
||||||
setEditData({});
|
|
||||||
}
|
|
||||||
}, [selectedModelCode, selectedItem]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setModels(getAllContentModels());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadItems();
|
|
||||||
|
|
||||||
const handleCmsUpdate = () => loadItems();
|
|
||||||
window.addEventListener('cms-updated', handleCmsUpdate);
|
|
||||||
return () => window.removeEventListener('cms-updated', handleCmsUpdate);
|
|
||||||
}, [loadItems]);
|
|
||||||
|
|
||||||
const selectedModel = useMemo(
|
|
||||||
() => models.find((m) => m.code === selectedModelCode),
|
|
||||||
[models, selectedModelCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFieldChange = (fieldName: string, value: unknown) => {
|
|
||||||
const newData = { ...editData, [fieldName]: value };
|
|
||||||
setEditData(newData);
|
|
||||||
|
|
||||||
if (selectedItem) {
|
|
||||||
const updated = { ...selectedItem, data: newData, title: fieldName === 'title' ? (value as string) : selectedItem.title };
|
|
||||||
setSelectedItem(updated);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
if (!selectedItem) return;
|
|
||||||
const saved = { ...selectedItem, data: editData };
|
|
||||||
saveContentItem(selectedItem.modelCode, saved);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectItem = (item: ContentItem) => {
|
|
||||||
setSelectedItem(item);
|
|
||||||
setEditData({ ...item.data });
|
|
||||||
};
|
|
||||||
|
|
||||||
const PreviewRenderer = useMemo(
|
|
||||||
() => (selectedItem ? getItemRenderer(selectedItem.modelCode) : null),
|
|
||||||
[selectedItem]
|
|
||||||
);
|
|
||||||
|
|
||||||
const previewZone = useMemo(() => {
|
|
||||||
const zoneData = getContentZone(previewZoneKey);
|
|
||||||
if (!zoneData || !selectedItem) return null;
|
|
||||||
const updatedItems = zoneData.items.map((zi) => {
|
|
||||||
if (zi.item?.id === selectedItem.id) {
|
|
||||||
return { ...zi, item: { ...selectedItem, data: editData } };
|
|
||||||
}
|
|
||||||
return zi;
|
|
||||||
});
|
|
||||||
return { ...zoneData, items: updatedItems };
|
|
||||||
}, [previewZoneKey, selectedItem, editData]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex">
|
|
||||||
<div className="w-80 flex-shrink-0 border-r border-white/[0.08] overflow-y-auto">
|
|
||||||
<div className="p-4 border-b border-white/[0.08]">
|
|
||||||
<label className="block text-xs font-medium text-white/60 mb-2">内容类型</label>
|
|
||||||
<select
|
|
||||||
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm focus:outline-none focus:border-brand/50"
|
|
||||||
value={selectedModelCode}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSelectedModelCode(e.target.value);
|
|
||||||
setSelectedItem(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{models.map((m) => (
|
|
||||||
<option key={m.code} value={m.code}>
|
|
||||||
{m.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<span className="text-xs text-white/50 font-medium">内容列表</span>
|
|
||||||
<button className="text-xs text-brand hover:text-brand/80 font-medium">
|
|
||||||
+ 新建
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{items.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => handleSelectItem(item)}
|
|
||||||
className={`p-3 rounded-lg cursor-pointer transition-all border ${
|
|
||||||
selectedItem?.id === item.id
|
|
||||||
? 'bg-brand/10 border-brand/30'
|
|
||||||
: 'bg-white/[0.02] border-white/[0.06] hover:bg-white/[0.05] hover:border-white/[0.1]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium text-white line-clamp-1">
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-white/40 flex items-center gap-2">
|
|
||||||
<span>{item.slug}</span>
|
|
||||||
<span className="w-1 h-1 rounded-full bg-emerald-400" />
|
|
||||||
<span className="text-emerald-400">已发布</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 flex min-h-0">
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
|
||||||
{selectedItem ? (
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-white">编辑内容</h2>
|
|
||||||
<p className="mt-1 text-sm text-white/40">修改后点击保存</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="px-4 py-2 bg-brand hover:bg-brand-hover text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
💾 保存
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-white/80 mb-2">
|
|
||||||
标题
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm focus:outline-none focus:border-brand/50 focus:ring-1 focus:ring-brand/30 transition-colors"
|
|
||||||
value={selectedItem.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedItem({ ...selectedItem, title: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-white/80 mb-2">
|
|
||||||
Slug(URL 标识)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm font-mono focus:outline-none focus:border-brand/50 focus:ring-1 focus:ring-brand/30 transition-colors"
|
|
||||||
value={selectedItem.slug}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedItem({ ...selectedItem, slug: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-white/[0.08] pt-5">
|
|
||||||
<h3 className="text-sm font-semibold text-white/80 mb-4">内容字段</h3>
|
|
||||||
<div className="space-y-5">
|
|
||||||
{selectedModel?.fields?.map((field) => (
|
|
||||||
<div key={field.name}>
|
|
||||||
<label className="block text-sm font-medium text-white/80 mb-2">
|
|
||||||
{field.label}
|
|
||||||
{field.required && (
|
|
||||||
<span className="text-red-400 ml-1">*</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
<FieldEditor
|
|
||||||
field={field}
|
|
||||||
value={editData[field.name]}
|
|
||||||
onChange={(val) => handleFieldChange(field.name, val)}
|
|
||||||
/>
|
|
||||||
{field.description && (
|
|
||||||
<p className="mt-1.5 text-xs text-white/35">
|
|
||||||
{field.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!selectedModel?.fields?.length && (
|
|
||||||
<div className="text-sm text-white/40 py-8 text-center">
|
|
||||||
该内容模型暂无字段定义
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-white/[0.08] pt-5">
|
|
||||||
<h3 className="text-sm font-semibold text-white/80 mb-4">发布设置</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-white/60 mb-2">状态</label>
|
|
||||||
<select
|
|
||||||
value={selectedItem.status}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedItem({
|
|
||||||
...selectedItem,
|
|
||||||
status: e.target.value as ContentItem['status'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm focus:outline-none focus:border-brand/50"
|
|
||||||
>
|
|
||||||
<option value="published">已发布</option>
|
|
||||||
<option value="draft">草稿</option>
|
|
||||||
<option value="archived">已归档</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-white/60 mb-2">排序</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm focus:outline-none focus:border-brand/50"
|
|
||||||
value={selectedItem.sortOrder ?? 0}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedItem({
|
|
||||||
...selectedItem,
|
|
||||||
sortOrder: Number(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-full flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-5xl mb-4 opacity-30">📝</div>
|
|
||||||
<div className="text-white/50">选择一条内容开始编辑</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-[420px] flex-shrink-0 border-l border-white/[0.08] overflow-y-auto bg-ink">
|
|
||||||
<div className="p-4 border-b border-white/[0.08] bg-ink-light flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-white/60">实时预览</span>
|
|
||||||
<select
|
|
||||||
className="px-2 py-1 bg-white/[0.05] border border-white/[0.1] rounded-md text-white text-xs focus:outline-none"
|
|
||||||
value={previewZoneKey}
|
|
||||||
onChange={(e) => setPreviewZoneKey(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="single">单卡片</option>
|
|
||||||
{PREVIEW_ZONES.map((z) => (
|
|
||||||
<option key={z.key} value={z.key}>
|
|
||||||
{z.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
{previewZoneKey === 'single' && selectedItem && PreviewRenderer
|
|
||||||
? createElement(PreviewRenderer, { item: { ...selectedItem, data: editData } })
|
|
||||||
: previewZone ? (
|
|
||||||
<ContentZoneRenderer zone={previewZone} />
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-white/40 py-8 text-center">
|
|
||||||
选择内容查看预览
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import type { ContentItem } from '@/lib/cms';
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const componentRegistry = new Map<string, React.ComponentType<any>>();
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export function registerComponent(modelCode: string, Component: React.ComponentType<any>) {
|
|
||||||
componentRegistry.set(modelCode, Component);
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export function getRegisteredComponent(modelCode: string): React.ComponentType<any> | undefined {
|
|
||||||
return componentRegistry.get(modelCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContentRendererProps {
|
|
||||||
item: ContentItem;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
component?: React.ComponentType<any>;
|
|
||||||
fallback?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContentRenderer({ item, component: ComponentProp, fallback = null }: ContentRendererProps) {
|
|
||||||
const Component = React.useMemo(() => {
|
|
||||||
return ComponentProp || getRegisteredComponent(item.modelCode);
|
|
||||||
}, [ComponentProp, item.modelCode]);
|
|
||||||
|
|
||||||
if (!Component) {
|
|
||||||
return <>{fallback}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return React.createElement(Component, { item, data: item.data });
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import type { FieldDefinition, FieldType } from '@/lib/cms';
|
|
||||||
|
|
||||||
export interface FieldRendererProps {
|
|
||||||
field: FieldDefinition;
|
|
||||||
value: unknown;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FieldRenderer({ field, value, className }: FieldRendererProps) {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderByType = (type: FieldType, val: unknown, fieldDef: FieldDefinition): React.ReactNode => {
|
|
||||||
switch (type) {
|
|
||||||
case 'text':
|
|
||||||
case 'textarea':
|
|
||||||
return <span className={className}>{String(val)}</span>;
|
|
||||||
|
|
||||||
case 'richtext':
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={className}
|
|
||||||
dangerouslySetInnerHTML={{ __html: String(val) }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'number':
|
|
||||||
return <span className={className}>{formatNumber(val)}</span>;
|
|
||||||
|
|
||||||
case 'boolean':
|
|
||||||
return <span className={className}>{val ? '是' : '否'}</span>;
|
|
||||||
|
|
||||||
case 'date':
|
|
||||||
return <span className={className}>{formatDate(val, 'date')}</span>;
|
|
||||||
|
|
||||||
case 'datetime':
|
|
||||||
return <span className={className}>{formatDate(val, 'datetime')}</span>;
|
|
||||||
|
|
||||||
case 'image':
|
|
||||||
case 'media':
|
|
||||||
return renderMedia(val, fieldDef, className);
|
|
||||||
|
|
||||||
case 'reference':
|
|
||||||
return <span className={className}>{String(val)}</span>;
|
|
||||||
|
|
||||||
case 'references':
|
|
||||||
if (Array.isArray(val)) {
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
{val.map((item, index) => (
|
|
||||||
<span key={index}>{String(item)}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
|
|
||||||
case 'object':
|
|
||||||
return renderObject(val, fieldDef, className);
|
|
||||||
|
|
||||||
case 'array':
|
|
||||||
return renderArray(val, fieldDef, className);
|
|
||||||
|
|
||||||
case 'json':
|
|
||||||
return (
|
|
||||||
<pre className={className}>
|
|
||||||
{JSON.stringify(val, null, 2)}
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return <span className={className}>{String(val)}</span>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return <>{renderByType(field.type, value, field)}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value: unknown): string {
|
|
||||||
const num = Number(value);
|
|
||||||
if (isNaN(num)) return String(value);
|
|
||||||
return num.toLocaleString('zh-CN');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value: unknown, format: 'date' | 'datetime'): string {
|
|
||||||
const date = new Date(String(value));
|
|
||||||
if (isNaN(date.getTime())) return String(value);
|
|
||||||
|
|
||||||
if (format === 'date') {
|
|
||||||
return date.toLocaleDateString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toLocaleString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMedia(value: unknown, field: FieldDefinition, className?: string): React.ReactNode {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={value}
|
|
||||||
alt={field.label}
|
|
||||||
className={className}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'object' && value !== null) {
|
|
||||||
const media = value as Record<string, unknown>;
|
|
||||||
const url = (media.url || media.path || media.src) as string | undefined;
|
|
||||||
const alt = (media.alt || field.label) as string;
|
|
||||||
|
|
||||||
if (url) {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt={alt}
|
|
||||||
className={className}
|
|
||||||
loading="lazy"
|
|
||||||
width={typeof media.width === 'number' ? media.width : undefined}
|
|
||||||
height={typeof media.height === 'number' ? media.height : undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderObject(value: unknown, field: FieldDefinition, className?: string): React.ReactNode {
|
|
||||||
if (!field.fields || !value || typeof value !== 'object') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const obj = value as Record<string, unknown>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
{field.fields.map((subField) => (
|
|
||||||
<div key={subField.name} className="mb-2">
|
|
||||||
<span className="font-medium text-text-secondary">{subField.label}:</span>
|
|
||||||
<FieldRenderer field={subField} value={obj[subField.name]} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderArray(value: unknown, field: FieldDefinition, className?: string): React.ReactNode {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemFields = field.fields;
|
|
||||||
if (itemFields && itemFields.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
{value.map((item, index) => (
|
|
||||||
<div key={index} className="mb-3">
|
|
||||||
<div className="font-medium text-text-secondary mb-1">第 {index + 1} 项</div>
|
|
||||||
{renderArrayItem(item, itemFields)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul className={className}>
|
|
||||||
{value.map((item, index) => (
|
|
||||||
<li key={index}>{String(item)}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderArrayItem(item: unknown, fields: FieldDefinition[]): React.ReactNode {
|
|
||||||
if (typeof item === 'object' && item !== null) {
|
|
||||||
const obj = item as Record<string, unknown>;
|
|
||||||
return (
|
|
||||||
<div className="pl-4 border-l-2 border-border-primary">
|
|
||||||
{fields.map((subField) => (
|
|
||||||
<div key={subField.name} className="mb-1">
|
|
||||||
<span className="text-sm text-text-muted">{subField.label}:</span>
|
|
||||||
<FieldRenderer field={subField} value={obj[subField.name]} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <span>{String(item)}</span>;
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import { ContentRenderer } from './ContentRenderer';
|
|
||||||
import { Spinner } from '@/components/ui/loading-state';
|
|
||||||
import type { ContentZone, ContentItem } from '@/lib/cms';
|
|
||||||
import { cmsClient } from '@/lib/cms';
|
|
||||||
|
|
||||||
export interface SectionRendererProps {
|
|
||||||
zoneCode: string;
|
|
||||||
fallback?: React.ReactNode;
|
|
||||||
loadingFallback?: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SectionRenderer({
|
|
||||||
zoneCode,
|
|
||||||
fallback = null,
|
|
||||||
loadingFallback,
|
|
||||||
className,
|
|
||||||
}: SectionRendererProps) {
|
|
||||||
const [zone, setZone] = React.useState<ContentZone | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
|
||||||
const [error, setError] = React.useState<Error | null>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
async function fetchZone() {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (cmsClient?.getZone) {
|
|
||||||
const result = await cmsClient.getZone(zoneCode);
|
|
||||||
if (mounted) {
|
|
||||||
setZone(result);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (mounted) {
|
|
||||||
setZone(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (mounted) {
|
|
||||||
setError(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchZone();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [zoneCode]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
if (loadingFallback) {
|
|
||||||
return <>{loadingFallback}</>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Spinner size="lg" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !zone) {
|
|
||||||
return <>{fallback}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedItems = [...zone.items].sort((a, b) => a.sortOrder - b.sortOrder);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
{sortedItems.map((zoneItem, index) => {
|
|
||||||
if (!zoneItem.item) return null;
|
|
||||||
return (
|
|
||||||
<ContentRenderer
|
|
||||||
key={zoneItem.itemId || index}
|
|
||||||
item={zoneItem.item as ContentItem}
|
|
||||||
fallback={null}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
ContentZoneRenderer,
|
|
||||||
getContentZone,
|
|
||||||
getAllContentZones,
|
|
||||||
getContentItems,
|
|
||||||
reorderZoneItems,
|
|
||||||
removeItemFromZone,
|
|
||||||
addItemToZone,
|
|
||||||
updateZoneSettings,
|
|
||||||
resetCmsData,
|
|
||||||
exportCmsData,
|
|
||||||
} from '@/lib/cms';
|
|
||||||
import type { ContentZone, ContentItem } from '@/lib/cms';
|
|
||||||
|
|
||||||
const ZONE_META: Record<string, { name: string; page: string }> = {
|
|
||||||
'home-stats': { name: '首页 · 数据指标区', page: 'home' },
|
|
||||||
'home-services': { name: '首页 · 核心服务区', page: 'home' },
|
|
||||||
'home-solutions': { name: '首页 · 解决方案区', page: 'home' },
|
|
||||||
'home-cases': { name: '首页 · 客户案例区', page: 'home' },
|
|
||||||
'home-news': { name: '首页 · 新闻动态区', page: 'home' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ZoneManager() {
|
|
||||||
const [zoneKey, setZoneKey] = useState('home-cases');
|
|
||||||
const [zone, setZone] = useState<ContentZone | null>(null);
|
|
||||||
const [showAddPanel, setShowAddPanel] = useState(false);
|
|
||||||
const [showTools, setShowTools] = useState(false);
|
|
||||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
|
||||||
const [zoneList, setZoneList] = useState<Record<string, ContentZone>>({});
|
|
||||||
|
|
||||||
const loadZones = useCallback(() => {
|
|
||||||
setZoneList(getAllContentZones());
|
|
||||||
const z = getContentZone(zoneKey);
|
|
||||||
setZone(z);
|
|
||||||
}, [zoneKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadZones();
|
|
||||||
|
|
||||||
const handleCmsUpdate = () => loadZones();
|
|
||||||
window.addEventListener('cms-updated', handleCmsUpdate);
|
|
||||||
return () => window.removeEventListener('cms-updated', handleCmsUpdate);
|
|
||||||
}, [loadZones]);
|
|
||||||
|
|
||||||
const zoneItemModelCode = useMemo(() => {
|
|
||||||
return zone?.items?.[0]?.item?.modelCode || 'case-study';
|
|
||||||
}, [zone]);
|
|
||||||
|
|
||||||
const candidateItems = useMemo(() => {
|
|
||||||
if (!zone) return [];
|
|
||||||
const allItems = getContentItems(zoneItemModelCode);
|
|
||||||
const existingIds = new Set(zone.items?.map((zi) => zi.item?.id).filter(Boolean) || []);
|
|
||||||
return allItems.filter((item) => !existingIds.has(item.id));
|
|
||||||
}, [zone, zoneItemModelCode]);
|
|
||||||
|
|
||||||
const sortedItems = useMemo(() => {
|
|
||||||
return [...(zone?.items || [])]
|
|
||||||
.filter((zi) => zi.item)
|
|
||||||
.sort(
|
|
||||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)
|
|
||||||
);
|
|
||||||
}, [zone]);
|
|
||||||
|
|
||||||
const handleDragStart = (index: number) => {
|
|
||||||
setDragIndex(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragOverIndex(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (targetIndex: number) => {
|
|
||||||
if (dragIndex === null || dragIndex === targetIndex) {
|
|
||||||
setDragIndex(null);
|
|
||||||
setDragOverIndex(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reorderZoneItems(zoneKey, dragIndex, targetIndex);
|
|
||||||
setDragIndex(null);
|
|
||||||
setDragOverIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveItem = (itemId: string) => {
|
|
||||||
removeItemFromZone(zoneKey, itemId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddItem = (item: ContentItem) => {
|
|
||||||
addItemToZone(zoneKey, item);
|
|
||||||
setShowAddPanel(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLayoutChange = (field: string, value: unknown) => {
|
|
||||||
updateZoneSettings(zoneKey, { [field]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
if (confirm('确定要重置所有 CMS 数据为默认值吗?此操作不可撤销。')) {
|
|
||||||
resetCmsData();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = () => {
|
|
||||||
const data = exportCmsData();
|
|
||||||
const blob = new Blob([data], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `novalon-cms-export-${Date.now()}.json`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoneMeta = ZONE_META[zoneKey];
|
|
||||||
|
|
||||||
const zoneKeys = Object.keys(zoneList).sort();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex">
|
|
||||||
<div className="w-72 flex-shrink-0 border-r border-white/[0.08] overflow-y-auto flex flex-col">
|
|
||||||
<div className="p-4 flex-1">
|
|
||||||
<div className="text-xs text-white/40 font-medium uppercase tracking-wider mb-3 px-3">
|
|
||||||
内容区列表
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{zoneKeys.map((key) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => setZoneKey(key)}
|
|
||||||
className={`w-full text-left px-3 py-2.5 rounded-lg text-sm transition-colors ${
|
|
||||||
zoneKey === key
|
|
||||||
? 'bg-brand/20 text-brand font-medium'
|
|
||||||
: 'text-white/60 hover:bg-white/[0.05] hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="font-medium">{ZONE_META[key]?.name || key}</div>
|
|
||||||
<div className="text-xs opacity-60 mt-0.5">
|
|
||||||
{zoneList[key]?.items?.length || 0} 条内容
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 border-t border-white/[0.08] space-y-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTools(!showTools)}
|
|
||||||
className="w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm text-white/60 hover:bg-white/[0.05] hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<span>⚙️ 工具</span>
|
|
||||||
<span>{showTools ? '▲' : '▼'}</span>
|
|
||||||
</button>
|
|
||||||
{showTools && (
|
|
||||||
<div className="space-y-1 px-2">
|
|
||||||
<button
|
|
||||||
onClick={handleExport}
|
|
||||||
className="w-full text-left px-3 py-2 rounded-lg text-sm text-white/60 hover:bg-white/[0.05] hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
📤 导出配置
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleReset}
|
|
||||||
className="w-full text-left px-3 py-2 rounded-lg text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
|
||||||
>
|
|
||||||
🔄 重置默认
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
|
||||||
<div className="flex-1 flex min-h-0">
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-white">
|
|
||||||
{zoneMeta?.name || zoneKey}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-white/40">
|
|
||||||
拖拽排序 · 点击 × 移除 · 共 {sortedItems.length} 条
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddPanel(true)}
|
|
||||||
className="px-4 py-2 bg-brand hover:bg-brand-hover text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
+ 添加内容
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{sortedItems.map((zoneItem, index) => {
|
|
||||||
const item = zoneItem.item!;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
draggable
|
|
||||||
onDragStart={() => handleDragStart(index)}
|
|
||||||
onDragOver={(e) => handleDragOver(e, index)}
|
|
||||||
onDrop={() => handleDrop(index)}
|
|
||||||
onDragEnd={() => {
|
|
||||||
setDragIndex(null);
|
|
||||||
setDragOverIndex(null);
|
|
||||||
}}
|
|
||||||
className={`
|
|
||||||
relative flex items-center gap-4 p-4 rounded-xl border transition-all cursor-move
|
|
||||||
${dragOverIndex === index ? 'border-brand/50 bg-brand/5' : 'border-white/[0.08] bg-white/[0.02] hover:bg-white/[0.04]'}
|
|
||||||
${dragIndex === index ? 'opacity-40' : ''}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg bg-white/[0.05] text-white/40 text-sm">
|
|
||||||
☰
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium text-white truncate">
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-xs text-white/40">
|
|
||||||
{item.modelCode} · {item.slug}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-shrink-0 text-xs text-white/40">
|
|
||||||
#{index + 1}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleRemoveItem(item.id);
|
|
||||||
}}
|
|
||||||
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg text-white/40 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{sortedItems.length === 0 && (
|
|
||||||
<div className="py-16 text-center border-2 border-dashed border-white/[0.08] rounded-xl">
|
|
||||||
<div className="text-4xl mb-3 opacity-30">📦</div>
|
|
||||||
<div className="text-white/50 text-sm">暂无内容,点击右上角「添加内容」</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-80 flex-shrink-0 border-l border-white/[0.08] overflow-y-auto">
|
|
||||||
<div className="p-4 border-b border-white/[0.08]">
|
|
||||||
<h3 className="text-sm font-semibold text-white">布局设置</h3>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 space-y-5">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-white/60 mb-2">
|
|
||||||
布局模式
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm focus:outline-none focus:border-brand/50"
|
|
||||||
value={(zone?.settings?.layout as string) || 'grid'}
|
|
||||||
onChange={(e) => handleLayoutChange('layout', e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="grid">网格布局</option>
|
|
||||||
<option value="list">列表布局</option>
|
|
||||||
<option value="carousel">横向滑动</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-white/60 mb-2">
|
|
||||||
列数
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm focus:outline-none focus:border-brand/50"
|
|
||||||
value={(zone?.settings?.columns as number) || 3}
|
|
||||||
onChange={(e) => handleLayoutChange('columns', Number(e.target.value))}
|
|
||||||
>
|
|
||||||
<option value={1}>1 列</option>
|
|
||||||
<option value={2}>2 列</option>
|
|
||||||
<option value={3}>3 列</option>
|
|
||||||
<option value={4}>4 列</option>
|
|
||||||
<option value={5}>5 列</option>
|
|
||||||
<option value={6}>6 列</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-white/60 mb-2">
|
|
||||||
间距
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.1] rounded-lg text-white text-sm focus:outline-none focus:border-brand/50"
|
|
||||||
value={(zone?.settings?.gap as string) || 'medium'}
|
|
||||||
onChange={(e) => handleLayoutChange('gap', e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="small">小</option>
|
|
||||||
<option value="medium">中</option>
|
|
||||||
<option value="large">大</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-white/[0.08] pt-5">
|
|
||||||
<h4 className="text-xs font-medium text-white/60 mb-3">内容区信息</h4>
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-white/40">Zone Key</span>
|
|
||||||
<span className="text-white/70 font-mono text-xs">{zoneKey}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-white/40">所属页面</span>
|
|
||||||
<span className="text-white/70">{zoneMeta?.page || '-'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-white/40">内容类型</span>
|
|
||||||
<span className="text-white/70">{zoneItemModelCode}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-white/40">内容数量</span>
|
|
||||||
<span className="text-white/70">{sortedItems.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-64 flex-shrink-0 border-t border-white/[0.08] bg-ink overflow-y-auto">
|
|
||||||
<div className="p-4 border-b border-white/[0.08] bg-ink-light flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-white/60">实时预览</span>
|
|
||||||
<span className="text-xs text-white/40">
|
|
||||||
布局: {(zone?.settings?.layout as string) || 'grid'} · {(zone?.settings?.columns as number) || 3} 列
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-6">
|
|
||||||
{zone ? (
|
|
||||||
<ContentZoneRenderer zone={zone} />
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-white/40 py-8 text-center">
|
|
||||||
选择内容区查看预览
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAddPanel && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
|
||||||
onClick={() => setShowAddPanel(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="bg-ink border border-white/[0.1] rounded-2xl w-full max-w-lg max-h-[70vh] overflow-hidden shadow-2xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="p-4 border-b border-white/[0.08] flex items-center justify-between">
|
|
||||||
<h3 className="text-base font-semibold text-white">添加内容</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddPanel(false)}
|
|
||||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-white/40 hover:text-white hover:bg-white/10 transition-colors"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 overflow-y-auto max-h-[60vh]">
|
|
||||||
{candidateItems.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{candidateItems.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => handleAddItem(item)}
|
|
||||||
className="w-full text-left p-3 rounded-lg border border-white/[0.08] bg-white/[0.02] hover:bg-white/[0.06] hover:border-brand/30 transition-all"
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium text-white">{item.title}</div>
|
|
||||||
<div className="mt-0.5 text-xs text-white/40">
|
|
||||||
{item.modelCode} · {item.slug}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-12 text-center">
|
|
||||||
<div className="text-4xl mb-3 opacity-30">✅</div>
|
|
||||||
<div className="text-white/50 text-sm">所有内容都已添加</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export { ContentRenderer, registerComponent, getRegisteredComponent } from './ContentRenderer';
|
|
||||||
export type { ContentRendererProps } from './ContentRenderer';
|
|
||||||
|
|
||||||
export { SectionRenderer } from './SectionRenderer';
|
|
||||||
export type { SectionRendererProps } from './SectionRenderer';
|
|
||||||
|
|
||||||
export { FieldRenderer } from './FieldRenderer';
|
|
||||||
export type { FieldRendererProps } from './FieldRenderer';
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { getItemRenderer } from './component-registry';
|
|
||||||
import type { ContentZone } from './types';
|
|
||||||
|
|
||||||
interface ContentZoneRendererProps {
|
|
||||||
zone: ContentZone;
|
|
||||||
className?: string;
|
|
||||||
itemClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContentZoneRenderer({ zone, className = '', itemClassName = '' }: ContentZoneRendererProps) {
|
|
||||||
const items = zone.items
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
|
||||||
.filter((zi) => zi.item);
|
|
||||||
|
|
||||||
const settings = zone.settings || {};
|
|
||||||
const layout = settings.layout || 'grid';
|
|
||||||
const columns = settings.columns || 3;
|
|
||||||
const gap = settings.gap || 'medium';
|
|
||||||
|
|
||||||
const gapClasses: Record<string, string> = {
|
|
||||||
small: 'gap-3',
|
|
||||||
medium: 'gap-6',
|
|
||||||
large: 'gap-8',
|
|
||||||
};
|
|
||||||
|
|
||||||
const gridColsClass = {
|
|
||||||
1: 'grid-cols-1',
|
|
||||||
2: 'grid-cols-1 md:grid-cols-2',
|
|
||||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
|
||||||
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
|
||||||
5: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5',
|
|
||||||
6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-6',
|
|
||||||
}[columns as number] || 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3';
|
|
||||||
|
|
||||||
if (layout === 'list') {
|
|
||||||
return (
|
|
||||||
<div className={`flex flex-col ${gapClasses[gap as string] || 'gap-6'} ${className}`}>
|
|
||||||
{items.map((zoneItem, i) => {
|
|
||||||
const item = zoneItem.item!;
|
|
||||||
const Renderer = getItemRenderer(item.modelCode);
|
|
||||||
if (!Renderer) {
|
|
||||||
return (
|
|
||||||
<div key={item.id} className={`p-4 border border-dashed border-white/20 rounded-lg ${itemClassName}`}>
|
|
||||||
<p className="text-sm text-white/50">未知内容类型: {item.modelCode}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Renderer
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
index={i}
|
|
||||||
variant={zoneItem.variant}
|
|
||||||
className={itemClassName}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (layout === 'carousel') {
|
|
||||||
return (
|
|
||||||
<div className={`overflow-x-auto ${className}`}>
|
|
||||||
<div className={`flex gap-6 pb-4 ${itemClassName}`} style={{ scrollSnapType: 'x mandatory' }}>
|
|
||||||
{items.map((zoneItem, i) => {
|
|
||||||
const item = zoneItem.item!;
|
|
||||||
const Renderer = getItemRenderer(item.modelCode);
|
|
||||||
if (!Renderer) return null;
|
|
||||||
return (
|
|
||||||
<div key={item.id} className="flex-shrink-0 w-80" style={{ scrollSnapAlign: 'start' }}>
|
|
||||||
<Renderer item={item} index={i} variant={zoneItem.variant} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`grid ${gridColsClass} ${gapClasses[gap as string] || 'gap-6'} ${className}`}>
|
|
||||||
{items.map((zoneItem, i) => {
|
|
||||||
const item = zoneItem.item!;
|
|
||||||
const Renderer = getItemRenderer(item.modelCode);
|
|
||||||
if (!Renderer) {
|
|
||||||
return (
|
|
||||||
<div key={item.id} className={`p-4 border border-dashed border-white/20 rounded-lg ${itemClassName}`}>
|
|
||||||
<p className="text-sm text-white/50">未知内容类型: {item.modelCode}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Renderer
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
index={i}
|
|
||||||
variant={zoneItem.variant}
|
|
||||||
className={itemClassName}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
import type {
|
|
||||||
ContentModel,
|
|
||||||
ContentItem,
|
|
||||||
ContentQueryParams,
|
|
||||||
PaginatedResult,
|
|
||||||
ContentZone,
|
|
||||||
ThemeConfig,
|
|
||||||
} from './types';
|
|
||||||
import {
|
|
||||||
getMockContentModel,
|
|
||||||
getMockItems,
|
|
||||||
getMockItemBySlug,
|
|
||||||
getMockItemById,
|
|
||||||
contentModels,
|
|
||||||
allMockItems,
|
|
||||||
} from './mock-data';
|
|
||||||
|
|
||||||
export interface CmsClientConfig {
|
|
||||||
baseUrl: string;
|
|
||||||
apiKey?: string;
|
|
||||||
timeout?: number;
|
|
||||||
mockMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CmsClient {
|
|
||||||
private config: Required<CmsClientConfig>;
|
|
||||||
|
|
||||||
constructor(config: CmsClientConfig) {
|
|
||||||
this.config = {
|
|
||||||
baseUrl: config.baseUrl,
|
|
||||||
apiKey: config.apiKey ?? '',
|
|
||||||
timeout: config.timeout ?? 10000,
|
|
||||||
mockMode: config.mockMode ?? false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async request<T>(path: string, options?: RequestInit): Promise<T> {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(options?.headers as Record<string, string>),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.config.apiKey) {
|
|
||||||
headers['Authorization'] = `Bearer ${this.config.apiKey}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
|
||||||
...options,
|
|
||||||
headers,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`CMS API Error: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (await response.json()) as T;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getModels(): Promise<ContentModel[]> {
|
|
||||||
if (this.config.mockMode) {
|
|
||||||
return Object.values(contentModels);
|
|
||||||
}
|
|
||||||
return this.request<ContentModel[]>('/api/models');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getModel(code: string): Promise<ContentModel> {
|
|
||||||
if (this.config.mockMode) {
|
|
||||||
const model = getMockContentModel(code);
|
|
||||||
if (!model) throw new Error(`Content model not found: ${code}`);
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
return this.request<ContentModel>(`/api/models/${code}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getItems(params: ContentQueryParams): Promise<PaginatedResult<ContentItem>> {
|
|
||||||
if (this.config.mockMode) {
|
|
||||||
// 使用统一 mock 数据源(覆盖所有已注册模型)
|
|
||||||
let items = params.modelCode ? [...getMockItems(params.modelCode)] : [];
|
|
||||||
|
|
||||||
if (params.status) {
|
|
||||||
items = items.filter((item) => item.status === params.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.search) {
|
|
||||||
const searchLower = params.search.toLowerCase();
|
|
||||||
items = items.filter(
|
|
||||||
(item) =>
|
|
||||||
item.title.toLowerCase().includes(searchLower) ||
|
|
||||||
(item.slug?.toLowerCase().includes(searchLower) ?? false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = params.page ?? 1;
|
|
||||||
const pageSize = params.pageSize ?? 10;
|
|
||||||
const total = items.length;
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
|
||||||
const start = (page - 1) * pageSize;
|
|
||||||
const paginatedItems = items.slice(start, start + pageSize);
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: paginatedItems,
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
totalPages,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
queryParams.set('modelCode', params.modelCode);
|
|
||||||
if (params.status) queryParams.set('status', params.status);
|
|
||||||
if (params.page) queryParams.set('page', String(params.page));
|
|
||||||
if (params.pageSize) queryParams.set('pageSize', String(params.pageSize));
|
|
||||||
if (params.sortBy) queryParams.set('sortBy', params.sortBy);
|
|
||||||
if (params.sortOrder) queryParams.set('sortOrder', params.sortOrder);
|
|
||||||
if (params.search) queryParams.set('search', params.search);
|
|
||||||
if (params.filters) {
|
|
||||||
Object.entries(params.filters).forEach(([key, value]) => {
|
|
||||||
queryParams.set(`filters[${key}]`, String(value));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.request<PaginatedResult<ContentItem>>(
|
|
||||||
`/api/items?${queryParams.toString()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getItem(id: string): Promise<ContentItem> {
|
|
||||||
if (this.config.mockMode) {
|
|
||||||
// 跨所有模型查找
|
|
||||||
for (const modelCode of Object.keys(allMockItems)) {
|
|
||||||
const item = getMockItemById(modelCode, id);
|
|
||||||
if (item) return item;
|
|
||||||
}
|
|
||||||
throw new Error(`Content item not found: ${id}`);
|
|
||||||
}
|
|
||||||
return this.request<ContentItem>(`/api/items/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getItemBySlug(modelCode: string, slug: string): Promise<ContentItem> {
|
|
||||||
if (this.config.mockMode) {
|
|
||||||
const item = getMockItemBySlug(modelCode, slug);
|
|
||||||
if (!item) throw new Error(`Content item not found: ${modelCode}/${slug}`);
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
return this.request<ContentItem>(`/api/items/slug/${modelCode}/${slug}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getZone(code: string): Promise<ContentZone> {
|
|
||||||
if (this.config.mockMode) {
|
|
||||||
return {
|
|
||||||
id: `zone-${code}`,
|
|
||||||
code,
|
|
||||||
name: code,
|
|
||||||
allowedModels: ['case-study'],
|
|
||||||
items: getMockItems('case-study').slice(0, 3).map((item, index) => ({
|
|
||||||
itemId: item.id,
|
|
||||||
item,
|
|
||||||
sortOrder: index,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return this.request<ContentZone>(`/api/zones/${code}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getZonesByPage(pageCode: string): Promise<ContentZone[]> {
|
|
||||||
if (this.config.mockMode) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: `zone-${pageCode}-featured`,
|
|
||||||
code: `${pageCode}-featured`,
|
|
||||||
name: `${pageCode} 精选区域`,
|
|
||||||
pageCode,
|
|
||||||
allowedModels: ['case-study'],
|
|
||||||
items: getMockItems('case-study')
|
|
||||||
.filter((item) => (item.data as { featured?: boolean }).featured)
|
|
||||||
.map((item, index) => ({
|
|
||||||
itemId: item.id,
|
|
||||||
item,
|
|
||||||
sortOrder: index,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return this.request<ContentZone[]>(`/api/zones/page/${pageCode}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getThemeConfig(): Promise<ThemeConfig> {
|
|
||||||
if (this.config.mockMode) {
|
|
||||||
return {
|
|
||||||
id: 'theme-default',
|
|
||||||
siteName: 'Novalon',
|
|
||||||
siteDescription: '企业数字化转型合作伙伴',
|
|
||||||
colors: {
|
|
||||||
primary: '#2563eb',
|
|
||||||
secondary: '#64748b',
|
|
||||||
accent: '#0891b2',
|
|
||||||
background: '#ffffff',
|
|
||||||
text: '#0f172a',
|
|
||||||
},
|
|
||||||
seo: {
|
|
||||||
defaultTitle: 'Novalon - 企业数字化转型合作伙伴',
|
|
||||||
defaultDescription: '专注企业数字化转型,提供从战略咨询到技术落地的全链路服务',
|
|
||||||
defaultKeywords: '数字化转型,ERP,数据中台,全渠道零售',
|
|
||||||
},
|
|
||||||
social: {
|
|
||||||
wechat: 'novalon',
|
|
||||||
linkedin: 'novalon',
|
|
||||||
github: 'novalon',
|
|
||||||
},
|
|
||||||
settings: {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return this.request<ThemeConfig>('/api/theme/config');
|
|
||||||
}
|
|
||||||
|
|
||||||
getTypedItem<T>(item: ContentItem): T {
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
slug: item.slug,
|
|
||||||
title: item.title,
|
|
||||||
...item.data,
|
|
||||||
} as unknown as T;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const cmsClient = new CmsClient({
|
|
||||||
baseUrl: '',
|
|
||||||
mockMode: true,
|
|
||||||
});
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { ComponentType } from 'react';
|
|
||||||
import type { ContentItem, ContentZone } from './types';
|
|
||||||
|
|
||||||
export interface ContentItemComponentProps {
|
|
||||||
item: ContentItem;
|
|
||||||
index?: number;
|
|
||||||
variant?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContentZoneComponentProps {
|
|
||||||
zone: ContentZone;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ContentItemRenderer = ComponentType<ContentItemComponentProps>;
|
|
||||||
export type ContentZoneRenderer = ComponentType<ContentZoneComponentProps>;
|
|
||||||
|
|
||||||
interface ComponentRegistry {
|
|
||||||
itemRenderers: Map<string, ContentItemRenderer>;
|
|
||||||
zoneRenderers: Map<string, ContentZoneRenderer>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const registry: ComponentRegistry = {
|
|
||||||
itemRenderers: new Map(),
|
|
||||||
zoneRenderers: new Map(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function registerItemRenderer(
|
|
||||||
contentType: string,
|
|
||||||
renderer: ContentItemRenderer,
|
|
||||||
): void {
|
|
||||||
registry.itemRenderers.set(contentType, renderer);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerZoneRenderer(
|
|
||||||
zoneType: string,
|
|
||||||
renderer: ContentZoneRenderer,
|
|
||||||
): void {
|
|
||||||
registry.zoneRenderers.set(zoneType, renderer);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getItemRenderer(contentType: string): ContentItemRenderer | undefined {
|
|
||||||
return registry.itemRenderers.get(contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getZoneRenderer(zoneType: string): ContentZoneRenderer | undefined {
|
|
||||||
return registry.zoneRenderers.get(zoneType);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasItemRenderer(contentType: string): boolean {
|
|
||||||
return registry.itemRenderers.has(contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasZoneRenderer(zoneType: string): boolean {
|
|
||||||
return registry.zoneRenderers.has(zoneType);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAllRegisteredItemTypes(): string[] {
|
|
||||||
return Array.from(registry.itemRenderers.keys());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAllRegisteredZoneTypes(): string[] {
|
|
||||||
return Array.from(registry.zoneRenderers.keys());
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,28 @@
|
|||||||
import { registerContentType, type ContentTypeConfig } from './registry';
|
|
||||||
import type { FieldDefinition } from './types';
|
import type { FieldDefinition } from './types';
|
||||||
|
|
||||||
|
export interface ContentTypeConfig {
|
||||||
|
model: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
fields: FieldDefinition[];
|
||||||
|
isPageType: boolean;
|
||||||
|
urlPattern?: string;
|
||||||
|
hasVersions: boolean;
|
||||||
|
hasDraft: boolean;
|
||||||
|
icon: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
listPage?: {
|
||||||
|
route: string;
|
||||||
|
};
|
||||||
|
detailPage?: {
|
||||||
|
routePattern: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const newsFields: FieldDefinition[] = [
|
const newsFields: FieldDefinition[] = [
|
||||||
{
|
{
|
||||||
name: 'excerpt',
|
name: 'excerpt',
|
||||||
@@ -993,17 +1015,9 @@ const legalPageConfig: ContentTypeConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @deprecated 旧客户端 CMS 的注册函数,已不再需要 */
|
||||||
export function registerAllContentTypes(): void {
|
export function registerAllContentTypes(): void {
|
||||||
registerContentType(newsConfig);
|
// no-op — 数据模型定义已全部移至 CONTENT_TYPE_CONFIGS
|
||||||
registerContentType(serviceConfig);
|
|
||||||
registerContentType(productConfig);
|
|
||||||
registerContentType(solutionConfig);
|
|
||||||
registerContentType(heroBannerConfig);
|
|
||||||
registerContentType(statItemConfig);
|
|
||||||
registerContentType(aboutPageConfig);
|
|
||||||
registerContentType(teamPageConfig);
|
|
||||||
registerContentType(contactPageConfig);
|
|
||||||
registerContentType(legalPageConfig);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CONTENT_TYPE_CONFIGS = {
|
export const CONTENT_TYPE_CONFIGS = {
|
||||||
|
|||||||
+2
-66
@@ -12,71 +12,7 @@ export {
|
|||||||
type ContentQueryParams,
|
type ContentQueryParams,
|
||||||
type PaginatedResult,
|
type PaginatedResult,
|
||||||
type AuditLog,
|
type AuditLog,
|
||||||
|
type CaseStudyData,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export { cmsClient } from './client';
|
export { registerAllContentTypes, CONTENT_TYPE_CONFIGS } from './content-types';
|
||||||
export type { CmsClient, CmsClientConfig } from './client';
|
|
||||||
|
|
||||||
export { getMockContentModel, getMockCaseStudies, getMockCaseStudyBySlug } from './mock-data';
|
|
||||||
export type { CaseStudyData } from './mock-data';
|
|
||||||
|
|
||||||
export {
|
|
||||||
registerContentType,
|
|
||||||
getContentTypeConfig,
|
|
||||||
getAllContentTypeConfigs,
|
|
||||||
getContentModel,
|
|
||||||
getAllContentModels,
|
|
||||||
} from './registry';
|
|
||||||
export type { ContentTypeConfig } from './registry';
|
|
||||||
|
|
||||||
export { registerAllContentTypes, CONTENT_TYPE_CONFIGS } from './content-types';
|
|
||||||
|
|
||||||
export {
|
|
||||||
registerItemRenderer,
|
|
||||||
registerZoneRenderer,
|
|
||||||
getItemRenderer,
|
|
||||||
getZoneRenderer,
|
|
||||||
hasItemRenderer,
|
|
||||||
hasZoneRenderer,
|
|
||||||
getAllRegisteredItemTypes,
|
|
||||||
getAllRegisteredZoneTypes,
|
|
||||||
} from './component-registry';
|
|
||||||
export type {
|
|
||||||
ContentItemComponentProps,
|
|
||||||
ContentZoneComponentProps,
|
|
||||||
ContentItemRenderer,
|
|
||||||
ContentZoneRenderer as ContentZoneRendererType,
|
|
||||||
} from './component-registry';
|
|
||||||
|
|
||||||
export {
|
|
||||||
NewsCardRenderer,
|
|
||||||
StatCardRenderer,
|
|
||||||
ServiceCardRenderer,
|
|
||||||
ProductCardRenderer,
|
|
||||||
SolutionCardRenderer,
|
|
||||||
HeroBannerRenderer,
|
|
||||||
} from './renderers';
|
|
||||||
|
|
||||||
export { ContentZoneRenderer } from './ContentZoneRenderer';
|
|
||||||
|
|
||||||
export {
|
|
||||||
loadCmsData,
|
|
||||||
saveCmsData,
|
|
||||||
getContentZone,
|
|
||||||
saveContentZone,
|
|
||||||
getAllContentZones,
|
|
||||||
getContentItems,
|
|
||||||
saveContentItem,
|
|
||||||
deleteContentItem,
|
|
||||||
resetCmsData,
|
|
||||||
exportCmsData,
|
|
||||||
importCmsData,
|
|
||||||
getContentZoneKeys,
|
|
||||||
getAvailableItemsForZone,
|
|
||||||
addItemToZone,
|
|
||||||
removeItemFromZone,
|
|
||||||
reorderZoneItems,
|
|
||||||
updateZoneSettings,
|
|
||||||
} from './storage';
|
|
||||||
|
|
||||||
export { initCms, isCmsInitialized } from './init';
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
registerItemRenderer,
|
|
||||||
} from './component-registry';
|
|
||||||
import {
|
|
||||||
NewsCardRenderer,
|
|
||||||
StatCardRenderer,
|
|
||||||
ServiceCardRenderer,
|
|
||||||
ProductCardRenderer,
|
|
||||||
SolutionCardRenderer,
|
|
||||||
HeroBannerRenderer,
|
|
||||||
} from './renderers';
|
|
||||||
import { registerAllContentTypes } from './content-types';
|
|
||||||
import type { ContentItem } from './types';
|
|
||||||
|
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
export function initCms(): void {
|
|
||||||
if (initialized) return;
|
|
||||||
initialized = true;
|
|
||||||
|
|
||||||
registerAllContentTypes();
|
|
||||||
|
|
||||||
registerItemRenderer('news', NewsCardRenderer);
|
|
||||||
registerItemRenderer('case-study', NewsCardRenderer as unknown as React.ComponentType<{ item: ContentItem; index?: number; variant?: string; className?: string }>);
|
|
||||||
registerItemRenderer('stat-item', StatCardRenderer);
|
|
||||||
registerItemRenderer('service', ServiceCardRenderer);
|
|
||||||
registerItemRenderer('product', ProductCardRenderer);
|
|
||||||
registerItemRenderer('solution', SolutionCardRenderer);
|
|
||||||
registerItemRenderer('hero-banner', HeroBannerRenderer);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCmsInitialized(): boolean {
|
|
||||||
return initialized;
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
import type { ContentModel, ContentItem } from './types';
|
|
||||||
import {
|
|
||||||
CASE_STUDIES,
|
|
||||||
type CaseStudy,
|
|
||||||
type CaseMetric,
|
|
||||||
type CaseTimelinePhase,
|
|
||||||
type CaseService,
|
|
||||||
type CaseTestimonial,
|
|
||||||
} from '../constants/cases';
|
|
||||||
import { NEWS } from '../constants/news';
|
|
||||||
import { SERVICES } from '../constants/services';
|
|
||||||
import { PRODUCTS } from '../constants/products';
|
|
||||||
import { SOLUTIONS } from '../constants/solutions';
|
|
||||||
|
|
||||||
// ============ 类型定义 ============
|
|
||||||
|
|
||||||
export interface CaseStudyData {
|
|
||||||
client: string;
|
|
||||||
industry: string;
|
|
||||||
companySize: string;
|
|
||||||
subtitle: string;
|
|
||||||
challenge: string;
|
|
||||||
solution: string;
|
|
||||||
result: string;
|
|
||||||
metrics: CaseMetric[];
|
|
||||||
timeline: CaseTimelinePhase[];
|
|
||||||
services: CaseService[];
|
|
||||||
testimonial?: CaseTestimonial;
|
|
||||||
color: 'brand' | 'blue' | 'teal' | 'amber' | 'purple';
|
|
||||||
featured?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 内容模型定义 ============
|
|
||||||
|
|
||||||
const caseStudyModel: ContentModel = {
|
|
||||||
id: 'model-case-study',
|
|
||||||
code: 'case-study',
|
|
||||||
name: '案例研究',
|
|
||||||
description: '客户成功案例与实践分享',
|
|
||||||
isPageType: true,
|
|
||||||
urlPattern: '/cases/{slug}',
|
|
||||||
hasVersions: true,
|
|
||||||
hasDraft: true,
|
|
||||||
icon: 'briefcase',
|
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
|
||||||
fields: [
|
|
||||||
{ name: 'client', type: 'text', label: '客户名称', required: true, description: '客户公司或机构名称' },
|
|
||||||
{ name: 'industry', type: 'text', label: '所属行业', required: true, description: '客户所属行业领域' },
|
|
||||||
{ name: 'companySize', type: 'text', label: '公司规模', required: true, description: '客户公司人员规模' },
|
|
||||||
{ name: 'subtitle', type: 'text', label: '副标题', required: true, description: '案例副标题,简要概括项目价值' },
|
|
||||||
{ name: 'challenge', type: 'textarea', label: '挑战', required: true, description: '客户面临的业务挑战与痛点' },
|
|
||||||
{ name: 'solution', type: 'textarea', label: '解决方案', required: true, description: '我们提供的解决方案与实施路径' },
|
|
||||||
{ name: 'result', type: 'textarea', label: '实施成果', required: true, description: '项目交付后的业务成果与价值' },
|
|
||||||
{
|
|
||||||
name: 'metrics',
|
|
||||||
type: 'array',
|
|
||||||
label: '关键指标',
|
|
||||||
required: false,
|
|
||||||
description: '项目关键量化指标',
|
|
||||||
fields: [
|
|
||||||
{ name: 'value', type: 'text', label: '数值', required: true },
|
|
||||||
{ name: 'label', type: 'text', label: '标签', required: true },
|
|
||||||
{ name: 'highlight', type: 'boolean', label: '高亮', required: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'timeline',
|
|
||||||
type: 'array',
|
|
||||||
label: '项目时间线',
|
|
||||||
required: false,
|
|
||||||
description: '项目各阶段时间与描述',
|
|
||||||
fields: [
|
|
||||||
{ name: 'phase', type: 'text', label: '阶段', required: true },
|
|
||||||
{ name: 'duration', type: 'text', label: '周期', required: true },
|
|
||||||
{ name: 'description', type: 'textarea', label: '描述', required: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'services',
|
|
||||||
type: 'array',
|
|
||||||
label: '相关服务',
|
|
||||||
required: false,
|
|
||||||
description: '案例涉及的服务项目',
|
|
||||||
fields: [
|
|
||||||
{ name: 'id', type: 'text', label: '服务ID', required: true },
|
|
||||||
{ name: 'title', type: 'text', label: '服务名称', required: true },
|
|
||||||
{ name: 'description', type: 'textarea', label: '服务描述', required: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'testimonial',
|
|
||||||
type: 'object',
|
|
||||||
label: '客户评价',
|
|
||||||
required: false,
|
|
||||||
description: '客户证言与评价',
|
|
||||||
fields: [
|
|
||||||
{ name: 'quote', type: 'textarea', label: '评价内容', required: true },
|
|
||||||
{ name: 'author', type: 'text', label: '评价人', required: true },
|
|
||||||
{ name: 'role', type: 'text', label: '职位', required: true },
|
|
||||||
{ name: 'company', type: 'text', label: '公司', required: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'color',
|
|
||||||
type: 'text',
|
|
||||||
label: '主题色',
|
|
||||||
required: true,
|
|
||||||
description: '案例展示主题色',
|
|
||||||
options: [
|
|
||||||
{ label: '品牌色', value: 'brand' },
|
|
||||||
{ label: '蓝色', value: 'blue' },
|
|
||||||
{ label: '青色', value: 'teal' },
|
|
||||||
{ label: '琥珀色', value: 'amber' },
|
|
||||||
{ label: '紫色', value: 'purple' },
|
|
||||||
],
|
|
||||||
defaultValue: 'brand',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'featured',
|
|
||||||
type: 'boolean',
|
|
||||||
label: '精选案例',
|
|
||||||
required: false,
|
|
||||||
description: '是否在首页/推荐位展示',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 通用转换函数 ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将业务数据记录转换为 CMS ContentItem
|
|
||||||
* @param source 原始数据记录
|
|
||||||
* @param modelCode 内容模型代码
|
|
||||||
* @param modelId 内容模型 ID
|
|
||||||
* @param index 索引(用于排序)
|
|
||||||
* @param options 配置选项(slug 字段名、title 字段名)
|
|
||||||
*/
|
|
||||||
function toContentItem<T extends Record<string, unknown>>(
|
|
||||||
source: T,
|
|
||||||
modelCode: string,
|
|
||||||
modelId: string,
|
|
||||||
index: number,
|
|
||||||
options: { slugField?: string; titleField?: string } = {},
|
|
||||||
): ContentItem {
|
|
||||||
const slugField = options.slugField || 'id';
|
|
||||||
const titleField = options.titleField || 'title';
|
|
||||||
return {
|
|
||||||
id: String(source.id || `item-${modelCode}-${index}`),
|
|
||||||
modelId,
|
|
||||||
modelCode,
|
|
||||||
title: String(source[titleField] || `未命名 ${index + 1}`),
|
|
||||||
slug: String(source[slugField] || source.id || `item-${index}`),
|
|
||||||
status: 'published',
|
|
||||||
version: 1,
|
|
||||||
sortOrder: index,
|
|
||||||
data: source,
|
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2024-06-01T00:00:00Z',
|
|
||||||
publishedAt: '2024-06-01T00:00:00Z',
|
|
||||||
createdBy: 'system',
|
|
||||||
updatedBy: 'system',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Mock 数据生成 ============
|
|
||||||
|
|
||||||
function caseStudyToContentItem(cs: CaseStudy): ContentItem {
|
|
||||||
const { id, slug, title, ...data } = cs;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
modelId: 'model-case-study',
|
|
||||||
modelCode: 'case-study',
|
|
||||||
title,
|
|
||||||
slug,
|
|
||||||
status: 'published',
|
|
||||||
data: data as Record<string, unknown>,
|
|
||||||
version: 1,
|
|
||||||
publishedAt: '2024-06-01T00:00:00Z',
|
|
||||||
createdBy: 'system',
|
|
||||||
updatedBy: 'system',
|
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2024-06-01T00:00:00Z',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const caseStudyItems: ContentItem[] = CASE_STUDIES.map(caseStudyToContentItem);
|
|
||||||
const newsItems: ContentItem[] = NEWS.map((n, i) =>
|
|
||||||
toContentItem(n as unknown as Record<string, unknown>, 'news', 'model-news', i),
|
|
||||||
);
|
|
||||||
const serviceItems: ContentItem[] = SERVICES.map((s, i) =>
|
|
||||||
toContentItem(s as unknown as Record<string, unknown>, 'service', 'model-service', i),
|
|
||||||
);
|
|
||||||
const productItems: ContentItem[] = PRODUCTS.map((p, i) =>
|
|
||||||
toContentItem(p as unknown as Record<string, unknown>, 'product', 'model-product', i),
|
|
||||||
);
|
|
||||||
const solutionItems: ContentItem[] = SOLUTIONS.map((s, i) =>
|
|
||||||
toContentItem(s as unknown as Record<string, unknown>, 'solution', 'model-solution', i),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ============ 模型注册表 ============
|
|
||||||
|
|
||||||
const contentModels: Record<string, ContentModel> = {
|
|
||||||
'case-study': caseStudyModel,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 所有 Mock 数据集合 ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 所有模型的 Mock 数据集合
|
|
||||||
* 用于 cmsClient mock 模式和 storage 默认数据初始化
|
|
||||||
*/
|
|
||||||
export const allMockItems: Record<string, ContentItem[]> = {
|
|
||||||
'case-study': caseStudyItems,
|
|
||||||
news: newsItems,
|
|
||||||
service: serviceItems,
|
|
||||||
product: productItems,
|
|
||||||
solution: solutionItems,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 同步访问函数(server-safe) ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取指定模型的所有 Mock 数据
|
|
||||||
* 同步函数,server-safe,兼容静态导出(generateStaticParams)
|
|
||||||
*/
|
|
||||||
export function getMockItems(modelCode: string): ContentItem[] {
|
|
||||||
return allMockItems[modelCode] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据 slug 获取单条 Mock 数据
|
|
||||||
* 同步函数,server-safe
|
|
||||||
*/
|
|
||||||
export function getMockItemBySlug(modelCode: string, slug: string): ContentItem | undefined {
|
|
||||||
return getMockItems(modelCode).find((item) => item.slug === slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据 ID 获取单条 Mock 数据
|
|
||||||
* 同步函数,server-safe
|
|
||||||
*/
|
|
||||||
export function getMockItemById(modelCode: string, id: string): ContentItem | undefined {
|
|
||||||
return getMockItems(modelCode).find((item) => item.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有已注册的内容模型
|
|
||||||
*/
|
|
||||||
export function getMockContentModel(code: string): ContentModel | undefined {
|
|
||||||
return contentModels[code];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有内容模型列表
|
|
||||||
*/
|
|
||||||
export function getAllMockContentModels(): ContentModel[] {
|
|
||||||
return Object.values(contentModels);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 兼容旧接口(保留导出,避免破坏性变更) ============
|
|
||||||
|
|
||||||
export function getMockCaseStudies(): ContentItem[] {
|
|
||||||
return caseStudyItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMockCaseStudyBySlug(slug: string): ContentItem | undefined {
|
|
||||||
return caseStudyItems.find((item) => item.slug === slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { contentModels, caseStudyItems };
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
import type { ContentZone, ContentItem } from './types';
|
|
||||||
import { STATS } from '@/lib/constants/stats';
|
|
||||||
import { SERVICES } from '@/lib/constants/services';
|
|
||||||
import { getFeaturedCases } from '@/lib/constants/cases';
|
|
||||||
import { NEWS } from '@/lib/constants/news';
|
|
||||||
import { SOLUTIONS } from '@/lib/constants/solutions';
|
|
||||||
|
|
||||||
function createBaseItem(id: string, modelCode: string, title: string, slug: string, index: number): Omit<ContentItem, 'data'> {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
modelId: modelCode,
|
|
||||||
modelCode,
|
|
||||||
title,
|
|
||||||
slug,
|
|
||||||
status: 'published',
|
|
||||||
version: 1,
|
|
||||||
sortOrder: index,
|
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
|
||||||
publishedAt: '2024-01-01T00:00:00Z',
|
|
||||||
createdBy: 'system',
|
|
||||||
updatedBy: 'system',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function statToContentItem(stat: Record<string, unknown>, index: number): ContentItem {
|
|
||||||
return {
|
|
||||||
...createBaseItem(`stat-${index}`, 'stat-item', String(stat.label || ''), `stat-${index}`, index),
|
|
||||||
data: {
|
|
||||||
icon: stat.icon,
|
|
||||||
label: stat.label,
|
|
||||||
value: stat.value,
|
|
||||||
suffix: stat.suffix,
|
|
||||||
prefix: stat.prefix || '',
|
|
||||||
decimals: stat.decimals || 0,
|
|
||||||
description: stat.description || '',
|
|
||||||
trendValue: stat.trend || '',
|
|
||||||
trendDirection: typeof stat.trend === 'string' && stat.trend.startsWith('+') ? 'up' : 'down',
|
|
||||||
accentColor: stat.color || 'brand',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function serviceToContentItem(service: { id: string; title: string; description: string; icon?: string; overview?: string; features?: unknown[]; benefits?: unknown[] }, index: number): ContentItem {
|
|
||||||
return {
|
|
||||||
...createBaseItem(service.id, 'service', service.title, service.id, index),
|
|
||||||
data: {
|
|
||||||
description: service.description,
|
|
||||||
icon: service.icon,
|
|
||||||
overview: service.overview,
|
|
||||||
features: service.features || [],
|
|
||||||
benefits: service.benefits || [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function caseStudyToContentItem(cs: { id: string; title: string; slug: string; client?: string; industry?: string; subtitle?: string; challenge?: string; result?: string; metrics?: unknown[]; color?: string; featured?: boolean }, index: number): ContentItem {
|
|
||||||
return {
|
|
||||||
...createBaseItem(cs.id, 'case-study', cs.title, cs.slug, index),
|
|
||||||
data: {
|
|
||||||
client: cs.client,
|
|
||||||
industry: cs.industry,
|
|
||||||
subtitle: cs.subtitle,
|
|
||||||
challenge: cs.challenge,
|
|
||||||
result: cs.result,
|
|
||||||
metrics: cs.metrics || [],
|
|
||||||
color: cs.color,
|
|
||||||
featured: cs.featured,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function newsToContentItem(news: { id: string; title: string; excerpt?: string; date?: string; category?: string; image?: string; content?: string }, index: number): ContentItem {
|
|
||||||
return {
|
|
||||||
...createBaseItem(news.id, 'news', news.title, news.id, index),
|
|
||||||
data: {
|
|
||||||
excerpt: news.excerpt,
|
|
||||||
date: news.date,
|
|
||||||
category: news.category,
|
|
||||||
image: news.image,
|
|
||||||
content: news.content,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function solutionToContentItem(solution: { id: string; title: string; description: string; icon?: string; industry?: string; overview?: string }, index: number): ContentItem {
|
|
||||||
return {
|
|
||||||
...createBaseItem(solution.id, 'solution', solution.title, solution.id, index),
|
|
||||||
data: {
|
|
||||||
description: solution.description,
|
|
||||||
icon: solution.icon,
|
|
||||||
industry: solution.industry || '',
|
|
||||||
overview: solution.overview || '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMockHomeHeroBanner(): ContentItem {
|
|
||||||
return {
|
|
||||||
...createBaseItem('hero-home-main', 'hero-banner', '企业数字化转型的可靠伙伴', 'hero-home-main', 0),
|
|
||||||
data: {
|
|
||||||
heading: '企业数字化转型的可靠伙伴',
|
|
||||||
subheading: '深耕企业数字化 10+ 年',
|
|
||||||
description: '从战略规划到落地实施,为成长型企业提供端到端的数字化解决方案。以务实的态度、专业的能力、长期的陪伴,帮助企业实现可持续增长。',
|
|
||||||
primaryCtaText: '了解解决方案',
|
|
||||||
primaryCtaLink: '/solutions',
|
|
||||||
secondaryCtaText: '联系我们',
|
|
||||||
secondaryCtaLink: '/contact',
|
|
||||||
theme: 'brand',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMockHomeStatsZone(): ContentZone {
|
|
||||||
const statList = Array.isArray(STATS) ? STATS : (STATS as { stats?: unknown[] }).stats || [];
|
|
||||||
const items = statList.slice(0, 4).map((s, i) => statToContentItem(s as Record<string, unknown>, i));
|
|
||||||
return {
|
|
||||||
id: 'zone-home-stats',
|
|
||||||
code: 'home-stats',
|
|
||||||
name: '首页数据指标区',
|
|
||||||
zoneKey: 'home-stats',
|
|
||||||
pageCode: 'home',
|
|
||||||
allowedModels: ['stat-item'],
|
|
||||||
items: items.map((item, i) => ({
|
|
||||||
item,
|
|
||||||
itemId: item.id,
|
|
||||||
sortOrder: i,
|
|
||||||
variant: 'default',
|
|
||||||
})),
|
|
||||||
settings: {
|
|
||||||
layout: 'grid',
|
|
||||||
columns: 4,
|
|
||||||
gap: 'large',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMockHomeServicesZone(): ContentZone {
|
|
||||||
const items = SERVICES.slice(0, 4).map((s, i) => serviceToContentItem(s, i));
|
|
||||||
return {
|
|
||||||
id: 'zone-home-services',
|
|
||||||
code: 'home-services',
|
|
||||||
name: '首页服务区',
|
|
||||||
zoneKey: 'home-services',
|
|
||||||
pageCode: 'home',
|
|
||||||
allowedModels: ['service'],
|
|
||||||
items: items.map((item, i) => ({
|
|
||||||
item,
|
|
||||||
itemId: item.id,
|
|
||||||
sortOrder: i,
|
|
||||||
variant: 'default',
|
|
||||||
})),
|
|
||||||
settings: {
|
|
||||||
layout: 'grid',
|
|
||||||
columns: 4,
|
|
||||||
gap: 'large',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMockHomeCasesZone(): ContentZone {
|
|
||||||
const items = getFeaturedCases().slice(0, 3).map((cs, i) => caseStudyToContentItem(cs, i));
|
|
||||||
return {
|
|
||||||
id: 'zone-home-cases',
|
|
||||||
code: 'home-cases',
|
|
||||||
name: '首页案例区',
|
|
||||||
zoneKey: 'home-cases',
|
|
||||||
pageCode: 'home',
|
|
||||||
allowedModels: ['case-study'],
|
|
||||||
items: items.map((item, i) => ({
|
|
||||||
item,
|
|
||||||
itemId: item.id,
|
|
||||||
sortOrder: i,
|
|
||||||
variant: 'default',
|
|
||||||
})),
|
|
||||||
settings: {
|
|
||||||
layout: 'grid',
|
|
||||||
columns: 3,
|
|
||||||
gap: 'large',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMockHomeNewsZone(): ContentZone {
|
|
||||||
const items = NEWS.slice(0, 3).map((n, i) => newsToContentItem(n, i));
|
|
||||||
return {
|
|
||||||
id: 'zone-home-news',
|
|
||||||
code: 'home-news',
|
|
||||||
name: '首页新闻区',
|
|
||||||
zoneKey: 'home-news',
|
|
||||||
pageCode: 'home',
|
|
||||||
allowedModels: ['news'],
|
|
||||||
items: items.map((item, i) => ({
|
|
||||||
item,
|
|
||||||
itemId: item.id,
|
|
||||||
sortOrder: i,
|
|
||||||
variant: 'default',
|
|
||||||
})),
|
|
||||||
settings: {
|
|
||||||
layout: 'grid',
|
|
||||||
columns: 3,
|
|
||||||
gap: 'large',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMockHomeSolutionsZone(): ContentZone {
|
|
||||||
const items = SOLUTIONS.slice(0, 4).map((s, i) => solutionToContentItem(s, i));
|
|
||||||
return {
|
|
||||||
id: 'zone-home-solutions',
|
|
||||||
code: 'home-solutions',
|
|
||||||
name: '首页方案区',
|
|
||||||
zoneKey: 'home-solutions',
|
|
||||||
pageCode: 'home',
|
|
||||||
allowedModels: ['solution'],
|
|
||||||
items: items.map((item, i) => ({
|
|
||||||
item,
|
|
||||||
itemId: item.id,
|
|
||||||
sortOrder: i,
|
|
||||||
variant: 'default',
|
|
||||||
})),
|
|
||||||
settings: {
|
|
||||||
layout: 'grid',
|
|
||||||
columns: 4,
|
|
||||||
gap: 'large',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMockContentZone(zoneKey: string, _page = 'home'): ContentZone | null {
|
|
||||||
const zones: Record<string, ContentZone> = {
|
|
||||||
'home-stats': getMockHomeStatsZone(),
|
|
||||||
'home-services': getMockHomeServicesZone(),
|
|
||||||
'home-cases': getMockHomeCasesZone(),
|
|
||||||
'home-news': getMockHomeNewsZone(),
|
|
||||||
'home-solutions': getMockHomeSolutionsZone(),
|
|
||||||
};
|
|
||||||
return zones[zoneKey] || null;
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import type { ContentModel, FieldDefinition, ContentItem } from './types';
|
|
||||||
import { getMockCaseStudies, getMockCaseStudyBySlug } from './mock-data';
|
|
||||||
|
|
||||||
export interface ContentTypeConfig {
|
|
||||||
model: ContentModel;
|
|
||||||
mockData?: {
|
|
||||||
list: () => Promise<{ items: ContentItem[]; total: number }>;
|
|
||||||
getBySlug?: (slug: string) => Promise<ContentItem | null>;
|
|
||||||
getById?: (id: string) => Promise<ContentItem | null>;
|
|
||||||
};
|
|
||||||
listPage?: {
|
|
||||||
route: string;
|
|
||||||
};
|
|
||||||
detailPage?: {
|
|
||||||
routePattern: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentTypeRegistry = new Map<string, ContentTypeConfig>();
|
|
||||||
|
|
||||||
export function registerContentType(
|
|
||||||
config: ContentTypeConfig
|
|
||||||
): void {
|
|
||||||
contentTypeRegistry.set(config.model.code, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getContentTypeConfig(code: string): ContentTypeConfig | undefined {
|
|
||||||
return contentTypeRegistry.get(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAllContentTypeConfigs(): ContentTypeConfig[] {
|
|
||||||
return Array.from(contentTypeRegistry.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getContentModel(code: string): ContentModel | undefined {
|
|
||||||
return contentTypeRegistry.get(code)?.model;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAllContentModels(): ContentModel[] {
|
|
||||||
return getAllContentTypeConfigs().map((c) => c.model);
|
|
||||||
}
|
|
||||||
|
|
||||||
const caseStudyFields: FieldDefinition[] = [
|
|
||||||
{
|
|
||||||
name: 'client',
|
|
||||||
type: 'text',
|
|
||||||
label: '客户名称',
|
|
||||||
required: true,
|
|
||||||
description: '客户公司或机构名称',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'industry',
|
|
||||||
type: 'text',
|
|
||||||
label: '所属行业',
|
|
||||||
required: true,
|
|
||||||
description: '客户所属行业领域',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'companySize',
|
|
||||||
type: 'text',
|
|
||||||
label: '公司规模',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'subtitle',
|
|
||||||
type: 'text',
|
|
||||||
label: '副标题',
|
|
||||||
required: true,
|
|
||||||
description: '案例副标题,简要概括项目价值',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'challenge',
|
|
||||||
type: 'textarea',
|
|
||||||
label: '挑战',
|
|
||||||
required: true,
|
|
||||||
description: '客户面临的业务挑战与痛点',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'solution',
|
|
||||||
type: 'textarea',
|
|
||||||
label: '方案',
|
|
||||||
required: true,
|
|
||||||
description: '我们提供的解决方案',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'result',
|
|
||||||
type: 'textarea',
|
|
||||||
label: '成果',
|
|
||||||
required: true,
|
|
||||||
description: '项目成果与价值',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'metrics',
|
|
||||||
type: 'array',
|
|
||||||
label: '核心指标',
|
|
||||||
description: '可量化的项目成果指标',
|
|
||||||
fields: [
|
|
||||||
{ name: 'value', type: 'text', label: '数值', required: true },
|
|
||||||
{ name: 'label', type: 'text', label: '指标名称', required: true },
|
|
||||||
{ name: 'highlight', type: 'boolean', label: '突出显示', defaultValue: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'timeline',
|
|
||||||
type: 'array',
|
|
||||||
label: '项目时间线',
|
|
||||||
fields: [
|
|
||||||
{ name: 'phase', type: 'text', label: '阶段名称', required: true },
|
|
||||||
{ name: 'duration', type: 'text', label: '持续时间', required: true },
|
|
||||||
{ name: 'description', type: 'textarea', label: '阶段描述' },
|
|
||||||
{ name: 'deliverables', type: 'array', label: '交付物', fields: [{ name: 'name', type: 'text', label: '名称' }] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'services',
|
|
||||||
type: 'array',
|
|
||||||
label: '相关服务',
|
|
||||||
fields: [
|
|
||||||
{ name: 'name', type: 'text', label: '服务名称', required: true },
|
|
||||||
{ name: 'description', type: 'textarea', label: '服务描述' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'testimonial',
|
|
||||||
type: 'object',
|
|
||||||
label: '客户证言',
|
|
||||||
fields: [
|
|
||||||
{ name: 'quote', type: 'textarea', label: '证言内容' },
|
|
||||||
{ name: 'author', type: 'text', label: '发言人' },
|
|
||||||
{ name: 'role', type: 'text', label: '职位' },
|
|
||||||
{ name: 'company', type: 'text', label: '公司' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'color',
|
|
||||||
type: 'text',
|
|
||||||
label: '主题色',
|
|
||||||
defaultValue: 'brand',
|
|
||||||
options: [
|
|
||||||
{ label: '品牌绿', value: 'brand' },
|
|
||||||
{ label: '蓝', value: 'blue' },
|
|
||||||
{ label: '青', value: 'teal' },
|
|
||||||
{ label: '琥珀', value: 'amber' },
|
|
||||||
{ label: '紫', value: 'purple' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'featured',
|
|
||||||
type: 'boolean',
|
|
||||||
label: '精选案例',
|
|
||||||
defaultValue: false,
|
|
||||||
description: '是否在首页精选区展示',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
registerContentType({
|
|
||||||
model: {
|
|
||||||
id: 'model-case-study',
|
|
||||||
code: 'case-study',
|
|
||||||
name: '案例研究',
|
|
||||||
description: '客户成功案例与数字化转型实践',
|
|
||||||
fields: caseStudyFields,
|
|
||||||
isPageType: true,
|
|
||||||
urlPattern: '/cases/{slug}',
|
|
||||||
hasVersions: true,
|
|
||||||
hasDraft: true,
|
|
||||||
icon: 'briefcase',
|
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
mockData: {
|
|
||||||
list: async () => {
|
|
||||||
const items = getMockCaseStudies();
|
|
||||||
return { items, total: items.length };
|
|
||||||
},
|
|
||||||
getBySlug: async (slug: string) => getMockCaseStudyBySlug(slug) ?? null,
|
|
||||||
},
|
|
||||||
listPage: {
|
|
||||||
route: '/cases',
|
|
||||||
},
|
|
||||||
detailPage: {
|
|
||||||
routePattern: '/cases/{slug}',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { ContentItemComponentProps } from './component-registry';
|
|
||||||
|
|
||||||
function getField(item: Record<string, unknown>, key: string, fallback = ''): string {
|
|
||||||
const value = item[key];
|
|
||||||
if (value === null || value === undefined) return fallback;
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NewsCardRenderer({ item, variant = 'default', className = '' }: ContentItemComponentProps) {
|
|
||||||
const data = item.data as Record<string, unknown>;
|
|
||||||
const title = getField(data, 'title') || item.title;
|
|
||||||
const excerpt = getField(data, 'excerpt');
|
|
||||||
const date = getField(data, 'date');
|
|
||||||
const category = getField(data, 'category');
|
|
||||||
const image = getField(data, 'image');
|
|
||||||
const link = `/news/${item.slug}`;
|
|
||||||
|
|
||||||
if (variant === 'featured') {
|
|
||||||
return (
|
|
||||||
<a href={link} className={`group block relative overflow-hidden rounded-2xl bg-white/[0.03] border border-white/[0.08] hover:border-white/[0.15] transition-all duration-500 ${className}`}>
|
|
||||||
<div className="aspect-[16/9] overflow-hidden">
|
|
||||||
{image && (
|
|
||||||
<img src={image} alt={title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<span className="px-2 py-0.5 text-xs font-medium bg-brand/20 text-brand rounded">{category}</span>
|
|
||||||
<span className="text-xs text-white/40">{date}</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-white group-hover:text-brand transition-colors line-clamp-2">{title}</h3>
|
|
||||||
<p className="mt-2 text-sm text-white/50 line-clamp-3">{excerpt}</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a href={link} className={`group block ${className}`}>
|
|
||||||
<article className="relative overflow-hidden rounded-xl bg-white/[0.03] border border-white/[0.08] hover:border-white/[0.15] transition-all duration-300">
|
|
||||||
{image && (
|
|
||||||
<div className="aspect-[16/9] overflow-hidden">
|
|
||||||
<img src={image} alt={title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="px-2 py-0.5 text-[10px] font-medium bg-brand/20 text-brand rounded">{category}</span>
|
|
||||||
<span className="text-[10px] text-white/40">{date}</span>
|
|
||||||
</div>
|
|
||||||
<h4 className="text-sm font-medium text-white group-hover:text-brand transition-colors line-clamp-2">{title}</h4>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatCardRenderer({ item, className = '' }: ContentItemComponentProps) {
|
|
||||||
const data = item.data as Record<string, unknown>;
|
|
||||||
const label = getField(data, 'label') || item.title;
|
|
||||||
const value = data.value ?? 0;
|
|
||||||
const prefix = getField(data, 'prefix');
|
|
||||||
const suffix = getField(data, 'suffix');
|
|
||||||
const decimals = (data.decimals as number) ?? 0;
|
|
||||||
const description = getField(data, 'description');
|
|
||||||
const trendValue = getField(data, 'trendValue');
|
|
||||||
const trendDirection = getField(data, 'trendDirection', 'up');
|
|
||||||
const accentColor = getField(data, 'accentColor', 'brand');
|
|
||||||
const icon = getField(data, 'icon');
|
|
||||||
|
|
||||||
const formattedValue = typeof value === 'number'
|
|
||||||
? `${prefix}${value.toLocaleString('zh-CN', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}${suffix}`
|
|
||||||
: `${prefix}${value}${suffix}`;
|
|
||||||
|
|
||||||
const accentClasses: Record<string, string> = {
|
|
||||||
brand: 'from-brand/20 to-brand/0 text-brand',
|
|
||||||
blue: 'from-blue-500/20 to-blue-500/0 text-blue-400',
|
|
||||||
teal: 'from-teal-500/20 to-teal-500/0 text-teal-400',
|
|
||||||
amber: 'from-amber-500/20 to-amber-500/0 text-amber-400',
|
|
||||||
purple: 'from-purple-500/20 to-purple-500/0 text-purple-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
const trendClasses: Record<string, string> = {
|
|
||||||
up: 'text-emerald-400',
|
|
||||||
down: 'text-red-400',
|
|
||||||
neutral: 'text-white/50',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`relative overflow-hidden rounded-2xl p-6 md:p-8 bg-white/[0.03] border border-white/[0.08] hover:border-white/[0.15] transition-all duration-300 ${className}`}>
|
|
||||||
<div className={`absolute inset-0 bg-gradient-to-br ${accentClasses[accentColor] || accentClasses.brand} opacity-50`} />
|
|
||||||
<div className="relative z-10">
|
|
||||||
{icon && <div className="text-3xl mb-4 opacity-80">{icon}</div>}
|
|
||||||
<div className="text-3xl md:text-4xl font-bold text-white tracking-tight">{formattedValue}</div>
|
|
||||||
<div className="mt-2 text-sm text-white/60 font-medium">{label}</div>
|
|
||||||
{description && <div className="mt-3 text-xs text-white/40 leading-relaxed">{description}</div>}
|
|
||||||
{trendValue && (
|
|
||||||
<div className={`mt-4 inline-flex items-center gap-1 text-xs font-medium ${trendClasses[trendDirection] || trendClasses.up}`}>
|
|
||||||
{trendDirection === 'up' && '↑'}
|
|
||||||
{trendDirection === 'down' && '↓'}
|
|
||||||
{trendDirection === 'neutral' && '→'}
|
|
||||||
<span>{trendValue}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ServiceCardRenderer({ item, className = '' }: ContentItemComponentProps) {
|
|
||||||
const data = item.data as Record<string, unknown>;
|
|
||||||
const title = getField(data, 'title') || item.title;
|
|
||||||
const description = getField(data, 'description');
|
|
||||||
const icon = getField(data, 'icon');
|
|
||||||
const link = `/services/${item.slug}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a href={link} className={`group block relative overflow-hidden rounded-2xl p-8 bg-white/[0.03] border border-white/[0.08] hover:border-brand/50 transition-all duration-500 hover:-translate-y-1 ${className}`}>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-brand/[0.08] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
<div className="relative z-10">
|
|
||||||
{icon && (
|
|
||||||
<div className="w-12 h-12 flex items-center justify-center rounded-xl bg-brand/20 text-brand text-2xl mb-6 group-hover:scale-110 transition-transform duration-300">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<h3 className="text-xl font-semibold text-white group-hover:text-brand transition-colors">{title}</h3>
|
|
||||||
<p className="mt-3 text-sm text-white/50 leading-relaxed line-clamp-3">{description}</p>
|
|
||||||
<div className="mt-6 inline-flex items-center gap-2 text-sm text-brand font-medium group-hover:gap-3 transition-all">
|
|
||||||
了解详情
|
|
||||||
<span>→</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProductCardRenderer({ item, className = '' }: ContentItemComponentProps) {
|
|
||||||
const data = item.data as Record<string, unknown>;
|
|
||||||
const title = getField(data, 'title') || item.title;
|
|
||||||
const description = getField(data, 'description');
|
|
||||||
const image = getField(data, 'image');
|
|
||||||
const category = getField(data, 'category');
|
|
||||||
const status = getField(data, 'status');
|
|
||||||
const link = `/products/${item.slug}`;
|
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
|
||||||
'研发中': 'bg-amber-500/20 text-amber-400',
|
|
||||||
'内测中': 'bg-blue-500/20 text-blue-400',
|
|
||||||
'已发布': 'bg-emerald-500/20 text-emerald-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a href={link} className={`group block relative overflow-hidden rounded-2xl bg-white/[0.03] border border-white/[0.08] hover:border-white/[0.15] transition-all duration-500 ${className}`}>
|
|
||||||
{image && (
|
|
||||||
<div className="aspect-[4/3] overflow-hidden relative">
|
|
||||||
<img src={image} alt={title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" />
|
|
||||||
{status && (
|
|
||||||
<span className={`absolute top-4 right-4 px-3 py-1 text-xs font-medium rounded-full ${statusColors[status] || 'bg-white/10 text-white/70'}`}>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="p-6">
|
|
||||||
{category && <div className="text-xs text-brand font-medium mb-2">{category}</div>}
|
|
||||||
<h3 className="text-lg font-semibold text-white group-hover:text-brand transition-colors line-clamp-2">{title}</h3>
|
|
||||||
<p className="mt-2 text-sm text-white/50 line-clamp-3">{description}</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SolutionCardRenderer({ item, className = '' }: ContentItemComponentProps) {
|
|
||||||
const data = item.data as Record<string, unknown>;
|
|
||||||
const title = getField(data, 'title') || item.title;
|
|
||||||
const description = getField(data, 'description');
|
|
||||||
const icon = getField(data, 'icon');
|
|
||||||
const industry = getField(data, 'industry');
|
|
||||||
const link = `/solutions/${item.slug}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a href={link} className={`group block relative overflow-hidden rounded-2xl p-8 bg-gradient-to-br from-white/[0.04] to-white/[0.01] border border-white/[0.08] hover:border-brand/40 transition-all duration-500 ${className}`}>
|
|
||||||
<div className="relative z-10">
|
|
||||||
{icon && (
|
|
||||||
<div className="w-14 h-14 flex items-center justify-center rounded-2xl bg-gradient-to-br from-brand/30 to-brand/10 text-brand text-2xl mb-6">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{industry && <div className="text-xs text-white/40 font-medium mb-2">{industry}</div>}
|
|
||||||
<h3 className="text-xl font-semibold text-white group-hover:text-brand transition-colors">{title}</h3>
|
|
||||||
<p className="mt-3 text-sm text-white/50 leading-relaxed line-clamp-3">{description}</p>
|
|
||||||
<div className="mt-6 inline-flex items-center gap-2 text-sm text-brand font-medium group-hover:gap-3 transition-all">
|
|
||||||
查看方案
|
|
||||||
<span>→</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeroBannerRenderer({ item, className = '' }: ContentItemComponentProps) {
|
|
||||||
const data = item.data as Record<string, unknown>;
|
|
||||||
const heading = getField(data, 'heading') || item.title;
|
|
||||||
const subheading = getField(data, 'subheading');
|
|
||||||
const description = getField(data, 'description');
|
|
||||||
const primaryCtaText = getField(data, 'primaryCtaText', '了解更多');
|
|
||||||
const primaryCtaLink = getField(data, 'primaryCtaLink', '/solutions');
|
|
||||||
const secondaryCtaText = getField(data, 'secondaryCtaText', '联系我们');
|
|
||||||
const secondaryCtaLink = getField(data, 'secondaryCtaLink', '/contact');
|
|
||||||
const theme = getField(data, 'theme', 'brand');
|
|
||||||
|
|
||||||
const themeGradients: Record<string, string> = {
|
|
||||||
brand: 'from-brand/30 via-brand/10 to-transparent',
|
|
||||||
blue: 'from-blue-500/30 via-blue-500/10 to-transparent',
|
|
||||||
teal: 'from-teal-500/30 via-teal-500/10 to-transparent',
|
|
||||||
amber: 'from-amber-500/30 via-amber-500/10 to-transparent',
|
|
||||||
purple: 'from-purple-500/30 via-purple-500/10 to-transparent',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={`relative min-h-[80vh] flex items-center overflow-hidden bg-ink ${className}`}>
|
|
||||||
<div className={`absolute inset-0 bg-gradient-radial ${themeGradients[theme] || themeGradients.brand} opacity-60`} />
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<div className="absolute top-1/4 -left-32 w-96 h-96 rounded-full bg-brand/20 blur-[120px]" />
|
|
||||||
<div className="absolute bottom-1/4 -right-32 w-96 h-96 rounded-full bg-blue-500/10 blur-[120px]" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10 w-full max-w-7xl mx-auto px-6 lg:px-8 py-32">
|
|
||||||
<div className="max-w-4xl">
|
|
||||||
{subheading && (
|
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/[0.05] border border-white/[0.1] text-sm text-white/70 mb-8">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-brand animate-pulse" />
|
|
||||||
{subheading}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold text-white tracking-tight leading-[1.1]">
|
|
||||||
{heading}
|
|
||||||
</h1>
|
|
||||||
{description && (
|
|
||||||
<p className="mt-8 text-lg md:text-xl text-white/60 max-w-2xl leading-relaxed">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="mt-12 flex flex-wrap gap-4">
|
|
||||||
<a
|
|
||||||
href={primaryCtaLink}
|
|
||||||
className="inline-flex items-center gap-2 px-8 py-4 bg-brand hover:bg-brand-hover text-white font-medium rounded-xl transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-brand/20"
|
|
||||||
>
|
|
||||||
{primaryCtaText}
|
|
||||||
<span>→</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={secondaryCtaLink}
|
|
||||||
className="inline-flex items-center gap-2 px-8 py-4 bg-white/[0.05] hover:bg-white/[0.1] text-white font-medium rounded-xl border border-white/[0.1] hover:border-white/[0.2] transition-all duration-300"
|
|
||||||
>
|
|
||||||
{secondaryCtaText}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { ContentZone, ContentItem, ContentZoneSettings } from './types';
|
|
||||||
import {
|
|
||||||
getMockHomeStatsZone,
|
|
||||||
getMockHomeServicesZone,
|
|
||||||
getMockHomeCasesZone,
|
|
||||||
getMockHomeNewsZone,
|
|
||||||
getMockHomeSolutionsZone,
|
|
||||||
getMockHomeHeroBanner,
|
|
||||||
} from './mock-home';
|
|
||||||
import { NEWS } from '@/lib/constants/news';
|
|
||||||
import { SERVICES } from '@/lib/constants/services';
|
|
||||||
import { PRODUCTS } from '@/lib/constants/products';
|
|
||||||
import { SOLUTIONS } from '@/lib/constants/solutions';
|
|
||||||
import { getFeaturedCases } from '@/lib/constants/cases';
|
|
||||||
import { STATS } from '@/lib/constants/stats';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'novalon-cms-data';
|
|
||||||
const VERSION = '1.0.0';
|
|
||||||
|
|
||||||
interface CmsStorageData {
|
|
||||||
version: string;
|
|
||||||
zones: Record<string, ContentZone>;
|
|
||||||
items: Record<string, ContentItem[]>;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultZones(): Record<string, ContentZone> {
|
|
||||||
return {
|
|
||||||
'home-stats': getMockHomeStatsZone(),
|
|
||||||
'home-services': getMockHomeServicesZone(),
|
|
||||||
'home-solutions': getMockHomeSolutionsZone(),
|
|
||||||
'home-cases': getMockHomeCasesZone(),
|
|
||||||
'home-news': getMockHomeNewsZone(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toContentItem(source: Record<string, unknown>, modelCode: string, index: number): ContentItem {
|
|
||||||
return {
|
|
||||||
id: String(source.id || `item-${index}`),
|
|
||||||
modelId: modelCode,
|
|
||||||
modelCode,
|
|
||||||
title: String(source.title || source.label || source.heading || `未命名 ${index + 1}`),
|
|
||||||
slug: String(source.slug || source.id || `item-${index}`),
|
|
||||||
status: 'published',
|
|
||||||
version: 1,
|
|
||||||
sortOrder: index,
|
|
||||||
data: source,
|
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
|
||||||
publishedAt: '2024-01-01T00:00:00Z',
|
|
||||||
createdBy: 'system',
|
|
||||||
updatedBy: 'system',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultItems(): Record<string, ContentItem[]> {
|
|
||||||
const statList = Array.isArray(STATS) ? STATS : (STATS as { stats?: unknown[] }).stats || [];
|
|
||||||
return {
|
|
||||||
'case-study': getFeaturedCases().map((cs, i) => toContentItem(cs as unknown as Record<string, unknown>, 'case-study', i)),
|
|
||||||
news: NEWS.map((n, i) => toContentItem(n as unknown as Record<string, unknown>, 'news', i)),
|
|
||||||
service: SERVICES.map((s, i) => toContentItem(s as unknown as Record<string, unknown>, 'service', i)),
|
|
||||||
product: PRODUCTS.map((p, i) => toContentItem(p as unknown as Record<string, unknown>, 'product', i)),
|
|
||||||
solution: SOLUTIONS.map((s, i) => toContentItem(s as unknown as Record<string, unknown>, 'solution', i)),
|
|
||||||
'hero-banner': [toContentItem(getMockHomeHeroBanner().data, 'hero-banner', 0)],
|
|
||||||
'stat-item': statList.map((s, i) => toContentItem({ ...(s as Record<string, unknown>), accentColor: (s as Record<string, unknown>).color }, 'stat-item', i)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultData(): CmsStorageData {
|
|
||||||
return {
|
|
||||||
version: VERSION,
|
|
||||||
zones: getDefaultZones(),
|
|
||||||
items: getDefaultItems(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBrowser(): boolean {
|
|
||||||
return typeof window !== 'undefined';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadCmsData(): CmsStorageData {
|
|
||||||
if (!isBrowser()) {
|
|
||||||
return getDefaultData();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) {
|
|
||||||
const defaultData = getDefaultData();
|
|
||||||
saveCmsData(defaultData);
|
|
||||||
return defaultData;
|
|
||||||
}
|
|
||||||
const parsed = JSON.parse(raw) as CmsStorageData;
|
|
||||||
if (parsed.version !== VERSION) {
|
|
||||||
const defaultData = getDefaultData();
|
|
||||||
saveCmsData(defaultData);
|
|
||||||
return defaultData;
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
} catch {
|
|
||||||
return getDefaultData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveCmsData(data: CmsStorageData): void {
|
|
||||||
if (!isBrowser()) return;
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify({ ...data, updatedAt: new Date().toISOString() })
|
|
||||||
);
|
|
||||||
window.dispatchEvent(new CustomEvent('cms-updated'));
|
|
||||||
} catch {
|
|
||||||
// silently fail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getContentZone(zoneKey: string): ContentZone | null {
|
|
||||||
const data = loadCmsData();
|
|
||||||
return data.zones[zoneKey] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveContentZone(zoneKey: string, zone: ContentZone): void {
|
|
||||||
const data = loadCmsData();
|
|
||||||
data.zones[zoneKey] = zone;
|
|
||||||
saveCmsData(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAllContentZones(): Record<string, ContentZone> {
|
|
||||||
return loadCmsData().zones;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getContentItems(modelCode: string): ContentItem[] {
|
|
||||||
const data = loadCmsData();
|
|
||||||
return data.items[modelCode] || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveContentItem(modelCode: string, item: ContentItem): void {
|
|
||||||
const data = loadCmsData();
|
|
||||||
const items = data.items[modelCode] || [];
|
|
||||||
const idx = items.findIndex((i) => i.id === item.id);
|
|
||||||
if (idx >= 0) {
|
|
||||||
items[idx] = item;
|
|
||||||
} else {
|
|
||||||
items.push(item);
|
|
||||||
}
|
|
||||||
data.items[modelCode] = items;
|
|
||||||
saveCmsData(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteContentItem(modelCode: string, itemId: string): void {
|
|
||||||
const data = loadCmsData();
|
|
||||||
const items = data.items[modelCode] || [];
|
|
||||||
data.items[modelCode] = items.filter((i) => i.id !== itemId);
|
|
||||||
saveCmsData(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetCmsData(): CmsStorageData {
|
|
||||||
const defaultData = getDefaultData();
|
|
||||||
saveCmsData(defaultData);
|
|
||||||
return defaultData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function exportCmsData(): string {
|
|
||||||
const data = loadCmsData();
|
|
||||||
return JSON.stringify(data, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function importCmsData(json: string): boolean {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(json) as CmsStorageData;
|
|
||||||
if (!data.version || !data.zones || !data.items) return false;
|
|
||||||
saveCmsData(data);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getContentZoneKeys(): string[] {
|
|
||||||
const data = loadCmsData();
|
|
||||||
return Object.keys(data.zones);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAvailableItemsForZone(zoneKey: string): ContentItem[] {
|
|
||||||
const zone = getContentZone(zoneKey);
|
|
||||||
if (!zone || zone.items.length === 0) return [];
|
|
||||||
const firstItem = zone.items.find((zi) => zi.item);
|
|
||||||
if (!firstItem?.item) return [];
|
|
||||||
const modelCode = firstItem.item.modelCode;
|
|
||||||
const allItems = getContentItems(modelCode);
|
|
||||||
const existingIds = new Set(
|
|
||||||
zone.items
|
|
||||||
.filter((zi) => zi.item)
|
|
||||||
.map((zi) => zi.item!.id)
|
|
||||||
);
|
|
||||||
return allItems.filter((item) => !existingIds.has(item.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addItemToZone(zoneKey: string, item: ContentItem): void {
|
|
||||||
const zone = getContentZone(zoneKey);
|
|
||||||
if (!zone) return;
|
|
||||||
zone.items.push({
|
|
||||||
item,
|
|
||||||
itemId: item.id,
|
|
||||||
sortOrder: zone.items.length,
|
|
||||||
variant: 'default',
|
|
||||||
});
|
|
||||||
saveContentZone(zoneKey, zone);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeItemFromZone(zoneKey: string, itemId: string): void {
|
|
||||||
const zone = getContentZone(zoneKey);
|
|
||||||
if (!zone) return;
|
|
||||||
zone.items = zone.items.filter((zi) => zi.item?.id !== itemId);
|
|
||||||
zone.items.forEach((zi, i) => { zi.sortOrder = i; });
|
|
||||||
saveContentZone(zoneKey, zone);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reorderZoneItems(zoneKey: string, fromIndex: number, toIndex: number): void {
|
|
||||||
const zone = getContentZone(zoneKey);
|
|
||||||
if (!zone) return;
|
|
||||||
const items = [...zone.items].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
|
||||||
const removed = items.splice(fromIndex, 1)[0];
|
|
||||||
if (!removed) return;
|
|
||||||
items.splice(toIndex, 0, removed);
|
|
||||||
items.forEach((item, i) => { item.sortOrder = i; });
|
|
||||||
zone.items = items;
|
|
||||||
saveContentZone(zoneKey, zone);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateZoneSettings(zoneKey: string, settings: Partial<ContentZoneSettings>): void {
|
|
||||||
const zone = getContentZone(zoneKey);
|
|
||||||
if (!zone) return;
|
|
||||||
zone.settings = { ...(zone.settings || {}), ...settings };
|
|
||||||
saveContentZone(zoneKey, zone);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,21 @@
|
|||||||
|
import type { CaseMetric, CaseTimelinePhase, CaseService, CaseTestimonial } from '@/lib/constants/cases';
|
||||||
|
|
||||||
|
export interface CaseStudyData {
|
||||||
|
client: string;
|
||||||
|
industry: string;
|
||||||
|
companySize: string;
|
||||||
|
subtitle: string;
|
||||||
|
challenge: string;
|
||||||
|
solution: string;
|
||||||
|
result: string;
|
||||||
|
metrics: CaseMetric[];
|
||||||
|
timeline: CaseTimelinePhase[];
|
||||||
|
services: CaseService[];
|
||||||
|
testimonial?: CaseTestimonial;
|
||||||
|
color: 'brand' | 'blue' | 'teal' | 'amber' | 'purple';
|
||||||
|
featured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type FieldType =
|
export type FieldType =
|
||||||
| 'text'
|
| 'text'
|
||||||
| 'textarea'
|
| 'textarea'
|
||||||
|
|||||||
Reference in New Issue
Block a user