feat(assets): 更新 Logo 与图片资源,添加数据层服务模块
- 更新 logo.svg/logo-light.svg/logo-white.svg 品牌标识 - 新增 logo-calligraphy.svg 书法体 Logo 变体 - 新增新闻、二维码、微信业务等图片资源 - 删除旧版 JPEG 测试图片 - 新增 CMS 数据层服务模块 (admin-api, auth, cms, crypto, db) - 新增 cases/cross-references/methodology/team 常量数据 - 新增 site-config 站点配置
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { getItemRenderer } from './component-registry';
|
||||
import type { ContentZone } from './types';
|
||||
|
||||
interface ContentZoneRendererProps {
|
||||
zone: ContentZone;
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
}
|
||||
|
||||
export function ContentZoneRenderer({ zone, className = '', itemClassName = '' }: ContentZoneRendererProps) {
|
||||
const items = zone.items
|
||||
.slice()
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
.filter((zi) => zi.item);
|
||||
|
||||
const settings = zone.settings || {};
|
||||
const layout = settings.layout || 'grid';
|
||||
const columns = settings.columns || 3;
|
||||
const gap = settings.gap || 'medium';
|
||||
|
||||
const gapClasses: Record<string, string> = {
|
||||
small: 'gap-3',
|
||||
medium: 'gap-6',
|
||||
large: 'gap-8',
|
||||
};
|
||||
|
||||
const gridColsClass = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
||||
5: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5',
|
||||
6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-6',
|
||||
}[columns as number] || 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3';
|
||||
|
||||
if (layout === 'list') {
|
||||
return (
|
||||
<div className={`flex flex-col ${gapClasses[gap as string] || 'gap-6'} ${className}`}>
|
||||
{items.map((zoneItem, i) => {
|
||||
const item = zoneItem.item!;
|
||||
const Renderer = getItemRenderer(item.modelCode);
|
||||
if (!Renderer) {
|
||||
return (
|
||||
<div key={item.id} className={`p-4 border border-dashed border-white/20 rounded-lg ${itemClassName}`}>
|
||||
<p className="text-sm text-white/50">未知内容类型: {item.modelCode}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Renderer
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={i}
|
||||
variant={zoneItem.variant}
|
||||
className={itemClassName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (layout === 'carousel') {
|
||||
return (
|
||||
<div className={`overflow-x-auto ${className}`}>
|
||||
<div className={`flex gap-6 pb-4 ${itemClassName}`} style={{ scrollSnapType: 'x mandatory' }}>
|
||||
{items.map((zoneItem, i) => {
|
||||
const item = zoneItem.item!;
|
||||
const Renderer = getItemRenderer(item.modelCode);
|
||||
if (!Renderer) return null;
|
||||
return (
|
||||
<div key={item.id} className="flex-shrink-0 w-80" style={{ scrollSnapAlign: 'start' }}>
|
||||
<Renderer item={item} index={i} variant={zoneItem.variant} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid ${gridColsClass} ${gapClasses[gap as string] || 'gap-6'} ${className}`}>
|
||||
{items.map((zoneItem, i) => {
|
||||
const item = zoneItem.item!;
|
||||
const Renderer = getItemRenderer(item.modelCode);
|
||||
if (!Renderer) {
|
||||
return (
|
||||
<div key={item.id} className={`p-4 border border-dashed border-white/20 rounded-lg ${itemClassName}`}>
|
||||
<p className="text-sm text-white/50">未知内容类型: {item.modelCode}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Renderer
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={i}
|
||||
variant={zoneItem.variant}
|
||||
className={itemClassName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import type {
|
||||
ContentModel,
|
||||
ContentItem,
|
||||
ContentQueryParams,
|
||||
PaginatedResult,
|
||||
ContentZone,
|
||||
ThemeConfig,
|
||||
} from './types';
|
||||
import {
|
||||
getMockContentModel,
|
||||
getMockItems,
|
||||
getMockItemBySlug,
|
||||
getMockItemById,
|
||||
contentModels,
|
||||
allMockItems,
|
||||
} from './mock-data';
|
||||
|
||||
export interface CmsClientConfig {
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
timeout?: number;
|
||||
mockMode?: boolean;
|
||||
}
|
||||
|
||||
export class CmsClient {
|
||||
private config: Required<CmsClientConfig>;
|
||||
|
||||
constructor(config: CmsClientConfig) {
|
||||
this.config = {
|
||||
baseUrl: config.baseUrl,
|
||||
apiKey: config.apiKey ?? '',
|
||||
timeout: config.timeout ?? 10000,
|
||||
mockMode: config.mockMode ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
private async request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (this.config.apiKey) {
|
||||
headers['Authorization'] = `Bearer ${this.config.apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`CMS API Error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async getModels(): Promise<ContentModel[]> {
|
||||
if (this.config.mockMode) {
|
||||
return Object.values(contentModels);
|
||||
}
|
||||
return this.request<ContentModel[]>('/api/models');
|
||||
}
|
||||
|
||||
async getModel(code: string): Promise<ContentModel> {
|
||||
if (this.config.mockMode) {
|
||||
const model = getMockContentModel(code);
|
||||
if (!model) throw new Error(`Content model not found: ${code}`);
|
||||
return model;
|
||||
}
|
||||
return this.request<ContentModel>(`/api/models/${code}`);
|
||||
}
|
||||
|
||||
async getItems(params: ContentQueryParams): Promise<PaginatedResult<ContentItem>> {
|
||||
if (this.config.mockMode) {
|
||||
// 使用统一 mock 数据源(覆盖所有已注册模型)
|
||||
let items = params.modelCode ? [...getMockItems(params.modelCode)] : [];
|
||||
|
||||
if (params.status) {
|
||||
items = items.filter((item) => item.status === params.status);
|
||||
}
|
||||
|
||||
if (params.search) {
|
||||
const searchLower = params.search.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(searchLower) ||
|
||||
(item.slug?.toLowerCase().includes(searchLower) ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
const page = params.page ?? 1;
|
||||
const pageSize = params.pageSize ?? 10;
|
||||
const total = items.length;
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const start = (page - 1) * pageSize;
|
||||
const paginatedItems = items.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
items: paginatedItems,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('modelCode', params.modelCode);
|
||||
if (params.status) queryParams.set('status', params.status);
|
||||
if (params.page) queryParams.set('page', String(params.page));
|
||||
if (params.pageSize) queryParams.set('pageSize', String(params.pageSize));
|
||||
if (params.sortBy) queryParams.set('sortBy', params.sortBy);
|
||||
if (params.sortOrder) queryParams.set('sortOrder', params.sortOrder);
|
||||
if (params.search) queryParams.set('search', params.search);
|
||||
if (params.filters) {
|
||||
Object.entries(params.filters).forEach(([key, value]) => {
|
||||
queryParams.set(`filters[${key}]`, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
return this.request<PaginatedResult<ContentItem>>(
|
||||
`/api/items?${queryParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getItem(id: string): Promise<ContentItem> {
|
||||
if (this.config.mockMode) {
|
||||
// 跨所有模型查找
|
||||
for (const modelCode of Object.keys(allMockItems)) {
|
||||
const item = getMockItemById(modelCode, id);
|
||||
if (item) return item;
|
||||
}
|
||||
throw new Error(`Content item not found: ${id}`);
|
||||
}
|
||||
return this.request<ContentItem>(`/api/items/${id}`);
|
||||
}
|
||||
|
||||
async getItemBySlug(modelCode: string, slug: string): Promise<ContentItem> {
|
||||
if (this.config.mockMode) {
|
||||
const item = getMockItemBySlug(modelCode, slug);
|
||||
if (!item) throw new Error(`Content item not found: ${modelCode}/${slug}`);
|
||||
return item;
|
||||
}
|
||||
return this.request<ContentItem>(`/api/items/slug/${modelCode}/${slug}`);
|
||||
}
|
||||
|
||||
async getZone(code: string): Promise<ContentZone> {
|
||||
if (this.config.mockMode) {
|
||||
return {
|
||||
id: `zone-${code}`,
|
||||
code,
|
||||
name: code,
|
||||
allowedModels: ['case-study'],
|
||||
items: getMockItems('case-study').slice(0, 3).map((item, index) => ({
|
||||
itemId: item.id,
|
||||
item,
|
||||
sortOrder: index,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return this.request<ContentZone>(`/api/zones/${code}`);
|
||||
}
|
||||
|
||||
async getZonesByPage(pageCode: string): Promise<ContentZone[]> {
|
||||
if (this.config.mockMode) {
|
||||
return [
|
||||
{
|
||||
id: `zone-${pageCode}-featured`,
|
||||
code: `${pageCode}-featured`,
|
||||
name: `${pageCode} 精选区域`,
|
||||
pageCode,
|
||||
allowedModels: ['case-study'],
|
||||
items: getMockItems('case-study')
|
||||
.filter((item) => (item.data as { featured?: boolean }).featured)
|
||||
.map((item, index) => ({
|
||||
itemId: item.id,
|
||||
item,
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
return this.request<ContentZone[]>(`/api/zones/page/${pageCode}`);
|
||||
}
|
||||
|
||||
async getThemeConfig(): Promise<ThemeConfig> {
|
||||
if (this.config.mockMode) {
|
||||
return {
|
||||
id: 'theme-default',
|
||||
siteName: 'Novalon',
|
||||
siteDescription: '企业数字化转型合作伙伴',
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
secondary: '#64748b',
|
||||
accent: '#0891b2',
|
||||
background: '#ffffff',
|
||||
text: '#0f172a',
|
||||
},
|
||||
seo: {
|
||||
defaultTitle: 'Novalon - 企业数字化转型合作伙伴',
|
||||
defaultDescription: '专注企业数字化转型,提供从战略咨询到技术落地的全链路服务',
|
||||
defaultKeywords: '数字化转型,ERP,数据中台,全渠道零售',
|
||||
},
|
||||
social: {
|
||||
wechat: 'novalon',
|
||||
linkedin: 'novalon',
|
||||
github: 'novalon',
|
||||
},
|
||||
settings: {},
|
||||
};
|
||||
}
|
||||
return this.request<ThemeConfig>('/api/theme/config');
|
||||
}
|
||||
|
||||
getTypedItem<T>(item: ContentItem): T {
|
||||
return {
|
||||
id: item.id,
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
...item.data,
|
||||
} as unknown as T;
|
||||
}
|
||||
}
|
||||
|
||||
export const cmsClient = new CmsClient({
|
||||
baseUrl: '',
|
||||
mockMode: true,
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ContentItem, ContentZone } from './types';
|
||||
|
||||
export interface ContentItemComponentProps {
|
||||
item: ContentItem;
|
||||
index?: number;
|
||||
variant?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface ContentZoneComponentProps {
|
||||
zone: ContentZone;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export type ContentItemRenderer = ComponentType<ContentItemComponentProps>;
|
||||
export type ContentZoneRenderer = ComponentType<ContentZoneComponentProps>;
|
||||
|
||||
interface ComponentRegistry {
|
||||
itemRenderers: Map<string, ContentItemRenderer>;
|
||||
zoneRenderers: Map<string, ContentZoneRenderer>;
|
||||
}
|
||||
|
||||
const registry: ComponentRegistry = {
|
||||
itemRenderers: new Map(),
|
||||
zoneRenderers: new Map(),
|
||||
};
|
||||
|
||||
export function registerItemRenderer(
|
||||
contentType: string,
|
||||
renderer: ContentItemRenderer,
|
||||
): void {
|
||||
registry.itemRenderers.set(contentType, renderer);
|
||||
}
|
||||
|
||||
export function registerZoneRenderer(
|
||||
zoneType: string,
|
||||
renderer: ContentZoneRenderer,
|
||||
): void {
|
||||
registry.zoneRenderers.set(zoneType, renderer);
|
||||
}
|
||||
|
||||
export function getItemRenderer(contentType: string): ContentItemRenderer | undefined {
|
||||
return registry.itemRenderers.get(contentType);
|
||||
}
|
||||
|
||||
export function getZoneRenderer(zoneType: string): ContentZoneRenderer | undefined {
|
||||
return registry.zoneRenderers.get(zoneType);
|
||||
}
|
||||
|
||||
export function hasItemRenderer(contentType: string): boolean {
|
||||
return registry.itemRenderers.has(contentType);
|
||||
}
|
||||
|
||||
export function hasZoneRenderer(zoneType: string): boolean {
|
||||
return registry.zoneRenderers.has(zoneType);
|
||||
}
|
||||
|
||||
export function getAllRegisteredItemTypes(): string[] {
|
||||
return Array.from(registry.itemRenderers.keys());
|
||||
}
|
||||
|
||||
export function getAllRegisteredZoneTypes(): string[] {
|
||||
return Array.from(registry.zoneRenderers.keys());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* CMS 服务端数据访问层
|
||||
* 从 Prisma 数据库直接读取已发布内容,供页面组件使用
|
||||
*/
|
||||
import { prisma } from '@/lib/db';
|
||||
import { cache } from 'react';
|
||||
import type { ContentItem, ContentStatus } from './types';
|
||||
|
||||
// ============ 类型转换 ============
|
||||
|
||||
function toContentItem(item: Record<string, unknown>): ContentItem {
|
||||
return {
|
||||
id: item.id as string,
|
||||
modelId: item.modelId as string,
|
||||
modelCode: item.modelCode as string,
|
||||
title: item.title as string,
|
||||
slug: (item.slug as string | null) ?? undefined,
|
||||
status: item.status as ContentStatus,
|
||||
data: JSON.parse(item.data as string),
|
||||
version: item.version as number,
|
||||
sortOrder: (item.sortOrder as number | null) ?? undefined,
|
||||
publishedAt: (item.publishedAt as Date | null)?.toISOString(),
|
||||
createdBy: item.createdBy as string,
|
||||
updatedBy: item.updatedBy as string,
|
||||
createdAt: (item.createdAt as Date).toISOString(),
|
||||
updatedAt: (item.updatedAt as Date).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 内容条目查询 ============
|
||||
|
||||
/**
|
||||
* 获取指定模型的已发布内容列表
|
||||
*/
|
||||
export const getPublishedItems = cache(async (modelCode: string): Promise<ContentItem[]> => {
|
||||
const items = await prisma.contentItem.findMany({
|
||||
where: { modelCode, status: 'published' },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
return items.map((item) => toContentItem(item as unknown as Record<string, unknown>));
|
||||
});
|
||||
|
||||
/**
|
||||
* 根据 slug 获取单条已发布内容
|
||||
*/
|
||||
export const getPublishedItemBySlug = cache(async (modelCode: string, slug: string): Promise<ContentItem | null> => {
|
||||
const item = await prisma.contentItem.findFirst({
|
||||
where: { modelCode, slug, status: 'published' },
|
||||
});
|
||||
if (!item) return null;
|
||||
return toContentItem(item as unknown as Record<string, unknown>);
|
||||
});
|
||||
|
||||
/**
|
||||
* 根据 ID 获取单条内容(不限状态)
|
||||
*/
|
||||
export const getItemById = cache(async (id: string): Promise<ContentItem | null> => {
|
||||
const item = await prisma.contentItem.findUnique({ where: { id } });
|
||||
if (!item) return null;
|
||||
return toContentItem(item as unknown as Record<string, unknown>);
|
||||
});
|
||||
|
||||
// ============ 内容区域查询 ============
|
||||
|
||||
/**
|
||||
* 获取指定页面的内容区域配置
|
||||
*/
|
||||
export const getPageZones = cache(async (pageCode: string) => {
|
||||
const zones = await prisma.contentZone.findMany({
|
||||
where: { pageCode },
|
||||
});
|
||||
return zones.map((z) => ({
|
||||
...z,
|
||||
allowedModels: JSON.parse(z.allowedModels),
|
||||
items: JSON.parse(z.items),
|
||||
settings: JSON.parse(z.settings),
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取单个内容区域
|
||||
*/
|
||||
export const getZone = cache(async (code: string) => {
|
||||
const zone = await prisma.contentZone.findUnique({ where: { code } });
|
||||
if (!zone) return null;
|
||||
return {
|
||||
...zone,
|
||||
allowedModels: JSON.parse(zone.allowedModels),
|
||||
items: JSON.parse(zone.items),
|
||||
settings: JSON.parse(zone.settings),
|
||||
};
|
||||
});
|
||||
|
||||
// ============ 内容模型查询 ============
|
||||
|
||||
/**
|
||||
* 获取所有内容模型
|
||||
*/
|
||||
export const getContentModels = cache(async () => {
|
||||
const models = await prisma.contentModel.findMany({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
return models.map((m) => ({
|
||||
...m,
|
||||
fields: JSON.parse(m.fields),
|
||||
}));
|
||||
});
|
||||
|
||||
// ============ 便捷函数:按业务类型获取 ============
|
||||
|
||||
export async function getCases() {
|
||||
return getPublishedItems('case-study');
|
||||
}
|
||||
|
||||
export async function getNews() {
|
||||
return getPublishedItems('news');
|
||||
}
|
||||
|
||||
export async function getServices() {
|
||||
return getPublishedItems('service');
|
||||
}
|
||||
|
||||
export async function getProducts() {
|
||||
return getPublishedItems('product');
|
||||
}
|
||||
|
||||
export async function getSolutions() {
|
||||
return getPublishedItems('solution');
|
||||
}
|
||||
|
||||
export async function getStats() {
|
||||
return getPublishedItems('stat-item');
|
||||
}
|
||||
|
||||
export async function getHeroBanners() {
|
||||
return getPublishedItems('hero-banner');
|
||||
}
|
||||
|
||||
export async function getCaseBySlug(slug: string) {
|
||||
return getPublishedItemBySlug('case-study', slug);
|
||||
}
|
||||
|
||||
export async function getNewsBySlug(slug: string) {
|
||||
return getPublishedItemBySlug('news', slug);
|
||||
}
|
||||
|
||||
export async function getServiceBySlug(slug: string) {
|
||||
return getPublishedItemBySlug('service', slug);
|
||||
}
|
||||
|
||||
export async function getProductBySlug(slug: string) {
|
||||
return getPublishedItemBySlug('product', slug);
|
||||
}
|
||||
|
||||
export async function getSolutionBySlug(slug: string) {
|
||||
return getPublishedItemBySlug('solution', slug);
|
||||
}
|
||||
|
||||
export async function getHomePageZones() {
|
||||
return getPageZones('home');
|
||||
}
|
||||
|
||||
// ============ 静态生成辅助 ============
|
||||
|
||||
/**
|
||||
* 获取所有已发布内容的 slug 列表(用于 generateStaticParams)
|
||||
*/
|
||||
export async function getAllPublishedSlugs(modelCode: string) {
|
||||
const items = await prisma.contentItem.findMany({
|
||||
where: { modelCode, status: 'published' },
|
||||
select: { slug: true },
|
||||
});
|
||||
return items.filter((i) => i.slug).map((i) => ({ slug: i.slug }));
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export {
|
||||
type FieldType,
|
||||
type FieldDefinition,
|
||||
type ContentModel,
|
||||
type ContentStatus,
|
||||
type ContentItem,
|
||||
type ContentZoneItem,
|
||||
type ContentZone,
|
||||
type ContentZoneSettings,
|
||||
type ThemeConfig,
|
||||
type MediaAsset,
|
||||
type ContentQueryParams,
|
||||
type PaginatedResult,
|
||||
type AuditLog,
|
||||
} from './types';
|
||||
|
||||
export { cmsClient } from './client';
|
||||
export type { CmsClient, CmsClientConfig } from './client';
|
||||
|
||||
export { getMockContentModel, getMockCaseStudies, getMockCaseStudyBySlug } from './mock-data';
|
||||
export type { CaseStudyData } from './mock-data';
|
||||
|
||||
export {
|
||||
registerContentType,
|
||||
getContentTypeConfig,
|
||||
getAllContentTypeConfigs,
|
||||
getContentModel,
|
||||
getAllContentModels,
|
||||
} from './registry';
|
||||
export type { ContentTypeConfig } from './registry';
|
||||
|
||||
export { registerAllContentTypes, CONTENT_TYPE_CONFIGS } from './content-types';
|
||||
|
||||
export {
|
||||
registerItemRenderer,
|
||||
registerZoneRenderer,
|
||||
getItemRenderer,
|
||||
getZoneRenderer,
|
||||
hasItemRenderer,
|
||||
hasZoneRenderer,
|
||||
getAllRegisteredItemTypes,
|
||||
getAllRegisteredZoneTypes,
|
||||
} from './component-registry';
|
||||
export type {
|
||||
ContentItemComponentProps,
|
||||
ContentZoneComponentProps,
|
||||
ContentItemRenderer,
|
||||
ContentZoneRenderer as ContentZoneRendererType,
|
||||
} from './component-registry';
|
||||
|
||||
export {
|
||||
NewsCardRenderer,
|
||||
StatCardRenderer,
|
||||
ServiceCardRenderer,
|
||||
ProductCardRenderer,
|
||||
SolutionCardRenderer,
|
||||
HeroBannerRenderer,
|
||||
} from './renderers';
|
||||
|
||||
export { ContentZoneRenderer } from './ContentZoneRenderer';
|
||||
|
||||
export {
|
||||
loadCmsData,
|
||||
saveCmsData,
|
||||
getContentZone,
|
||||
saveContentZone,
|
||||
getAllContentZones,
|
||||
getContentItems,
|
||||
saveContentItem,
|
||||
deleteContentItem,
|
||||
resetCmsData,
|
||||
exportCmsData,
|
||||
importCmsData,
|
||||
getContentZoneKeys,
|
||||
getAvailableItemsForZone,
|
||||
addItemToZone,
|
||||
removeItemFromZone,
|
||||
reorderZoneItems,
|
||||
updateZoneSettings,
|
||||
} from './storage';
|
||||
|
||||
export { initCms, isCmsInitialized } from './init';
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
registerItemRenderer,
|
||||
} from './component-registry';
|
||||
import {
|
||||
NewsCardRenderer,
|
||||
StatCardRenderer,
|
||||
ServiceCardRenderer,
|
||||
ProductCardRenderer,
|
||||
SolutionCardRenderer,
|
||||
HeroBannerRenderer,
|
||||
} from './renderers';
|
||||
import { registerAllContentTypes } from './content-types';
|
||||
import type { ContentItem } from './types';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export function initCms(): void {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
registerAllContentTypes();
|
||||
|
||||
registerItemRenderer('news', NewsCardRenderer);
|
||||
registerItemRenderer('case-study', NewsCardRenderer as unknown as React.ComponentType<{ item: ContentItem; index?: number; variant?: string; className?: string }>);
|
||||
registerItemRenderer('stat-item', StatCardRenderer);
|
||||
registerItemRenderer('service', ServiceCardRenderer);
|
||||
registerItemRenderer('product', ProductCardRenderer);
|
||||
registerItemRenderer('solution', SolutionCardRenderer);
|
||||
registerItemRenderer('hero-banner', HeroBannerRenderer);
|
||||
}
|
||||
|
||||
export function isCmsInitialized(): boolean {
|
||||
return initialized;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { ContentModel, ContentItem } from './types';
|
||||
import {
|
||||
CASE_STUDIES,
|
||||
type CaseStudy,
|
||||
type CaseMetric,
|
||||
type CaseTimelinePhase,
|
||||
type CaseService,
|
||||
type CaseTestimonial,
|
||||
} from '../constants/cases';
|
||||
import { NEWS } from '../constants/news';
|
||||
import { SERVICES } from '../constants/services';
|
||||
import { PRODUCTS } from '../constants/products';
|
||||
import { SOLUTIONS } from '../constants/solutions';
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
export interface CaseStudyData {
|
||||
client: string;
|
||||
industry: string;
|
||||
companySize: string;
|
||||
subtitle: string;
|
||||
challenge: string;
|
||||
solution: string;
|
||||
result: string;
|
||||
metrics: CaseMetric[];
|
||||
timeline: CaseTimelinePhase[];
|
||||
services: CaseService[];
|
||||
testimonial?: CaseTestimonial;
|
||||
color: 'brand' | 'blue' | 'teal' | 'amber' | 'purple';
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
// ============ 内容模型定义 ============
|
||||
|
||||
const caseStudyModel: ContentModel = {
|
||||
id: 'model-case-study',
|
||||
code: 'case-study',
|
||||
name: '案例研究',
|
||||
description: '客户成功案例与实践分享',
|
||||
isPageType: true,
|
||||
urlPattern: '/cases/{slug}',
|
||||
hasVersions: true,
|
||||
hasDraft: true,
|
||||
icon: 'briefcase',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
fields: [
|
||||
{ name: 'client', type: 'text', label: '客户名称', required: true, description: '客户公司或机构名称' },
|
||||
{ name: 'industry', type: 'text', label: '所属行业', required: true, description: '客户所属行业领域' },
|
||||
{ name: 'companySize', type: 'text', label: '公司规模', required: true, description: '客户公司人员规模' },
|
||||
{ name: 'subtitle', type: 'text', label: '副标题', required: true, description: '案例副标题,简要概括项目价值' },
|
||||
{ name: 'challenge', type: 'textarea', label: '挑战', required: true, description: '客户面临的业务挑战与痛点' },
|
||||
{ name: 'solution', type: 'textarea', label: '解决方案', required: true, description: '我们提供的解决方案与实施路径' },
|
||||
{ name: 'result', type: 'textarea', label: '实施成果', required: true, description: '项目交付后的业务成果与价值' },
|
||||
{
|
||||
name: 'metrics',
|
||||
type: 'array',
|
||||
label: '关键指标',
|
||||
required: false,
|
||||
description: '项目关键量化指标',
|
||||
fields: [
|
||||
{ name: 'value', type: 'text', label: '数值', required: true },
|
||||
{ name: 'label', type: 'text', label: '标签', required: true },
|
||||
{ name: 'highlight', type: 'boolean', label: '高亮', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'timeline',
|
||||
type: 'array',
|
||||
label: '项目时间线',
|
||||
required: false,
|
||||
description: '项目各阶段时间与描述',
|
||||
fields: [
|
||||
{ name: 'phase', type: 'text', label: '阶段', required: true },
|
||||
{ name: 'duration', type: 'text', label: '周期', required: true },
|
||||
{ name: 'description', type: 'textarea', label: '描述', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'services',
|
||||
type: 'array',
|
||||
label: '相关服务',
|
||||
required: false,
|
||||
description: '案例涉及的服务项目',
|
||||
fields: [
|
||||
{ name: 'id', type: 'text', label: '服务ID', required: true },
|
||||
{ name: 'title', type: 'text', label: '服务名称', required: true },
|
||||
{ name: 'description', type: 'textarea', label: '服务描述', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'testimonial',
|
||||
type: 'object',
|
||||
label: '客户评价',
|
||||
required: false,
|
||||
description: '客户证言与评价',
|
||||
fields: [
|
||||
{ name: 'quote', type: 'textarea', label: '评价内容', required: true },
|
||||
{ name: 'author', type: 'text', label: '评价人', required: true },
|
||||
{ name: 'role', type: 'text', label: '职位', required: true },
|
||||
{ name: 'company', type: 'text', label: '公司', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'color',
|
||||
type: 'text',
|
||||
label: '主题色',
|
||||
required: true,
|
||||
description: '案例展示主题色',
|
||||
options: [
|
||||
{ label: '品牌色', value: 'brand' },
|
||||
{ label: '蓝色', value: 'blue' },
|
||||
{ label: '青色', value: 'teal' },
|
||||
{ label: '琥珀色', value: 'amber' },
|
||||
{ label: '紫色', value: 'purple' },
|
||||
],
|
||||
defaultValue: 'brand',
|
||||
},
|
||||
{
|
||||
name: 'featured',
|
||||
type: 'boolean',
|
||||
label: '精选案例',
|
||||
required: false,
|
||||
description: '是否在首页/推荐位展示',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ============ 通用转换函数 ============
|
||||
|
||||
/**
|
||||
* 将业务数据记录转换为 CMS ContentItem
|
||||
* @param source 原始数据记录
|
||||
* @param modelCode 内容模型代码
|
||||
* @param modelId 内容模型 ID
|
||||
* @param index 索引(用于排序)
|
||||
* @param options 配置选项(slug 字段名、title 字段名)
|
||||
*/
|
||||
function toContentItem<T extends Record<string, unknown>>(
|
||||
source: T,
|
||||
modelCode: string,
|
||||
modelId: string,
|
||||
index: number,
|
||||
options: { slugField?: string; titleField?: string } = {},
|
||||
): ContentItem {
|
||||
const slugField = options.slugField || 'id';
|
||||
const titleField = options.titleField || 'title';
|
||||
return {
|
||||
id: String(source.id || `item-${modelCode}-${index}`),
|
||||
modelId,
|
||||
modelCode,
|
||||
title: String(source[titleField] || `未命名 ${index + 1}`),
|
||||
slug: String(source[slugField] || source.id || `item-${index}`),
|
||||
status: 'published',
|
||||
version: 1,
|
||||
sortOrder: index,
|
||||
data: source,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-06-01T00:00:00Z',
|
||||
publishedAt: '2024-06-01T00:00:00Z',
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system',
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Mock 数据生成 ============
|
||||
|
||||
function caseStudyToContentItem(cs: CaseStudy): ContentItem {
|
||||
const { id, slug, title, ...data } = cs;
|
||||
return {
|
||||
id,
|
||||
modelId: 'model-case-study',
|
||||
modelCode: 'case-study',
|
||||
title,
|
||||
slug,
|
||||
status: 'published',
|
||||
data: data as Record<string, unknown>,
|
||||
version: 1,
|
||||
publishedAt: '2024-06-01T00:00:00Z',
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-06-01T00:00:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
const caseStudyItems: ContentItem[] = CASE_STUDIES.map(caseStudyToContentItem);
|
||||
const newsItems: ContentItem[] = NEWS.map((n, i) =>
|
||||
toContentItem(n as unknown as Record<string, unknown>, 'news', 'model-news', i),
|
||||
);
|
||||
const serviceItems: ContentItem[] = SERVICES.map((s, i) =>
|
||||
toContentItem(s as unknown as Record<string, unknown>, 'service', 'model-service', i),
|
||||
);
|
||||
const productItems: ContentItem[] = PRODUCTS.map((p, i) =>
|
||||
toContentItem(p as unknown as Record<string, unknown>, 'product', 'model-product', i),
|
||||
);
|
||||
const solutionItems: ContentItem[] = SOLUTIONS.map((s, i) =>
|
||||
toContentItem(s as unknown as Record<string, unknown>, 'solution', 'model-solution', i),
|
||||
);
|
||||
|
||||
// ============ 模型注册表 ============
|
||||
|
||||
const contentModels: Record<string, ContentModel> = {
|
||||
'case-study': caseStudyModel,
|
||||
};
|
||||
|
||||
// ============ 所有 Mock 数据集合 ============
|
||||
|
||||
/**
|
||||
* 所有模型的 Mock 数据集合
|
||||
* 用于 cmsClient mock 模式和 storage 默认数据初始化
|
||||
*/
|
||||
export const allMockItems: Record<string, ContentItem[]> = {
|
||||
'case-study': caseStudyItems,
|
||||
news: newsItems,
|
||||
service: serviceItems,
|
||||
product: productItems,
|
||||
solution: solutionItems,
|
||||
};
|
||||
|
||||
// ============ 同步访问函数(server-safe) ============
|
||||
|
||||
/**
|
||||
* 获取指定模型的所有 Mock 数据
|
||||
* 同步函数,server-safe,兼容静态导出(generateStaticParams)
|
||||
*/
|
||||
export function getMockItems(modelCode: string): ContentItem[] {
|
||||
return allMockItems[modelCode] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 slug 获取单条 Mock 数据
|
||||
* 同步函数,server-safe
|
||||
*/
|
||||
export function getMockItemBySlug(modelCode: string, slug: string): ContentItem | undefined {
|
||||
return getMockItems(modelCode).find((item) => item.slug === slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 获取单条 Mock 数据
|
||||
* 同步函数,server-safe
|
||||
*/
|
||||
export function getMockItemById(modelCode: string, id: string): ContentItem | undefined {
|
||||
return getMockItems(modelCode).find((item) => item.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的内容模型
|
||||
*/
|
||||
export function getMockContentModel(code: string): ContentModel | undefined {
|
||||
return contentModels[code];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有内容模型列表
|
||||
*/
|
||||
export function getAllMockContentModels(): ContentModel[] {
|
||||
return Object.values(contentModels);
|
||||
}
|
||||
|
||||
// ============ 兼容旧接口(保留导出,避免破坏性变更) ============
|
||||
|
||||
export function getMockCaseStudies(): ContentItem[] {
|
||||
return caseStudyItems;
|
||||
}
|
||||
|
||||
export function getMockCaseStudyBySlug(slug: string): ContentItem | undefined {
|
||||
return caseStudyItems.find((item) => item.slug === slug);
|
||||
}
|
||||
|
||||
export { contentModels, caseStudyItems };
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { ContentZone, ContentItem } from './types';
|
||||
import { STATS } from '@/lib/constants/stats';
|
||||
import { SERVICES } from '@/lib/constants/services';
|
||||
import { getFeaturedCases } from '@/lib/constants/cases';
|
||||
import { NEWS } from '@/lib/constants/news';
|
||||
import { SOLUTIONS } from '@/lib/constants/solutions';
|
||||
|
||||
function createBaseItem(id: string, modelCode: string, title: string, slug: string, index: number): Omit<ContentItem, 'data'> {
|
||||
return {
|
||||
id,
|
||||
modelId: modelCode,
|
||||
modelCode,
|
||||
title,
|
||||
slug,
|
||||
status: 'published',
|
||||
version: 1,
|
||||
sortOrder: index,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
publishedAt: '2024-01-01T00:00:00Z',
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system',
|
||||
};
|
||||
}
|
||||
|
||||
function statToContentItem(stat: Record<string, unknown>, index: number): ContentItem {
|
||||
return {
|
||||
...createBaseItem(`stat-${index}`, 'stat-item', String(stat.label || ''), `stat-${index}`, index),
|
||||
data: {
|
||||
icon: stat.icon,
|
||||
label: stat.label,
|
||||
value: stat.value,
|
||||
suffix: stat.suffix,
|
||||
prefix: stat.prefix || '',
|
||||
decimals: stat.decimals || 0,
|
||||
description: stat.description || '',
|
||||
trendValue: stat.trend || '',
|
||||
trendDirection: typeof stat.trend === 'string' && stat.trend.startsWith('+') ? 'up' : 'down',
|
||||
accentColor: stat.color || 'brand',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function serviceToContentItem(service: { id: string; title: string; description: string; icon?: string; overview?: string; features?: unknown[]; benefits?: unknown[] }, index: number): ContentItem {
|
||||
return {
|
||||
...createBaseItem(service.id, 'service', service.title, service.id, index),
|
||||
data: {
|
||||
description: service.description,
|
||||
icon: service.icon,
|
||||
overview: service.overview,
|
||||
features: service.features || [],
|
||||
benefits: service.benefits || [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function caseStudyToContentItem(cs: { id: string; title: string; slug: string; client?: string; industry?: string; subtitle?: string; challenge?: string; result?: string; metrics?: unknown[]; color?: string; featured?: boolean }, index: number): ContentItem {
|
||||
return {
|
||||
...createBaseItem(cs.id, 'case-study', cs.title, cs.slug, index),
|
||||
data: {
|
||||
client: cs.client,
|
||||
industry: cs.industry,
|
||||
subtitle: cs.subtitle,
|
||||
challenge: cs.challenge,
|
||||
result: cs.result,
|
||||
metrics: cs.metrics || [],
|
||||
color: cs.color,
|
||||
featured: cs.featured,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function newsToContentItem(news: { id: string; title: string; excerpt?: string; date?: string; category?: string; image?: string; content?: string }, index: number): ContentItem {
|
||||
return {
|
||||
...createBaseItem(news.id, 'news', news.title, news.id, index),
|
||||
data: {
|
||||
excerpt: news.excerpt,
|
||||
date: news.date,
|
||||
category: news.category,
|
||||
image: news.image,
|
||||
content: news.content,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function solutionToContentItem(solution: { id: string; title: string; description: string; icon?: string; industry?: string; overview?: string }, index: number): ContentItem {
|
||||
return {
|
||||
...createBaseItem(solution.id, 'solution', solution.title, solution.id, index),
|
||||
data: {
|
||||
description: solution.description,
|
||||
icon: solution.icon,
|
||||
industry: solution.industry || '',
|
||||
overview: solution.overview || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockHomeHeroBanner(): ContentItem {
|
||||
return {
|
||||
...createBaseItem('hero-home-main', 'hero-banner', '企业数字化转型的可靠伙伴', 'hero-home-main', 0),
|
||||
data: {
|
||||
heading: '企业数字化转型的可靠伙伴',
|
||||
subheading: '深耕企业数字化 10+ 年',
|
||||
description: '从战略规划到落地实施,为成长型企业提供端到端的数字化解决方案。以务实的态度、专业的能力、长期的陪伴,帮助企业实现可持续增长。',
|
||||
primaryCtaText: '了解解决方案',
|
||||
primaryCtaLink: '/solutions',
|
||||
secondaryCtaText: '联系我们',
|
||||
secondaryCtaLink: '/contact',
|
||||
theme: 'brand',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockHomeStatsZone(): ContentZone {
|
||||
const statList = Array.isArray(STATS) ? STATS : (STATS as { stats?: unknown[] }).stats || [];
|
||||
const items = statList.slice(0, 4).map((s, i) => statToContentItem(s as Record<string, unknown>, i));
|
||||
return {
|
||||
id: 'zone-home-stats',
|
||||
code: 'home-stats',
|
||||
name: '首页数据指标区',
|
||||
zoneKey: 'home-stats',
|
||||
pageCode: 'home',
|
||||
allowedModels: ['stat-item'],
|
||||
items: items.map((item, i) => ({
|
||||
item,
|
||||
itemId: item.id,
|
||||
sortOrder: i,
|
||||
variant: 'default',
|
||||
})),
|
||||
settings: {
|
||||
layout: 'grid',
|
||||
columns: 4,
|
||||
gap: 'large',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockHomeServicesZone(): ContentZone {
|
||||
const items = SERVICES.slice(0, 4).map((s, i) => serviceToContentItem(s, i));
|
||||
return {
|
||||
id: 'zone-home-services',
|
||||
code: 'home-services',
|
||||
name: '首页服务区',
|
||||
zoneKey: 'home-services',
|
||||
pageCode: 'home',
|
||||
allowedModels: ['service'],
|
||||
items: items.map((item, i) => ({
|
||||
item,
|
||||
itemId: item.id,
|
||||
sortOrder: i,
|
||||
variant: 'default',
|
||||
})),
|
||||
settings: {
|
||||
layout: 'grid',
|
||||
columns: 4,
|
||||
gap: 'large',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockHomeCasesZone(): ContentZone {
|
||||
const items = getFeaturedCases().slice(0, 3).map((cs, i) => caseStudyToContentItem(cs, i));
|
||||
return {
|
||||
id: 'zone-home-cases',
|
||||
code: 'home-cases',
|
||||
name: '首页案例区',
|
||||
zoneKey: 'home-cases',
|
||||
pageCode: 'home',
|
||||
allowedModels: ['case-study'],
|
||||
items: items.map((item, i) => ({
|
||||
item,
|
||||
itemId: item.id,
|
||||
sortOrder: i,
|
||||
variant: 'default',
|
||||
})),
|
||||
settings: {
|
||||
layout: 'grid',
|
||||
columns: 3,
|
||||
gap: 'large',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockHomeNewsZone(): ContentZone {
|
||||
const items = NEWS.slice(0, 3).map((n, i) => newsToContentItem(n, i));
|
||||
return {
|
||||
id: 'zone-home-news',
|
||||
code: 'home-news',
|
||||
name: '首页新闻区',
|
||||
zoneKey: 'home-news',
|
||||
pageCode: 'home',
|
||||
allowedModels: ['news'],
|
||||
items: items.map((item, i) => ({
|
||||
item,
|
||||
itemId: item.id,
|
||||
sortOrder: i,
|
||||
variant: 'default',
|
||||
})),
|
||||
settings: {
|
||||
layout: 'grid',
|
||||
columns: 3,
|
||||
gap: 'large',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockHomeSolutionsZone(): ContentZone {
|
||||
const items = SOLUTIONS.slice(0, 4).map((s, i) => solutionToContentItem(s, i));
|
||||
return {
|
||||
id: 'zone-home-solutions',
|
||||
code: 'home-solutions',
|
||||
name: '首页方案区',
|
||||
zoneKey: 'home-solutions',
|
||||
pageCode: 'home',
|
||||
allowedModels: ['solution'],
|
||||
items: items.map((item, i) => ({
|
||||
item,
|
||||
itemId: item.id,
|
||||
sortOrder: i,
|
||||
variant: 'default',
|
||||
})),
|
||||
settings: {
|
||||
layout: 'grid',
|
||||
columns: 4,
|
||||
gap: 'large',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockContentZone(zoneKey: string, _page = 'home'): ContentZone | null {
|
||||
const zones: Record<string, ContentZone> = {
|
||||
'home-stats': getMockHomeStatsZone(),
|
||||
'home-services': getMockHomeServicesZone(),
|
||||
'home-cases': getMockHomeCasesZone(),
|
||||
'home-news': getMockHomeNewsZone(),
|
||||
'home-solutions': getMockHomeSolutionsZone(),
|
||||
};
|
||||
return zones[zoneKey] || null;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { ContentModel, FieldDefinition, ContentItem } from './types';
|
||||
import { getMockCaseStudies, getMockCaseStudyBySlug } from './mock-data';
|
||||
|
||||
export interface ContentTypeConfig {
|
||||
model: ContentModel;
|
||||
mockData?: {
|
||||
list: () => Promise<{ items: ContentItem[]; total: number }>;
|
||||
getBySlug?: (slug: string) => Promise<ContentItem | null>;
|
||||
getById?: (id: string) => Promise<ContentItem | null>;
|
||||
};
|
||||
listPage?: {
|
||||
route: string;
|
||||
};
|
||||
detailPage?: {
|
||||
routePattern: string;
|
||||
};
|
||||
}
|
||||
|
||||
const contentTypeRegistry = new Map<string, ContentTypeConfig>();
|
||||
|
||||
export function registerContentType(
|
||||
config: ContentTypeConfig
|
||||
): void {
|
||||
contentTypeRegistry.set(config.model.code, config);
|
||||
}
|
||||
|
||||
export function getContentTypeConfig(code: string): ContentTypeConfig | undefined {
|
||||
return contentTypeRegistry.get(code);
|
||||
}
|
||||
|
||||
export function getAllContentTypeConfigs(): ContentTypeConfig[] {
|
||||
return Array.from(contentTypeRegistry.values());
|
||||
}
|
||||
|
||||
export function getContentModel(code: string): ContentModel | undefined {
|
||||
return contentTypeRegistry.get(code)?.model;
|
||||
}
|
||||
|
||||
export function getAllContentModels(): ContentModel[] {
|
||||
return getAllContentTypeConfigs().map((c) => c.model);
|
||||
}
|
||||
|
||||
const caseStudyFields: FieldDefinition[] = [
|
||||
{
|
||||
name: 'client',
|
||||
type: 'text',
|
||||
label: '客户名称',
|
||||
required: true,
|
||||
description: '客户公司或机构名称',
|
||||
},
|
||||
{
|
||||
name: 'industry',
|
||||
type: 'text',
|
||||
label: '所属行业',
|
||||
required: true,
|
||||
description: '客户所属行业领域',
|
||||
},
|
||||
{
|
||||
name: 'companySize',
|
||||
type: 'text',
|
||||
label: '公司规模',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'subtitle',
|
||||
type: 'text',
|
||||
label: '副标题',
|
||||
required: true,
|
||||
description: '案例副标题,简要概括项目价值',
|
||||
},
|
||||
{
|
||||
name: 'challenge',
|
||||
type: 'textarea',
|
||||
label: '挑战',
|
||||
required: true,
|
||||
description: '客户面临的业务挑战与痛点',
|
||||
},
|
||||
{
|
||||
name: 'solution',
|
||||
type: 'textarea',
|
||||
label: '方案',
|
||||
required: true,
|
||||
description: '我们提供的解决方案',
|
||||
},
|
||||
{
|
||||
name: 'result',
|
||||
type: 'textarea',
|
||||
label: '成果',
|
||||
required: true,
|
||||
description: '项目成果与价值',
|
||||
},
|
||||
{
|
||||
name: 'metrics',
|
||||
type: 'array',
|
||||
label: '核心指标',
|
||||
description: '可量化的项目成果指标',
|
||||
fields: [
|
||||
{ name: 'value', type: 'text', label: '数值', required: true },
|
||||
{ name: 'label', type: 'text', label: '指标名称', required: true },
|
||||
{ name: 'highlight', type: 'boolean', label: '突出显示', defaultValue: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'timeline',
|
||||
type: 'array',
|
||||
label: '项目时间线',
|
||||
fields: [
|
||||
{ name: 'phase', type: 'text', label: '阶段名称', required: true },
|
||||
{ name: 'duration', type: 'text', label: '持续时间', required: true },
|
||||
{ name: 'description', type: 'textarea', label: '阶段描述' },
|
||||
{ name: 'deliverables', type: 'array', label: '交付物', fields: [{ name: 'name', type: 'text', label: '名称' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'services',
|
||||
type: 'array',
|
||||
label: '相关服务',
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', label: '服务名称', required: true },
|
||||
{ name: 'description', type: 'textarea', label: '服务描述' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'testimonial',
|
||||
type: 'object',
|
||||
label: '客户证言',
|
||||
fields: [
|
||||
{ name: 'quote', type: 'textarea', label: '证言内容' },
|
||||
{ name: 'author', type: 'text', label: '发言人' },
|
||||
{ name: 'role', type: 'text', label: '职位' },
|
||||
{ name: 'company', type: 'text', label: '公司' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'color',
|
||||
type: 'text',
|
||||
label: '主题色',
|
||||
defaultValue: 'brand',
|
||||
options: [
|
||||
{ label: '品牌绿', value: 'brand' },
|
||||
{ label: '蓝', value: 'blue' },
|
||||
{ label: '青', value: 'teal' },
|
||||
{ label: '琥珀', value: 'amber' },
|
||||
{ label: '紫', value: 'purple' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'featured',
|
||||
type: 'boolean',
|
||||
label: '精选案例',
|
||||
defaultValue: false,
|
||||
description: '是否在首页精选区展示',
|
||||
},
|
||||
];
|
||||
|
||||
registerContentType({
|
||||
model: {
|
||||
id: 'model-case-study',
|
||||
code: 'case-study',
|
||||
name: '案例研究',
|
||||
description: '客户成功案例与数字化转型实践',
|
||||
fields: caseStudyFields,
|
||||
isPageType: true,
|
||||
urlPattern: '/cases/{slug}',
|
||||
hasVersions: true,
|
||||
hasDraft: true,
|
||||
icon: 'briefcase',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
mockData: {
|
||||
list: async () => {
|
||||
const items = getMockCaseStudies();
|
||||
return { items, total: items.length };
|
||||
},
|
||||
getBySlug: async (slug: string) => getMockCaseStudyBySlug(slug) ?? null,
|
||||
},
|
||||
listPage: {
|
||||
route: '/cases',
|
||||
},
|
||||
detailPage: {
|
||||
routePattern: '/cases/{slug}',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
'use client';
|
||||
|
||||
import type { ContentItemComponentProps } from './component-registry';
|
||||
|
||||
function getField(item: Record<string, unknown>, key: string, fallback = ''): string {
|
||||
const value = item[key];
|
||||
if (value === null || value === undefined) return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function NewsCardRenderer({ item, variant = 'default', className = '' }: ContentItemComponentProps) {
|
||||
const data = item.data as Record<string, unknown>;
|
||||
const title = getField(data, 'title') || item.title;
|
||||
const excerpt = getField(data, 'excerpt');
|
||||
const date = getField(data, 'date');
|
||||
const category = getField(data, 'category');
|
||||
const image = getField(data, 'image');
|
||||
const link = `/news/${item.slug}`;
|
||||
|
||||
if (variant === 'featured') {
|
||||
return (
|
||||
<a href={link} className={`group block relative overflow-hidden rounded-2xl bg-white/[0.03] border border-white/[0.08] hover:border-white/[0.15] transition-all duration-500 ${className}`}>
|
||||
<div className="aspect-[16/9] overflow-hidden">
|
||||
{image && (
|
||||
<img src={image} alt={title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-brand/20 text-brand rounded">{category}</span>
|
||||
<span className="text-xs text-white/40">{date}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-brand transition-colors line-clamp-2">{title}</h3>
|
||||
<p className="mt-2 text-sm text-white/50 line-clamp-3">{excerpt}</p>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={link} className={`group block ${className}`}>
|
||||
<article className="relative overflow-hidden rounded-xl bg-white/[0.03] border border-white/[0.08] hover:border-white/[0.15] transition-all duration-300">
|
||||
{image && (
|
||||
<div className="aspect-[16/9] overflow-hidden">
|
||||
<img src={image} alt={title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium bg-brand/20 text-brand rounded">{category}</span>
|
||||
<span className="text-[10px] text-white/40">{date}</span>
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-white group-hover:text-brand transition-colors line-clamp-2">{title}</h4>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatCardRenderer({ item, className = '' }: ContentItemComponentProps) {
|
||||
const data = item.data as Record<string, unknown>;
|
||||
const label = getField(data, 'label') || item.title;
|
||||
const value = data.value ?? 0;
|
||||
const prefix = getField(data, 'prefix');
|
||||
const suffix = getField(data, 'suffix');
|
||||
const decimals = (data.decimals as number) ?? 0;
|
||||
const description = getField(data, 'description');
|
||||
const trendValue = getField(data, 'trendValue');
|
||||
const trendDirection = getField(data, 'trendDirection', 'up');
|
||||
const accentColor = getField(data, 'accentColor', 'brand');
|
||||
const icon = getField(data, 'icon');
|
||||
|
||||
const formattedValue = typeof value === 'number'
|
||||
? `${prefix}${value.toLocaleString('zh-CN', { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}${suffix}`
|
||||
: `${prefix}${value}${suffix}`;
|
||||
|
||||
const accentClasses: Record<string, string> = {
|
||||
brand: 'from-brand/20 to-brand/0 text-brand',
|
||||
blue: 'from-blue-500/20 to-blue-500/0 text-blue-400',
|
||||
teal: 'from-teal-500/20 to-teal-500/0 text-teal-400',
|
||||
amber: 'from-amber-500/20 to-amber-500/0 text-amber-400',
|
||||
purple: 'from-purple-500/20 to-purple-500/0 text-purple-400',
|
||||
};
|
||||
|
||||
const trendClasses: Record<string, string> = {
|
||||
up: 'text-emerald-400',
|
||||
down: 'text-red-400',
|
||||
neutral: 'text-white/50',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-2xl p-6 md:p-8 bg-white/[0.03] border border-white/[0.08] hover:border-white/[0.15] transition-all duration-300 ${className}`}>
|
||||
<div className={`absolute inset-0 bg-gradient-to-br ${accentClasses[accentColor] || accentClasses.brand} opacity-50`} />
|
||||
<div className="relative z-10">
|
||||
{icon && <div className="text-3xl mb-4 opacity-80">{icon}</div>}
|
||||
<div className="text-3xl md:text-4xl font-bold text-white tracking-tight">{formattedValue}</div>
|
||||
<div className="mt-2 text-sm text-white/60 font-medium">{label}</div>
|
||||
{description && <div className="mt-3 text-xs text-white/40 leading-relaxed">{description}</div>}
|
||||
{trendValue && (
|
||||
<div className={`mt-4 inline-flex items-center gap-1 text-xs font-medium ${trendClasses[trendDirection] || trendClasses.up}`}>
|
||||
{trendDirection === 'up' && '↑'}
|
||||
{trendDirection === 'down' && '↓'}
|
||||
{trendDirection === 'neutral' && '→'}
|
||||
<span>{trendValue}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServiceCardRenderer({ item, className = '' }: ContentItemComponentProps) {
|
||||
const data = item.data as Record<string, unknown>;
|
||||
const title = getField(data, 'title') || item.title;
|
||||
const description = getField(data, 'description');
|
||||
const icon = getField(data, 'icon');
|
||||
const link = `/services/${item.slug}`;
|
||||
|
||||
return (
|
||||
<a href={link} className={`group block relative overflow-hidden rounded-2xl p-8 bg-white/[0.03] border border-white/[0.08] hover:border-brand/50 transition-all duration-500 hover:-translate-y-1 ${className}`}>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-brand/[0.08] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="relative z-10">
|
||||
{icon && (
|
||||
<div className="w-12 h-12 flex items-center justify-center rounded-xl bg-brand/20 text-brand text-2xl mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-xl font-semibold text-white group-hover:text-brand transition-colors">{title}</h3>
|
||||
<p className="mt-3 text-sm text-white/50 leading-relaxed line-clamp-3">{description}</p>
|
||||
<div className="mt-6 inline-flex items-center gap-2 text-sm text-brand font-medium group-hover:gap-3 transition-all">
|
||||
了解详情
|
||||
<span>→</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductCardRenderer({ item, className = '' }: ContentItemComponentProps) {
|
||||
const data = item.data as Record<string, unknown>;
|
||||
const title = getField(data, 'title') || item.title;
|
||||
const description = getField(data, 'description');
|
||||
const image = getField(data, 'image');
|
||||
const category = getField(data, 'category');
|
||||
const status = getField(data, 'status');
|
||||
const link = `/products/${item.slug}`;
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
'研发中': 'bg-amber-500/20 text-amber-400',
|
||||
'内测中': 'bg-blue-500/20 text-blue-400',
|
||||
'已发布': 'bg-emerald-500/20 text-emerald-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<a href={link} className={`group block relative overflow-hidden rounded-2xl bg-white/[0.03] border border-white/[0.08] hover:border-white/[0.15] transition-all duration-500 ${className}`}>
|
||||
{image && (
|
||||
<div className="aspect-[4/3] overflow-hidden relative">
|
||||
<img src={image} alt={title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" />
|
||||
{status && (
|
||||
<span className={`absolute top-4 right-4 px-3 py-1 text-xs font-medium rounded-full ${statusColors[status] || 'bg-white/10 text-white/70'}`}>
|
||||
{status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
{category && <div className="text-xs text-brand font-medium mb-2">{category}</div>}
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-brand transition-colors line-clamp-2">{title}</h3>
|
||||
<p className="mt-2 text-sm text-white/50 line-clamp-3">{description}</p>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function SolutionCardRenderer({ item, className = '' }: ContentItemComponentProps) {
|
||||
const data = item.data as Record<string, unknown>;
|
||||
const title = getField(data, 'title') || item.title;
|
||||
const description = getField(data, 'description');
|
||||
const icon = getField(data, 'icon');
|
||||
const industry = getField(data, 'industry');
|
||||
const link = `/solutions/${item.slug}`;
|
||||
|
||||
return (
|
||||
<a href={link} className={`group block relative overflow-hidden rounded-2xl p-8 bg-gradient-to-br from-white/[0.04] to-white/[0.01] border border-white/[0.08] hover:border-brand/40 transition-all duration-500 ${className}`}>
|
||||
<div className="relative z-10">
|
||||
{icon && (
|
||||
<div className="w-14 h-14 flex items-center justify-center rounded-2xl bg-gradient-to-br from-brand/30 to-brand/10 text-brand text-2xl mb-6">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
{industry && <div className="text-xs text-white/40 font-medium mb-2">{industry}</div>}
|
||||
<h3 className="text-xl font-semibold text-white group-hover:text-brand transition-colors">{title}</h3>
|
||||
<p className="mt-3 text-sm text-white/50 leading-relaxed line-clamp-3">{description}</p>
|
||||
<div className="mt-6 inline-flex items-center gap-2 text-sm text-brand font-medium group-hover:gap-3 transition-all">
|
||||
查看方案
|
||||
<span>→</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroBannerRenderer({ item, className = '' }: ContentItemComponentProps) {
|
||||
const data = item.data as Record<string, unknown>;
|
||||
const heading = getField(data, 'heading') || item.title;
|
||||
const subheading = getField(data, 'subheading');
|
||||
const description = getField(data, 'description');
|
||||
const primaryCtaText = getField(data, 'primaryCtaText', '了解更多');
|
||||
const primaryCtaLink = getField(data, 'primaryCtaLink', '/solutions');
|
||||
const secondaryCtaText = getField(data, 'secondaryCtaText', '联系我们');
|
||||
const secondaryCtaLink = getField(data, 'secondaryCtaLink', '/contact');
|
||||
const theme = getField(data, 'theme', 'brand');
|
||||
|
||||
const themeGradients: Record<string, string> = {
|
||||
brand: 'from-brand/30 via-brand/10 to-transparent',
|
||||
blue: 'from-blue-500/30 via-blue-500/10 to-transparent',
|
||||
teal: 'from-teal-500/30 via-teal-500/10 to-transparent',
|
||||
amber: 'from-amber-500/30 via-amber-500/10 to-transparent',
|
||||
purple: 'from-purple-500/30 via-purple-500/10 to-transparent',
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={`relative min-h-[80vh] flex items-center overflow-hidden bg-ink ${className}`}>
|
||||
<div className={`absolute inset-0 bg-gradient-radial ${themeGradients[theme] || themeGradients.brand} opacity-60`} />
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute top-1/4 -left-32 w-96 h-96 rounded-full bg-brand/20 blur-[120px]" />
|
||||
<div className="absolute bottom-1/4 -right-32 w-96 h-96 rounded-full bg-blue-500/10 blur-[120px]" />
|
||||
</div>
|
||||
<div className="relative z-10 w-full max-w-7xl mx-auto px-6 lg:px-8 py-32">
|
||||
<div className="max-w-4xl">
|
||||
{subheading && (
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/[0.05] border border-white/[0.1] text-sm text-white/70 mb-8">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand animate-pulse" />
|
||||
{subheading}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold text-white tracking-tight leading-[1.1]">
|
||||
{heading}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="mt-8 text-lg md:text-xl text-white/60 max-w-2xl leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-12 flex flex-wrap gap-4">
|
||||
<a
|
||||
href={primaryCtaLink}
|
||||
className="inline-flex items-center gap-2 px-8 py-4 bg-brand hover:bg-brand-hover text-white font-medium rounded-xl transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-brand/20"
|
||||
>
|
||||
{primaryCtaText}
|
||||
<span>→</span>
|
||||
</a>
|
||||
<a
|
||||
href={secondaryCtaLink}
|
||||
className="inline-flex items-center gap-2 px-8 py-4 bg-white/[0.05] hover:bg-white/[0.1] text-white font-medium rounded-xl border border-white/[0.1] hover:border-white/[0.2] transition-all duration-300"
|
||||
>
|
||||
{secondaryCtaText}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import type { ContentZone, ContentItem, ContentZoneSettings } from './types';
|
||||
import {
|
||||
getMockHomeStatsZone,
|
||||
getMockHomeServicesZone,
|
||||
getMockHomeCasesZone,
|
||||
getMockHomeNewsZone,
|
||||
getMockHomeSolutionsZone,
|
||||
getMockHomeHeroBanner,
|
||||
} from './mock-home';
|
||||
import { NEWS } from '@/lib/constants/news';
|
||||
import { SERVICES } from '@/lib/constants/services';
|
||||
import { PRODUCTS } from '@/lib/constants/products';
|
||||
import { SOLUTIONS } from '@/lib/constants/solutions';
|
||||
import { getFeaturedCases } from '@/lib/constants/cases';
|
||||
import { STATS } from '@/lib/constants/stats';
|
||||
|
||||
const STORAGE_KEY = 'novalon-cms-data';
|
||||
const VERSION = '1.0.0';
|
||||
|
||||
interface CmsStorageData {
|
||||
version: string;
|
||||
zones: Record<string, ContentZone>;
|
||||
items: Record<string, ContentItem[]>;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function getDefaultZones(): Record<string, ContentZone> {
|
||||
return {
|
||||
'home-stats': getMockHomeStatsZone(),
|
||||
'home-services': getMockHomeServicesZone(),
|
||||
'home-solutions': getMockHomeSolutionsZone(),
|
||||
'home-cases': getMockHomeCasesZone(),
|
||||
'home-news': getMockHomeNewsZone(),
|
||||
};
|
||||
}
|
||||
|
||||
function toContentItem(source: Record<string, unknown>, modelCode: string, index: number): ContentItem {
|
||||
return {
|
||||
id: String(source.id || `item-${index}`),
|
||||
modelId: modelCode,
|
||||
modelCode,
|
||||
title: String(source.title || source.label || source.heading || `未命名 ${index + 1}`),
|
||||
slug: String(source.slug || source.id || `item-${index}`),
|
||||
status: 'published',
|
||||
version: 1,
|
||||
sortOrder: index,
|
||||
data: source,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
publishedAt: '2024-01-01T00:00:00Z',
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system',
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultItems(): Record<string, ContentItem[]> {
|
||||
const statList = Array.isArray(STATS) ? STATS : (STATS as { stats?: unknown[] }).stats || [];
|
||||
return {
|
||||
'case-study': getFeaturedCases().map((cs, i) => toContentItem(cs as unknown as Record<string, unknown>, 'case-study', i)),
|
||||
news: NEWS.map((n, i) => toContentItem(n as unknown as Record<string, unknown>, 'news', i)),
|
||||
service: SERVICES.map((s, i) => toContentItem(s as unknown as Record<string, unknown>, 'service', i)),
|
||||
product: PRODUCTS.map((p, i) => toContentItem(p as unknown as Record<string, unknown>, 'product', i)),
|
||||
solution: SOLUTIONS.map((s, i) => toContentItem(s as unknown as Record<string, unknown>, 'solution', i)),
|
||||
'hero-banner': [toContentItem(getMockHomeHeroBanner().data, 'hero-banner', 0)],
|
||||
'stat-item': statList.map((s, i) => toContentItem({ ...(s as Record<string, unknown>), accentColor: (s as Record<string, unknown>).color }, 'stat-item', i)),
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultData(): CmsStorageData {
|
||||
return {
|
||||
version: VERSION,
|
||||
zones: getDefaultZones(),
|
||||
items: getDefaultItems(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== 'undefined';
|
||||
}
|
||||
|
||||
export function loadCmsData(): CmsStorageData {
|
||||
if (!isBrowser()) {
|
||||
return getDefaultData();
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
const defaultData = getDefaultData();
|
||||
saveCmsData(defaultData);
|
||||
return defaultData;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as CmsStorageData;
|
||||
if (parsed.version !== VERSION) {
|
||||
const defaultData = getDefaultData();
|
||||
saveCmsData(defaultData);
|
||||
return defaultData;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return getDefaultData();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCmsData(data: CmsStorageData): void {
|
||||
if (!isBrowser()) return;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ ...data, updatedAt: new Date().toISOString() })
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent('cms-updated'));
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
export function getContentZone(zoneKey: string): ContentZone | null {
|
||||
const data = loadCmsData();
|
||||
return data.zones[zoneKey] || null;
|
||||
}
|
||||
|
||||
export function saveContentZone(zoneKey: string, zone: ContentZone): void {
|
||||
const data = loadCmsData();
|
||||
data.zones[zoneKey] = zone;
|
||||
saveCmsData(data);
|
||||
}
|
||||
|
||||
export function getAllContentZones(): Record<string, ContentZone> {
|
||||
return loadCmsData().zones;
|
||||
}
|
||||
|
||||
export function getContentItems(modelCode: string): ContentItem[] {
|
||||
const data = loadCmsData();
|
||||
return data.items[modelCode] || [];
|
||||
}
|
||||
|
||||
export function saveContentItem(modelCode: string, item: ContentItem): void {
|
||||
const data = loadCmsData();
|
||||
const items = data.items[modelCode] || [];
|
||||
const idx = items.findIndex((i) => i.id === item.id);
|
||||
if (idx >= 0) {
|
||||
items[idx] = item;
|
||||
} else {
|
||||
items.push(item);
|
||||
}
|
||||
data.items[modelCode] = items;
|
||||
saveCmsData(data);
|
||||
}
|
||||
|
||||
export function deleteContentItem(modelCode: string, itemId: string): void {
|
||||
const data = loadCmsData();
|
||||
const items = data.items[modelCode] || [];
|
||||
data.items[modelCode] = items.filter((i) => i.id !== itemId);
|
||||
saveCmsData(data);
|
||||
}
|
||||
|
||||
export function resetCmsData(): CmsStorageData {
|
||||
const defaultData = getDefaultData();
|
||||
saveCmsData(defaultData);
|
||||
return defaultData;
|
||||
}
|
||||
|
||||
export function exportCmsData(): string {
|
||||
const data = loadCmsData();
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
export function importCmsData(json: string): boolean {
|
||||
try {
|
||||
const data = JSON.parse(json) as CmsStorageData;
|
||||
if (!data.version || !data.zones || !data.items) return false;
|
||||
saveCmsData(data);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getContentZoneKeys(): string[] {
|
||||
const data = loadCmsData();
|
||||
return Object.keys(data.zones);
|
||||
}
|
||||
|
||||
export function getAvailableItemsForZone(zoneKey: string): ContentItem[] {
|
||||
const zone = getContentZone(zoneKey);
|
||||
if (!zone || zone.items.length === 0) return [];
|
||||
const firstItem = zone.items.find((zi) => zi.item);
|
||||
if (!firstItem?.item) return [];
|
||||
const modelCode = firstItem.item.modelCode;
|
||||
const allItems = getContentItems(modelCode);
|
||||
const existingIds = new Set(
|
||||
zone.items
|
||||
.filter((zi) => zi.item)
|
||||
.map((zi) => zi.item!.id)
|
||||
);
|
||||
return allItems.filter((item) => !existingIds.has(item.id));
|
||||
}
|
||||
|
||||
export function addItemToZone(zoneKey: string, item: ContentItem): void {
|
||||
const zone = getContentZone(zoneKey);
|
||||
if (!zone) return;
|
||||
zone.items.push({
|
||||
item,
|
||||
itemId: item.id,
|
||||
sortOrder: zone.items.length,
|
||||
variant: 'default',
|
||||
});
|
||||
saveContentZone(zoneKey, zone);
|
||||
}
|
||||
|
||||
export function removeItemFromZone(zoneKey: string, itemId: string): void {
|
||||
const zone = getContentZone(zoneKey);
|
||||
if (!zone) return;
|
||||
zone.items = zone.items.filter((zi) => zi.item?.id !== itemId);
|
||||
zone.items.forEach((zi, i) => { zi.sortOrder = i; });
|
||||
saveContentZone(zoneKey, zone);
|
||||
}
|
||||
|
||||
export function reorderZoneItems(zoneKey: string, fromIndex: number, toIndex: number): void {
|
||||
const zone = getContentZone(zoneKey);
|
||||
if (!zone) return;
|
||||
const items = [...zone.items].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
const removed = items.splice(fromIndex, 1)[0];
|
||||
if (!removed) return;
|
||||
items.splice(toIndex, 0, removed);
|
||||
items.forEach((item, i) => { item.sortOrder = i; });
|
||||
zone.items = items;
|
||||
saveContentZone(zoneKey, zone);
|
||||
}
|
||||
|
||||
export function updateZoneSettings(zoneKey: string, settings: Partial<ContentZoneSettings>): void {
|
||||
const zone = getContentZone(zoneKey);
|
||||
if (!zone) return;
|
||||
zone.settings = { ...(zone.settings || {}), ...settings };
|
||||
saveContentZone(zoneKey, zone);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
export type FieldType =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'richtext'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'date'
|
||||
| 'datetime'
|
||||
| 'image'
|
||||
| 'media'
|
||||
| 'reference'
|
||||
| 'references'
|
||||
| 'json'
|
||||
| 'array'
|
||||
| 'object'
|
||||
| 'select'
|
||||
| 'dropdown';
|
||||
|
||||
export interface FieldDefinition {
|
||||
name: string;
|
||||
type: FieldType;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: unknown;
|
||||
validation?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: string;
|
||||
custom?: string;
|
||||
};
|
||||
options?: Array<{ label: string; value: string | number | boolean }>;
|
||||
referenceModel?: string;
|
||||
ui?: {
|
||||
component?: string;
|
||||
width?: 'full' | 'half' | 'third';
|
||||
helpText?: string;
|
||||
};
|
||||
fields?: FieldDefinition[];
|
||||
}
|
||||
|
||||
export interface ContentModel {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
fields: FieldDefinition[];
|
||||
isPageType: boolean;
|
||||
urlPattern?: string;
|
||||
hasVersions: boolean;
|
||||
hasDraft: boolean;
|
||||
icon?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type ContentStatus = 'draft' | 'review' | 'published' | 'archived';
|
||||
|
||||
export interface ContentItem {
|
||||
id: string;
|
||||
modelId: string;
|
||||
modelCode: string;
|
||||
title: string;
|
||||
slug?: string;
|
||||
status: ContentStatus;
|
||||
data: Record<string, unknown>;
|
||||
version: number;
|
||||
sortOrder?: number;
|
||||
publishedAt?: string;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ContentZoneItem {
|
||||
itemId?: string;
|
||||
item?: ContentItem;
|
||||
sortOrder: number;
|
||||
variant?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ContentZoneSettings {
|
||||
layout?: 'grid' | 'list' | 'carousel';
|
||||
columns?: number;
|
||||
gap?: 'small' | 'medium' | 'large';
|
||||
showTitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
export interface ContentZone {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
pageCode?: string;
|
||||
zoneKey?: string;
|
||||
allowedModels: string[];
|
||||
items: ContentZoneItem[];
|
||||
settings?: ContentZoneSettings;
|
||||
}
|
||||
|
||||
export interface ThemeConfig {
|
||||
id: string;
|
||||
siteName: string;
|
||||
siteDescription: string;
|
||||
logo?: string;
|
||||
favicon?: string;
|
||||
colors: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
text: string;
|
||||
};
|
||||
seo: {
|
||||
defaultTitle: string;
|
||||
defaultDescription: string;
|
||||
defaultKeywords: string;
|
||||
ogImage?: string;
|
||||
};
|
||||
social: {
|
||||
wechat?: string;
|
||||
weibo?: string;
|
||||
linkedin?: string;
|
||||
github?: string;
|
||||
};
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MediaAsset {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
url: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
storageType: 'local' | 'oss' | 's3';
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ContentQueryParams {
|
||||
modelCode: string;
|
||||
status?: ContentStatus;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
filters?: Record<string, string | number | boolean>;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: string;
|
||||
module: 'model' | 'item' | 'zone' | 'theme' | 'media';
|
||||
targetId: string;
|
||||
action: 'create' | 'update' | 'delete' | 'publish' | 'unpublish';
|
||||
operator: string;
|
||||
beforeData?: Record<string, unknown>;
|
||||
afterData?: Record<string, unknown>;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
Reference in New Issue
Block a user