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:
@@ -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';
|
||||
Reference in New Issue
Block a user