feat(cms): 添加 CMS 内容管理系统与 Admin 管理后台
- 新增 Prisma + SQLite 数据库模型 (Category, Content, Media, User 等) - 新增 Admin 管理后台 (认证、内容管理、媒体管理) - 新增 CMS API 路由 (CRUD, 草稿/发布, 重新验证) - 新增 CMS 内容版本的历史归档页面 - 新增 components/cms 内容渲染组件 - 新增 components/admin 管理后台 UI 组件 - 更新 Contact API 路由
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useAuth } from './auth-context';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
Briefcase,
|
||||
Box,
|
||||
Layers,
|
||||
Image,
|
||||
BarChart3,
|
||||
Newspaper,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
ChevronDown,
|
||||
Settings,
|
||||
Users,
|
||||
Phone,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: '内容管理',
|
||||
icon: FileText,
|
||||
children: [
|
||||
{ label: '新闻资讯', href: '/admin/content/news', icon: Newspaper },
|
||||
{ label: '案例研究', href: '/admin/content/case-study', icon: Briefcase },
|
||||
{ label: '服务管理', href: '/admin/content/service', icon: Settings },
|
||||
{ label: '产品管理', href: '/admin/content/product', icon: Box },
|
||||
{ label: '解决方案', href: '/admin/content/solution', icon: Layers },
|
||||
{ label: 'Hero Banner', href: '/admin/content/hero-banner', icon: Image },
|
||||
{ label: '数据指标', href: '/admin/content/stat-item', icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '页面管理',
|
||||
icon: FileText,
|
||||
children: [
|
||||
{ label: '关于我们', href: '/admin/content/about-page', icon: Users },
|
||||
{ label: '团队介绍', href: '/admin/content/team-page', icon: Users },
|
||||
{ label: '联系我们', href: '/admin/content/contact-page', icon: Phone },
|
||||
{ label: '法律页面', href: '/admin/content/legal-page', icon: Shield },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '页面配置',
|
||||
icon: Layers,
|
||||
children: [
|
||||
{ label: '首页区域', href: '/admin/zones', icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '资源管理',
|
||||
icon: Image,
|
||||
children: [
|
||||
{ label: '媒体库', href: '/admin/media', icon: Image },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set(['内容管理']));
|
||||
const { user, logout } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const toggleMenu = (label: string) => {
|
||||
setExpandedMenus((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label)) next.delete(label);
|
||||
else next.add(label);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push('/admin/login');
|
||||
};
|
||||
|
||||
const isActive = (href: string) => pathname === href || pathname?.startsWith(href + '/');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed top-0 left-0 z-50 h-full w-64 bg-white border-r border-gray-200 transform transition-transform duration-200 ease-in-out lg:translate-x-0 ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
|
||||
<Link href="/admin" className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold text-gray-900">Novalon CMS</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="lg:hidden p-1 rounded hover:bg-gray-100"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
|
||||
{/* Dashboard */}
|
||||
<Link
|
||||
href="/admin"
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
pathname === '/admin'
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
仪表盘
|
||||
</Link>
|
||||
|
||||
{/* Menu groups */}
|
||||
{menuItems.map((group) => (
|
||||
<div key={group.label}>
|
||||
<button
|
||||
onClick={() => toggleMenu(group.label)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<group.icon className="w-4 h-4" />
|
||||
{group.label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 transition-transform ${
|
||||
expandedMenus.has(group.label) ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{expandedMenus.has(group.label) && (
|
||||
<div className="ml-4 mt-1 space-y-1">
|
||||
{group.children.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
isActive(item.href)
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="w-3.5 h-3.5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User info */}
|
||||
<div className="border-t border-gray-200 p-3">
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-900 text-white flex items-center justify-center text-sm font-medium">
|
||||
{user?.nickname?.charAt(0) || 'A'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{user?.nickname || user?.username}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{user?.role === 'admin' ? '管理员' : '编辑'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-64">
|
||||
{/* Top bar */}
|
||||
<header className="sticky top-0 z-30 h-16 bg-white border-b border-gray-200 flex items-center justify-between px-4 lg:px-6">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="lg:hidden p-2 rounded hover:bg-gray-100"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
target="_blank"
|
||||
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
查看网站
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="p-4 lg:p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
role: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: AuthUser | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
token: null,
|
||||
loading: true,
|
||||
login: async () => {},
|
||||
logout: () => {},
|
||||
});
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const savedToken = localStorage.getItem('novalon_admin_token');
|
||||
const savedUser = localStorage.getItem('novalon_admin_user');
|
||||
if (savedToken && savedUser) {
|
||||
try {
|
||||
setToken(savedToken);
|
||||
setUser(JSON.parse(savedUser));
|
||||
} catch {
|
||||
localStorage.removeItem('novalon_admin_token');
|
||||
localStorage.removeItem('novalon_admin_user');
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || '登录失败');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setToken(data.accessToken);
|
||||
setUser(data.user);
|
||||
localStorage.setItem('novalon_admin_token', data.accessToken);
|
||||
localStorage.setItem('novalon_admin_user', JSON.stringify(data.user));
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
localStorage.removeItem('novalon_admin_token');
|
||||
localStorage.removeItem('novalon_admin_user');
|
||||
router.push('/admin/login');
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'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 });
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
'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>;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
Link as LinkIcon,
|
||||
Quote,
|
||||
Undo2,
|
||||
Redo2,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string;
|
||||
onChange: (html: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TipTap 富文本编辑器
|
||||
* 用于 CMS ContentEditor 的 richtext 字段
|
||||
* 支持:加粗、斜体、标题、列表、引用、链接
|
||||
* 暗色主题适配(CMS Studio 暗色背景)
|
||||
*/
|
||||
export function RichTextEditor({ value, onChange, placeholder = '请输入内容...' }: RichTextEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [2, 3],
|
||||
},
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: 'text-brand underline',
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
],
|
||||
content: value,
|
||||
immediatelyRender: false,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
onChange(e.getHTML());
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'rich-text-editor-content',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 同步外部 value 变化(如切换内容条目时)
|
||||
useEffect(() => {
|
||||
if (editor && value !== editor.getHTML()) {
|
||||
editor.commands.setContent(value || '');
|
||||
}
|
||||
}, [editor, value]);
|
||||
|
||||
if (!editor) {
|
||||
return (
|
||||
<div className="min-h-[120px] bg-white/[0.05] border border-white/[0.1] rounded-lg p-3 text-white/40 text-sm">
|
||||
加载编辑器...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rich-text-editor bg-white/[0.05] border border-white/[0.1] rounded-lg overflow-hidden focus-within:border-brand/50 focus-within:ring-1 focus-within:ring-brand/30 transition-colors">
|
||||
<Toolbar editor={editor} />
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toolbar({ editor }: { editor: Editor }) {
|
||||
const btnClass = (isActive: boolean) =>
|
||||
`p-1.5 rounded transition-colors ${
|
||||
isActive
|
||||
? 'bg-brand/20 text-brand'
|
||||
: 'text-white/60 hover:bg-white/[0.05] hover:text-white'
|
||||
}`;
|
||||
|
||||
const setLink = () => {
|
||||
const previousUrl = editor.getAttributes('link').href;
|
||||
const url = window.prompt('链接 URL', previousUrl);
|
||||
|
||||
if (url === null) return;
|
||||
if (url === '') {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 px-2 py-1.5 border-b border-white/[0.08] bg-white/[0.02]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={btnClass(editor.isActive('bold'))}
|
||||
title="加粗"
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
className={btnClass(editor.isActive('italic'))}
|
||||
title="斜体"
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-white/[0.08] mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
className={btnClass(editor.isActive('heading', { level: 2 }))}
|
||||
title="标题 2"
|
||||
>
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
className={btnClass(editor.isActive('heading', { level: 3 }))}
|
||||
title="标题 3"
|
||||
>
|
||||
<Heading3 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-white/[0.08] mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className={btnClass(editor.isActive('bulletList'))}
|
||||
title="无序列表"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={btnClass(editor.isActive('orderedList'))}
|
||||
title="有序列表"
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
className={btnClass(editor.isActive('blockquote'))}
|
||||
title="引用"
|
||||
>
|
||||
<Quote className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-white/[0.08] mx-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={setLink}
|
||||
className={btnClass(editor.isActive('link'))}
|
||||
title="链接"
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
className="p-1.5 rounded text-white/60 hover:bg-white/[0.05] hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="撤销"
|
||||
>
|
||||
<Undo2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
className="p-1.5 rounded text-white/60 hover:bg-white/[0.05] hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="重做"
|
||||
>
|
||||
<Redo2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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';
|
||||
@@ -0,0 +1,391 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
||||
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
|
||||
import { CheckCircle2, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface MethodologyLayer {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface MethodologyFrameworkProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
layers: MethodologyLayer[];
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export function MethodologyFramework({
|
||||
title,
|
||||
subtitle,
|
||||
layers,
|
||||
accentColor = '#C41E3A',
|
||||
}: MethodologyFrameworkProps) {
|
||||
return (
|
||||
<section className="relative py-20 sm:py-28 md:py-32 lg:py-40 overflow-hidden bg-bg-primary">
|
||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
||||
<ScrollReveal className="mb-16 sm:mb-20 max-w-3xl">
|
||||
<SectionLabel>Methodology</SectionLabel>
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-text-secondary leading-relaxed text-lg">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="relative">
|
||||
<StaggerReveal staggerDelay={0.1} delayChildren={0.05}>
|
||||
{layers.map((layer, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="relative group"
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ duration: 0.6, delay: index * 0.08, ease: EASE_OUT }}
|
||||
>
|
||||
<div className="flex gap-6 lg:gap-10">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center text-white font-bold text-lg shrink-0"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</div>
|
||||
{index < layers.length - 1 && (
|
||||
<div className="w-px flex-1 bg-gradient-to-b from-border-primary to-transparent mt-3" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 pb-12 lg:pb-16">
|
||||
<h3 className="text-xl lg:text-2xl font-bold text-text-primary mb-3">
|
||||
{layer.title}
|
||||
</h3>
|
||||
<p className="text-text-secondary leading-relaxed mb-5">
|
||||
{layer.description}
|
||||
</p>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
{layer.items.map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 shrink-0 mt-0.5" style={{ color: accentColor }} />
|
||||
<span className="text-text-secondary text-sm leading-relaxed">
|
||||
{item}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</StaggerReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TechStackCategory {
|
||||
name: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface TechStackShowcaseProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
categories: TechStackCategory[];
|
||||
}
|
||||
|
||||
export function TechStackShowcase({ title, subtitle, categories }: TechStackShowcaseProps) {
|
||||
return (
|
||||
<section className="relative py-20 sm:py-28 md:py-32 lg:py-40 overflow-hidden bg-bg-secondary">
|
||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
||||
<ScrollReveal className="mb-16 sm:mb-20 max-w-3xl">
|
||||
<SectionLabel>Tech Stack</SectionLabel>
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-text-secondary leading-relaxed text-lg">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<StaggerReveal staggerDelay={0.08} delayChildren={0.05}>
|
||||
{categories.map((category, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group relative p-8 border border-border-primary bg-bg-primary hover:border-border-secondary hover:bg-bg-secondary transition-all duration-500"
|
||||
>
|
||||
<div className="absolute top-0 left-0 h-1 w-0 group-hover:w-full transition-all duration-700 bg-brand" />
|
||||
<h3 className="text-lg font-bold text-text-primary mb-5">{category.name}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{category.items.map((item, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-bg-secondary text-text-secondary border border-border-primary hover:border-brand/30 hover:text-brand transition-colors duration-300"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</StaggerReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TimelineStep {
|
||||
phase: string;
|
||||
title: string;
|
||||
description: string;
|
||||
duration: string;
|
||||
deliverables: string[];
|
||||
}
|
||||
|
||||
export interface DeliveryTimelineProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
steps: TimelineStep[];
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export function DeliveryTimeline({
|
||||
title,
|
||||
subtitle,
|
||||
steps,
|
||||
accentColor = '#C41E3A',
|
||||
}: DeliveryTimelineProps) {
|
||||
return (
|
||||
<section className="relative py-20 sm:py-28 md:py-32 lg:py-40 overflow-hidden bg-bg-primary">
|
||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
||||
<ScrollReveal className="mb-16 sm:mb-20 max-w-3xl">
|
||||
<SectionLabel>Delivery Process</SectionLabel>
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-text-secondary leading-relaxed text-lg">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute left-8 lg:left-1/2 top-0 bottom-0 w-px bg-gradient-to-b from-brand/50 via-border-primary to-brand/50" />
|
||||
|
||||
<div className="space-y-12 lg:space-y-16">
|
||||
{steps.map((step, index) => (
|
||||
<ScrollReveal key={index} delay={index * 0.08}>
|
||||
<div className={cn(
|
||||
'relative flex gap-8 lg:gap-12',
|
||||
index % 2 === 0 ? 'lg:flex-row' : 'lg:flex-row-reverse'
|
||||
)}>
|
||||
<div className="hidden lg:block lg:w-1/2" />
|
||||
|
||||
<div className="absolute left-8 lg:left-1/2 -translate-x-1/2 w-4 h-4 rounded-full border-2 z-10"
|
||||
style={{ borderColor: accentColor, backgroundColor: '#ffffff' }}
|
||||
/>
|
||||
|
||||
<div className="lg:w-1/2 pl-16 lg:pl-0">
|
||||
<div className={cn(
|
||||
'p-8 lg:p-10 border border-border-primary bg-bg-secondary hover:border-border-secondary transition-all duration-500',
|
||||
index % 2 === 0 ? 'lg:ml-12' : 'lg:mr-12'
|
||||
)}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span
|
||||
className="text-xs font-bold tracking-[0.2em] uppercase"
|
||||
style={{ color: accentColor }}
|
||||
>
|
||||
{step.phase}
|
||||
</span>
|
||||
<span className="text-sm text-text-muted">
|
||||
{step.duration}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-text-primary mb-3">{step.title}</h3>
|
||||
<p className="text-text-secondary leading-relaxed mb-5">
|
||||
{step.description}
|
||||
</p>
|
||||
<div className="pt-5 border-t border-border-primary">
|
||||
<div className="text-xs text-text-muted mb-3 font-medium uppercase tracking-wider">
|
||||
交付物
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{step.deliverables.map((deliverable, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2.5 py-1 text-xs bg-bg-primary text-text-secondary border border-border-primary"
|
||||
>
|
||||
{deliverable}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FAQItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface FAQSectionProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
items: FAQItem[];
|
||||
}
|
||||
|
||||
export function FAQSection({ title, subtitle, items }: FAQSectionProps) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||
|
||||
return (
|
||||
<section className="relative py-20 sm:py-28 md:py-32 lg:py-40 overflow-hidden bg-bg-secondary">
|
||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
||||
<div className="grid lg:grid-cols-12 gap-12 lg:gap-20">
|
||||
<ScrollReveal className="lg:col-span-4">
|
||||
<SectionLabel>FAQ</SectionLabel>
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-text-secondary leading-relaxed text-lg">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="lg:col-span-8 space-y-3">
|
||||
<StaggerReveal staggerDelay={0.06} delayChildren={0.05}>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-border-primary bg-bg-primary hover:border-border-secondary transition-colors duration-300"
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpenIndex(openIndex === index ? null : index)}
|
||||
className="w-full flex items-center justify-between gap-6 p-6 lg:p-7 text-left"
|
||||
>
|
||||
<span className="text-text-primary font-semibold text-base lg:text-lg">
|
||||
{item.question}
|
||||
</span>
|
||||
<motion.div
|
||||
animate={{ rotate: openIndex === index ? 180 : 0 }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT }}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ChevronDown className="w-5 h-5 text-text-muted" />
|
||||
</motion.div>
|
||||
</button>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
height: openIndex === index ? 'auto' : 0,
|
||||
opacity: openIndex === index ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.4, ease: EASE_OUT }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-6 lg:px-7 pb-7 pt-1 text-text-secondary leading-relaxed">
|
||||
{item.answer}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
</StaggerReveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DataMetric {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
trend?: string;
|
||||
trendUp?: boolean;
|
||||
}
|
||||
|
||||
export interface DataProofSectionProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
metrics: DataMetric[];
|
||||
}
|
||||
|
||||
export function DataProofSection({ title, subtitle, metrics }: DataProofSectionProps) {
|
||||
return (
|
||||
<section className="relative py-20 sm:py-28 md:py-32 lg:py-40 overflow-hidden bg-bg-primary">
|
||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
||||
<ScrollReveal className="mb-16 sm:mb-20 max-w-3xl">
|
||||
<SectionLabel>Proven Results</SectionLabel>
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-text-secondary leading-relaxed text-lg">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-border-primary">
|
||||
<StaggerReveal staggerDelay={0.08} delayChildren={0.05}>
|
||||
{metrics.map((metric, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group relative p-10 lg:p-12 bg-bg-secondary hover:bg-bg-secondary transition-all duration-500"
|
||||
>
|
||||
<div className="absolute top-0 left-0 h-1 w-0 group-hover:w-full transition-all duration-700 bg-brand" />
|
||||
<div className="relative z-10">
|
||||
<div className="text-5xl lg:text-6xl font-bold tracking-tighter mb-4">
|
||||
<span className="text-text-primary">{metric.value}</span>
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-text-primary mb-2">
|
||||
{metric.label}
|
||||
</div>
|
||||
{metric.description && (
|
||||
<p className="text-sm text-text-muted leading-relaxed">
|
||||
{metric.description}
|
||||
</p>
|
||||
)}
|
||||
{metric.trend && (
|
||||
<div className={cn(
|
||||
'mt-4 inline-flex items-center gap-1.5 text-sm font-medium',
|
||||
metric.trendUp ? 'text-success' : 'text-brand'
|
||||
)}>
|
||||
<span>{metric.trend}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</StaggerReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ScrollReveal } from '@/components/ui/scroll-reveal';
|
||||
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
|
||||
import { Quote } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface Testimonial {
|
||||
quote: string;
|
||||
author: string;
|
||||
title: string;
|
||||
company: string;
|
||||
industry: string;
|
||||
}
|
||||
|
||||
export interface TestimonialSectionProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
testimonials: Testimonial[];
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export function TestimonialSection({
|
||||
title,
|
||||
subtitle,
|
||||
testimonials,
|
||||
accentColor = '#C41E3A',
|
||||
}: TestimonialSectionProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
return (
|
||||
<section className="relative py-20 sm:py-28 md:py-32 lg:py-40 overflow-hidden bg-bg-secondary">
|
||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
||||
<ScrollReveal className="mb-16 sm:mb-20 max-w-3xl">
|
||||
<SectionLabel>Client Stories</SectionLabel>
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-text-secondary leading-relaxed text-lg">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative overflow-hidden">
|
||||
<motion.div
|
||||
className="flex"
|
||||
animate={{ x: `-${activeIndex * 100}%` }}
|
||||
transition={{ duration: 0.6, ease: EASE_OUT }}
|
||||
>
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<div key={index} className="w-full shrink-0">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="relative p-10 lg:p-16 border border-border-primary bg-bg-primary">
|
||||
<Quote className="absolute top-8 left-8 w-12 h-12 opacity-10 text-brand" />
|
||||
<div className="relative z-10">
|
||||
<p className="text-xl lg:text-2xl text-text-primary leading-relaxed font-light mb-10 lg:mb-14">
|
||||
「{testimonial.quote}」
|
||||
</p>
|
||||
<div className="flex items-center gap-5">
|
||||
<div
|
||||
className="w-14 h-14 rounded-full flex items-center justify-center text-white font-bold text-lg"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
>
|
||||
{testimonial.author.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-text-primary font-semibold text-lg">
|
||||
{testimonial.author}
|
||||
</div>
|
||||
<div className="text-text-secondary text-sm">
|
||||
{testimonial.title} · {testimonial.company}
|
||||
</div>
|
||||
<div className="text-text-muted text-xs mt-0.5">
|
||||
{testimonial.industry}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-3 mt-10">
|
||||
{testimonials.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
className={cn(
|
||||
'h-2 rounded-full transition-all duration-500',
|
||||
activeIndex === index
|
||||
? 'w-8'
|
||||
: 'w-2 bg-border-primary hover:bg-border-secondary'
|
||||
)}
|
||||
style={activeIndex === index ? { backgroundColor: accentColor } : {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export interface LogoWallProps {
|
||||
title: string;
|
||||
logos: { name: string; industry?: string }[];
|
||||
}
|
||||
|
||||
export function LogoWall({ title, logos }: LogoWallProps) {
|
||||
return (
|
||||
<section className="relative py-16 sm:py-20 overflow-hidden bg-bg-primary">
|
||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
||||
<ScrollReveal>
|
||||
<div className="text-center mb-12">
|
||||
<p className="text-sm text-text-muted tracking-[0.2em] uppercase font-medium">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-px bg-border-primary">
|
||||
{logos.map((logo, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.05, ease: EASE_OUT }}
|
||||
className="flex items-center justify-center p-8 bg-bg-primary hover:bg-bg-secondary transition-colors duration-300"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-text-secondary font-semibold text-base hover:text-text-primary transition-colors duration-300">
|
||||
{logo.name}
|
||||
</div>
|
||||
{logo.industry && (
|
||||
<div className="text-text-muted text-xs mt-1">
|
||||
{logo.industry}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user