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:
张翔
2026-07-07 11:44:56 +08:00
parent 1959f6455f
commit 55381d7012
34 changed files with 134 additions and 5995 deletions
-473
View File
@@ -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">
SlugURL
</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>
);
}
-36
View File
@@ -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 });
}
-207
View File
@@ -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>;
}
-93
View File
@@ -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>
);
}
-394
View File
@@ -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>
);
}
-8
View File
@@ -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';