diff --git a/public/images/news/founding.webp b/public/images/news/founding.webp new file mode 100644 index 0000000..0cbb18c Binary files /dev/null and b/public/images/news/founding.webp differ diff --git a/public/images/news/solution.webp b/public/images/news/solution.webp new file mode 100644 index 0000000..e138f61 Binary files /dev/null and b/public/images/news/solution.webp differ diff --git a/public/images/qrcode.webp b/public/images/qrcode.webp new file mode 100644 index 0000000..6208f9e Binary files /dev/null and b/public/images/qrcode.webp differ diff --git a/public/images/149A1D2F-D9FD-49C7-B139-142C50C5FE8B_1_201_a.jpeg b/public/images/wechat-business-qr.jpeg similarity index 100% rename from public/images/149A1D2F-D9FD-49C7-B139-142C50C5FE8B_1_201_a.jpeg rename to public/images/wechat-business-qr.jpeg diff --git a/public/images/wechat-business-qr.webp b/public/images/wechat-business-qr.webp new file mode 100644 index 0000000..f63bb0b Binary files /dev/null and b/public/images/wechat-business-qr.webp differ diff --git a/public/logo-calligraphy.svg b/public/logo-calligraphy.svg new file mode 100644 index 0000000..591aa65 --- /dev/null +++ b/public/logo-calligraphy.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NOVALON + + \ No newline at end of file diff --git a/public/logo-light.svg b/public/logo-light.svg index f2f64dc..79da069 100644 --- a/public/logo-light.svg +++ b/public/logo-light.svg @@ -1,4 +1,4 @@ - + @@ -32,7 +32,7 @@ C7,15 10,11 14,10 Z" fill="none" stroke="#fff" stroke-width="1.5" opacity="0.5"/> - + @@ -41,7 +41,7 @@ - + @@ -52,23 +52,23 @@ - + - + - + - + - + - + - - NOVALON + + 睿新致远 \ No newline at end of file diff --git a/public/logo-v4-backup.svg b/public/logo-v4-backup.svg new file mode 100644 index 0000000..79da069 --- /dev/null +++ b/public/logo-v4-backup.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 睿新致远 + + \ No newline at end of file diff --git a/public/logo-white.svg b/public/logo-white.svg index 4a96760..f64d6a0 100644 --- a/public/logo-white.svg +++ b/public/logo-white.svg @@ -68,7 +68,7 @@ - - NOVALON + + 睿新致远 \ No newline at end of file diff --git a/public/logo.svg b/public/logo.svg index 1c5ea9b..eb58576 100644 --- a/public/logo.svg +++ b/public/logo.svg @@ -34,19 +34,19 @@ - + - + - + - + @@ -56,19 +56,19 @@ - + - + - + - + - - NOVALON + + NOVALON \ No newline at end of file diff --git a/src/lib/admin-api.ts b/src/lib/admin-api.ts new file mode 100644 index 0000000..d11c228 --- /dev/null +++ b/src/lib/admin-api.ts @@ -0,0 +1,178 @@ +'use client'; + +import { encrypt, decrypt } from '@/lib/crypto'; + +class AdminApiClient { + private getToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem('novalon_admin_token'); + } + + private async request( + path: string, + options: RequestInit & { encrypt?: boolean } = {}, + ): Promise { + const token = this.getToken(); + const shouldEncrypt = options.encrypt !== false && token; + + const headers: Record = { + ...(options.headers as Record), + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + let body = options.body; + + if (shouldEncrypt && body && token) { + try { + const parsed = JSON.parse(body as string); + const encrypted = encrypt(parsed, token); + headers['Content-Type'] = 'application/json'; + headers['X-Encrypted'] = '1'; + body = JSON.stringify({ data: encrypted }); + } catch { + // 如果不是 JSON 或加密失败,使用原始 body + if (!headers['Content-Type']) { + headers['Content-Type'] = 'application/json'; + } + } + } else if (body && !headers['Content-Type']) { + headers['Content-Type'] = 'application/json'; + } + + const res = await fetch(path, { + ...options, + headers, + body, + }); + + if (res.status === 401) { + localStorage.removeItem('novalon_admin_token'); + localStorage.removeItem('novalon_admin_user'); + if (typeof window !== 'undefined') { + window.location.href = '/admin/login'; + } + throw new Error('未授权'); + } + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || data.message || `请求失败 (${res.status})`); + } + + const data = await res.json(); + + // 解密响应 + if (shouldEncrypt && token && data.encrypted) { + try { + return decrypt(data.encrypted, token); + } catch { + return data as T; + } + } + + return data as T; + } + + async login(username: string, password: string) { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || '登录失败'); + } + return res.json(); + } + + async getModels() { + return this.request('/api/admin/models'); + } + + async getItems(params: Record) { + const query = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => query.set(k, String(v))); + return this.request<{ items: unknown[]; total: number; page: number; pageSize: number; totalPages: number }>( + `/api/admin/items?${query.toString()}`, + ); + } + + async createItem(data: Record) { + return this.request('/api/admin/items', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateItem(id: string, data: Record) { + return this.request(`/api/admin/items?id=${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + async deleteItem(id: string) { + return this.request(`/api/admin/items?id=${id}`, { + method: 'DELETE', + }); + } + + async getZones(pageCode?: string) { + const query = pageCode ? `?pageCode=${pageCode}` : ''; + return this.request(`/api/admin/zones${query}`); + } + + async saveZone(data: Record) { + return this.request('/api/admin/zones', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async getMedia(params?: Record) { + const query = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([k, v]) => query.set(k, String(v))); + } + return this.request<{ items: unknown[]; total: number }>(`/api/admin/media?${query.toString()}`); + } + + async uploadMedia(file: File) { + const formData = new FormData(); + formData.append('file', file); + const token = this.getToken(); + const res = await fetch('/api/admin/media', { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: formData, + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || '上传失败'); + } + return res.json(); + } + + async deleteMedia(id: string) { + return this.request(`/api/admin/media?id=${id}`, { method: 'DELETE' }); + } + + async getStats() { + const [models, zones] = await Promise.all([ + this.getModels(), + this.getZones(), + ]); + const itemsRes = await this.getItems({ page: 1, pageSize: 1 }); + return { + models: Array.isArray(models) ? models.length : 0, + items: (itemsRes).total || 0, + zones: Array.isArray(zones) ? zones.length : 0, + }; + } +} + +export const adminApi = new AdminApiClient(); \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..38b29a5 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,84 @@ +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcryptjs'; +import { type NextRequest } from 'next/server'; + +const JWT_SECRET = process.env.JWT_SECRET || 'novalon-cms-secret-key-2024'; +const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'novalon-cms-refresh-secret-2024'; +const TOKEN_EXPIRY = '24h'; +const REFRESH_TOKEN_EXPIRY = '7d'; + +export interface JwtPayload { + userId: string; + username: string; + role: string; +} + +// ============ 密码工具 ============ + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, 10); +} + +export async function verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); +} + +// ============ Token 工具 ============ + +export function generateTokens(payload: JwtPayload): { accessToken: string; refreshToken: string } { + const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }); + const refreshToken = jwt.sign(payload, JWT_REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRY }); + return { accessToken, refreshToken }; +} + +export function verifyAccessToken(token: string): JwtPayload { + return jwt.verify(token, JWT_SECRET) as JwtPayload; +} + +export function verifyRefreshToken(token: string): JwtPayload { + return jwt.verify(token, JWT_REFRESH_SECRET) as JwtPayload; +} + +// ============ 请求认证 ============ + +export function getTokenFromRequest(request: NextRequest): string | null { + const authHeader = request.headers.get('authorization'); + if (authHeader?.startsWith('Bearer ')) { + return authHeader.slice(7); + } + return null; +} + +export function authenticateRequest(request: NextRequest): JwtPayload | null { + const token = getTokenFromRequest(request); + if (!token) return null; + try { + return verifyAccessToken(token); + } catch { + return null; + } +} + +// ============ Cookie 工具 ============ + +export function setTokenCookie(response: Response, accessToken: string, refreshToken: string): void { + response.headers.set( + 'Set-Cookie', + `novalon_token=${accessToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` + ); + response.headers.append( + 'Set-Cookie', + `novalon_refresh=${refreshToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800` + ); +} + +export function clearTokenCookie(response: Response): void { + response.headers.set( + 'Set-Cookie', + `novalon_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0` + ); + response.headers.append( + 'Set-Cookie', + `novalon_refresh=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0` + ); +} \ No newline at end of file diff --git a/src/lib/cms/ContentZoneRenderer.tsx b/src/lib/cms/ContentZoneRenderer.tsx new file mode 100644 index 0000000..4846314 --- /dev/null +++ b/src/lib/cms/ContentZoneRenderer.tsx @@ -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 = { + 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 ( + + {items.map((zoneItem, i) => { + const item = zoneItem.item!; + const Renderer = getItemRenderer(item.modelCode); + if (!Renderer) { + return ( + + 未知内容类型: {item.modelCode} + + ); + } + return ( + + ); + })} + + ); + } + + if (layout === 'carousel') { + return ( + + + {items.map((zoneItem, i) => { + const item = zoneItem.item!; + const Renderer = getItemRenderer(item.modelCode); + if (!Renderer) return null; + return ( + + + + ); + })} + + + ); + } + + return ( + + {items.map((zoneItem, i) => { + const item = zoneItem.item!; + const Renderer = getItemRenderer(item.modelCode); + if (!Renderer) { + return ( + + 未知内容类型: {item.modelCode} + + ); + } + return ( + + ); + })} + + ); +} diff --git a/src/lib/cms/client.ts b/src/lib/cms/client.ts new file mode 100644 index 0000000..d96ad84 --- /dev/null +++ b/src/lib/cms/client.ts @@ -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; + + constructor(config: CmsClientConfig) { + this.config = { + baseUrl: config.baseUrl, + apiKey: config.apiKey ?? '', + timeout: config.timeout ?? 10000, + mockMode: config.mockMode ?? false, + }; + } + + private async request(path: string, options?: RequestInit): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + + try { + const headers: Record = { + 'Content-Type': 'application/json', + ...(options?.headers as Record), + }; + + 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 { + if (this.config.mockMode) { + return Object.values(contentModels); + } + return this.request('/api/models'); + } + + async getModel(code: string): Promise { + if (this.config.mockMode) { + const model = getMockContentModel(code); + if (!model) throw new Error(`Content model not found: ${code}`); + return model; + } + return this.request(`/api/models/${code}`); + } + + async getItems(params: ContentQueryParams): Promise> { + 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>( + `/api/items?${queryParams.toString()}`, + ); + } + + async getItem(id: string): Promise { + 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(`/api/items/${id}`); + } + + async getItemBySlug(modelCode: string, slug: string): Promise { + 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(`/api/items/slug/${modelCode}/${slug}`); + } + + async getZone(code: string): Promise { + 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(`/api/zones/${code}`); + } + + async getZonesByPage(pageCode: string): Promise { + 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(`/api/zones/page/${pageCode}`); + } + + async getThemeConfig(): Promise { + 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('/api/theme/config'); + } + + getTypedItem(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, +}); diff --git a/src/lib/cms/component-registry.ts b/src/lib/cms/component-registry.ts new file mode 100644 index 0000000..3b48063 --- /dev/null +++ b/src/lib/cms/component-registry.ts @@ -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; +export type ContentZoneRenderer = ComponentType; + +interface ComponentRegistry { + itemRenderers: Map; + zoneRenderers: Map; +} + +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()); +} diff --git a/src/lib/cms/content-types.ts b/src/lib/cms/content-types.ts new file mode 100644 index 0000000..b25cea3 --- /dev/null +++ b/src/lib/cms/content-types.ts @@ -0,0 +1,1020 @@ +import { registerContentType, type ContentTypeConfig } from './registry'; +import type { FieldDefinition } from './types'; + +const newsFields: FieldDefinition[] = [ + { + name: 'excerpt', + type: 'textarea', + label: '摘要', + required: true, + description: '新闻摘要,用于列表页展示', + }, + { + name: 'date', + type: 'date', + label: '发布日期', + required: true, + }, + { + name: 'category', + type: 'text', + label: '分类', + required: true, + options: [ + { label: '公司动态', value: 'company' }, + { label: '行业资讯', value: 'industry' }, + { label: '技术分享', value: 'tech' }, + ], + }, + { + name: 'image', + type: 'image', + label: '封面图', + required: true, + }, + { + name: 'content', + type: 'richtext', + label: '正文内容', + required: true, + }, + { + name: 'featured', + type: 'boolean', + label: '精选推荐', + defaultValue: false, + description: '是否在首页精选区展示', + }, +]; + +const serviceFields: FieldDefinition[] = [ + { + name: 'description', + type: 'textarea', + label: '简短描述', + required: true, + }, + { + name: 'icon', + type: 'text', + label: '图标标识', + required: true, + description: '图标名称,对应图标库', + }, + { + name: 'overview', + type: 'textarea', + label: '服务概述', + required: true, + }, + { + name: 'features', + type: 'array', + label: '核心能力', + fields: [{ name: 'name', type: 'text', label: '能力名称', required: true }], + }, + { + name: 'benefits', + type: 'array', + label: '服务价值', + fields: [{ name: 'name', type: 'text', label: '价值点', required: true }], + }, + { + name: 'process', + type: 'array', + label: '服务流程', + fields: [{ name: 'step', type: 'text', label: '步骤名称', required: true }], + }, + { + name: 'heroThemeId', + type: 'text', + label: 'Hero 主题 ID', + description: '关联的 Hero 区域主题配置', + }, +]; + +const productFields: FieldDefinition[] = [ + { + name: 'description', + type: 'textarea', + label: '产品简介', + required: true, + }, + { + name: 'image', + type: 'image', + label: '产品图片', + required: true, + }, + { + name: 'category', + type: 'text', + label: '分类名称', + required: true, + }, + { + name: 'categoryId', + type: 'text', + label: '分类 ID', + required: true, + options: [ + { label: '企业套装', value: 'enterprise' }, + { label: '专业产品', value: 'specialized' }, + ], + }, + { + name: 'status', + type: 'text', + label: '产品状态', + required: true, + options: [ + { label: '研发中', value: '研发中' }, + { label: '内测中', value: '内测中' }, + { label: '已发布', value: '已发布' }, + ], + }, + { + name: 'overview', + type: 'textarea', + label: '产品概述', + required: true, + }, + { + name: 'features', + type: 'array', + label: '核心功能', + fields: [{ name: 'name', type: 'text', label: '功能名称', required: true }], + }, + { + name: 'benefits', + type: 'array', + label: '产品价值', + fields: [{ name: 'name', type: 'text', label: '价值点', required: true }], + }, + { + name: 'tags', + type: 'array', + label: '标签', + fields: [{ name: 'name', type: 'text', label: '标签名', required: true }], + }, + { + name: 'featured', + type: 'boolean', + label: '精选产品', + defaultValue: false, + }, +]; + +const solutionFields: FieldDefinition[] = [ + { + name: 'description', + type: 'textarea', + label: '方案简介', + required: true, + }, + { + name: 'icon', + type: 'text', + label: '图标标识', + required: true, + }, + { + name: 'industry', + type: 'text', + label: '适用行业', + }, + { + name: 'overview', + type: 'textarea', + label: '方案概述', + required: true, + }, + { + name: 'painPoints', + type: 'array', + label: '行业痛点', + fields: [{ name: 'text', type: 'text', label: '痛点描述', required: true }], + }, + { + name: 'valueProps', + type: 'array', + label: '方案价值', + fields: [{ name: 'text', type: 'text', label: '价值描述', required: true }], + }, + { + name: 'featured', + type: 'boolean', + label: '精选方案', + defaultValue: false, + }, +]; + +const heroBannerFields: FieldDefinition[] = [ + { + name: 'heading', + type: 'text', + label: '主标题', + required: true, + ui: { width: 'full' }, + }, + { + name: 'subheading', + type: 'textarea', + label: '副标题', + required: true, + ui: { width: 'full' }, + }, + { + name: 'description', + type: 'textarea', + label: '描述文本', + }, + { + name: 'primaryCtaText', + type: 'text', + label: '主按钮文案', + defaultValue: '了解更多', + }, + { + name: 'primaryCtaLink', + type: 'text', + label: '主按钮链接', + defaultValue: '/solutions', + }, + { + name: 'secondaryCtaText', + type: 'text', + label: '副按钮文案', + defaultValue: '联系我们', + }, + { + name: 'secondaryCtaLink', + type: 'text', + label: '副按钮链接', + defaultValue: '/contact', + }, + { + name: 'theme', + type: 'text', + label: '主题风格', + defaultValue: 'brand', + options: [ + { label: '品牌绿', value: 'brand' }, + { label: '蓝', value: 'blue' }, + { label: '青', value: 'teal' }, + { label: '琥珀', value: 'amber' }, + { label: '紫', value: 'purple' }, + ], + }, + { + name: 'sortOrder', + type: 'number', + label: '排序', + defaultValue: 0, + }, +]; + +const statItemFields: FieldDefinition[] = [ + { + name: 'icon', + type: 'text', + label: '图标', + required: true, + }, + { + name: 'label', + type: 'text', + label: '指标名称', + required: true, + }, + { + name: 'value', + type: 'number', + label: '数值', + required: true, + }, + { + name: 'suffix', + type: 'text', + label: '后缀', + defaultValue: '', + }, + { + name: 'prefix', + type: 'text', + label: '前缀', + defaultValue: '', + }, + { + name: 'decimals', + type: 'number', + label: '小数位数', + defaultValue: 0, + }, + { + name: 'description', + type: 'textarea', + label: '描述说明', + }, + { + name: 'trendValue', + type: 'text', + label: '趋势值', + }, + { + name: 'trendDirection', + type: 'text', + label: '趋势方向', + defaultValue: 'up', + options: [ + { label: '上升', value: 'up' }, + { label: '下降', value: 'down' }, + { label: '持平', value: 'neutral' }, + ], + }, + { + name: 'accentColor', + type: 'text', + label: '强调色', + defaultValue: 'brand', + options: [ + { label: '品牌绿', value: 'brand' }, + { label: '蓝', value: 'blue' }, + { label: '青', value: 'teal' }, + { label: '琥珀', value: 'amber' }, + { label: '紫', value: 'purple' }, + ], + }, + { + name: 'sortOrder', + type: 'number', + label: '排序', + defaultValue: 0, + }, +]; + +const aboutPageFields: FieldDefinition[] = [ + { + name: 'heroSubtitle', + type: 'text', + label: 'Hero 副标题', + }, + { + name: 'heroTitle', + type: 'text', + label: 'Hero 主标题', + required: true, + }, + { + name: 'heroDescription', + type: 'textarea', + label: 'Hero 描述', + }, + { + name: 'heroPrimaryCtaLabel', + type: 'text', + label: '主按钮文案', + defaultValue: '了解更多', + }, + { + name: 'heroPrimaryCtaHref', + type: 'text', + label: '主按钮链接', + defaultValue: '/contact', + }, + { + name: 'heroSecondaryCtaLabel', + type: 'text', + label: '副按钮文案', + defaultValue: '我们的团队', + }, + { + name: 'heroSecondaryCtaHref', + type: 'text', + label: '副按钮链接', + defaultValue: '/team', + }, + { + name: 'whoWeAreTitle', + type: 'text', + label: '关于我们标题', + required: true, + }, + { + name: 'whoWeAreDescription', + type: 'textarea', + label: '关于我们描述', + required: true, + }, + { + name: 'promiseText', + type: 'textarea', + label: '承诺宣言', + }, + { + name: 'coreValues', + type: 'array', + label: '核心价值观', + fields: [ + { name: 'number', type: 'text', label: '序号', required: true }, + { name: 'title', type: 'text', label: '标题', required: true }, + { name: 'description', type: 'textarea', label: '描述', required: true }, + ], + }, + { + name: 'milestones', + type: 'array', + label: '发展历程', + fields: [ + { name: 'year', type: 'text', label: '年份', required: true }, + { name: 'title', type: 'text', label: '标题', required: true }, + { name: 'description', type: 'textarea', label: '描述' }, + { name: 'highlight', type: 'text', label: '亮点' }, + ], + }, + { + name: 'keyMetrics', + type: 'array', + label: '关键数据', + fields: [ + { name: 'value', type: 'text', label: '数值', required: true }, + { name: 'label', type: 'text', label: '标签', required: true }, + { name: 'sub', type: 'text', label: '副标题' }, + ], + }, + { + name: 'certifications', + type: 'array', + label: '资质认证', + fields: [ + { name: 'name', type: 'text', label: '名称', required: true }, + { name: 'description', type: 'textarea', label: '描述' }, + ], + }, + { + name: 'partners', + type: 'array', + label: '合作伙伴', + fields: [ + { name: 'name', type: 'text', label: '名称', required: true }, + ], + }, + { + name: 'ctaTitle', + type: 'text', + label: 'CTA 标题', + required: true, + }, + { + name: 'ctaDescription', + type: 'textarea', + label: 'CTA 描述', + }, + { + name: 'ctaPrimaryLabel', + type: 'text', + label: '主按钮文案', + defaultValue: '联系我们', + }, + { + name: 'ctaPrimaryHref', + type: 'text', + label: '主按钮链接', + defaultValue: '/contact', + }, + { + name: 'ctaSecondaryLabel', + type: 'text', + label: '副按钮文案', + defaultValue: '了解更多', + }, + { + name: 'ctaSecondaryHref', + type: 'text', + label: '副按钮链接', + defaultValue: '/solutions', + }, +]; + +const teamPageFields: FieldDefinition[] = [ + { + name: 'heroSubtitle', + type: 'text', + label: 'Hero 副标题', + }, + { + name: 'heroTitle', + type: 'text', + label: 'Hero 主标题', + required: true, + }, + { + name: 'heroDescription', + type: 'textarea', + label: 'Hero 描述', + }, + { + name: 'heroPrimaryCtaLabel', + type: 'text', + label: '主按钮文案', + defaultValue: '加入我们', + }, + { + name: 'heroPrimaryCtaHref', + type: 'text', + label: '主按钮链接', + defaultValue: '/contact', + }, + { + name: 'heroSecondaryCtaLabel', + type: 'text', + label: '副按钮文案', + defaultValue: '了解更多', + }, + { + name: 'heroSecondaryCtaHref', + type: 'text', + label: '副按钮链接', + defaultValue: '/about', + }, + { + name: 'strengths', + type: 'array', + label: '团队优势', + fields: [ + { name: 'number', type: 'text', label: '序号', required: true }, + { name: 'title', type: 'text', label: '标题', required: true }, + { name: 'description', type: 'textarea', label: '描述', required: true }, + { name: 'iconName', type: 'text', label: '图标名称' }, + ], + }, + { + name: 'culture', + type: 'array', + label: '团队文化', + fields: [ + { name: 'number', type: 'text', label: '序号', required: true }, + { name: 'title', type: 'text', label: '标题', required: true }, + { name: 'description', type: 'textarea', label: '描述', required: true }, + { name: 'iconName', type: 'text', label: '图标名称' }, + ], + }, + { + name: 'stats', + type: 'array', + label: '数据统计', + fields: [ + { name: 'value', type: 'number', label: '数值', required: true }, + { name: 'suffix', type: 'text', label: '后缀' }, + { name: 'label', type: 'text', label: '标签', required: true }, + ], + }, + { + name: 'aboutTitle', + type: 'text', + label: '关于标题', + }, + { + name: 'aboutParagraphs', + type: 'array', + label: '关于段落', + fields: [ + { name: 'text', type: 'textarea', label: '段落内容', required: true }, + ], + }, + { + name: 'ctaTitle', + type: 'text', + label: 'CTA 标题', + required: true, + }, + { + name: 'ctaDescription', + type: 'textarea', + label: 'CTA 描述', + }, + { + name: 'ctaPrimaryLabel', + type: 'text', + label: '主按钮文案', + defaultValue: '联系我们', + }, + { + name: 'ctaPrimaryHref', + type: 'text', + label: '主按钮链接', + defaultValue: '/contact', + }, + { + name: 'ctaSecondaryLabel', + type: 'text', + label: '副按钮文案', + defaultValue: '关于我们', + }, + { + name: 'ctaSecondaryHref', + type: 'text', + label: '副按钮链接', + defaultValue: '/about', + }, +]; + +const contactPageFields: FieldDefinition[] = [ + { + name: 'heroSubtitle', + type: 'text', + label: 'Hero 副标题', + }, + { + name: 'heroTitle', + type: 'text', + label: 'Hero 主标题', + required: true, + }, + { + name: 'heroDescription', + type: 'textarea', + label: 'Hero 描述', + }, + { + name: 'contactTitle', + type: 'text', + label: '联系方式标题', + required: true, + }, + { + name: 'emailLabel', + type: 'text', + label: '邮箱标签', + defaultValue: '邮箱', + }, + { + name: 'addressLabel', + type: 'text', + label: '地址标签', + defaultValue: '地址', + }, + { + name: 'email', + type: 'text', + label: '邮箱地址', + required: true, + }, + { + name: 'address', + type: 'text', + label: '办公地址', + required: true, + }, + { + name: 'workHoursTitle', + type: 'text', + label: '工作时间标题', + }, + { + name: 'dayLabel', + type: 'text', + label: '工作日标签', + defaultValue: '周一至周五', + }, + { + name: 'hours', + type: 'text', + label: '工作时间', + defaultValue: '09:00 - 18:00', + }, + { + name: 'commitmentTitle', + type: 'text', + label: '承诺标题', + }, + { + name: 'commitmentItems', + type: 'array', + label: '承诺列表', + fields: [ + { name: 'item', type: 'text', label: '承诺内容', required: true }, + ], + }, + { + name: 'formTitle', + type: 'text', + label: '表单标题', + }, + { + name: 'successTitle', + type: 'text', + label: '提交成功标题', + defaultValue: '感谢您的留言', + }, + { + name: 'successMessage', + type: 'textarea', + label: '提交成功消息', + }, + { + name: 'submitLabel', + type: 'text', + label: '提交按钮文案', + defaultValue: '发送消息', + }, + { + name: 'submittingLabel', + type: 'text', + label: '提交中文案', + defaultValue: '发送中...', + }, + { + name: 'ctaSubtitle', + type: 'text', + label: 'CTA 区域副标题', + }, + { + name: 'ctaTitle', + type: 'text', + label: 'CTA 标题', + }, + { + name: 'ctaDescription', + type: 'textarea', + label: 'CTA 描述', + }, + { + name: 'ctaPrimaryLabel', + type: 'text', + label: '主按钮文案', + defaultValue: '了解更多', + }, + { + name: 'ctaPrimaryHref', + type: 'text', + label: '主按钮链接', + defaultValue: '/solutions', + }, + { + name: 'ctaSecondaryLabel', + type: 'text', + label: '副按钮文案', + defaultValue: '关于我们', + }, + { + name: 'ctaSecondaryHref', + type: 'text', + label: '副按钮链接', + defaultValue: '/about', + }, +]; + +const legalPageFields: FieldDefinition[] = [ + { + name: 'pageType', + type: 'text', + label: '页面类型', + required: true, + options: [ + { label: '隐私政策', value: 'privacy' }, + { label: '服务条款', value: 'terms' }, + ], + }, + { + name: 'heroTitle', + type: 'text', + label: '页面标题', + required: true, + }, + { + name: 'heroDescription', + type: 'textarea', + label: '页面描述', + }, + { + name: 'lastUpdated', + type: 'text', + label: '最后更新日期', + required: true, + }, + { + name: 'sections', + type: 'array', + label: '内容章节', + fields: [ + { name: 'title', type: 'text', label: '章节标题', required: true }, + { name: 'order', type: 'number', label: '排序', required: true }, + { name: 'content', type: 'json', label: '章节内容', required: true }, + ], + }, +]; + +const newsConfig: ContentTypeConfig = { + model: { + id: 'model-news', + code: 'news', + name: '新闻资讯', + description: '公司动态、行业资讯、技术博客等内容', + fields: newsFields, + isPageType: true, + urlPattern: '/news/{slug}', + hasVersions: true, + hasDraft: true, + icon: 'newspaper', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + listPage: { + route: '/news', + }, + detailPage: { + routePattern: '/news/{slug}', + }, +}; + +const serviceConfig: ContentTypeConfig = { + model: { + id: 'model-service', + code: 'service', + name: '服务', + description: '咨询服务、实施服务、运维服务等', + fields: serviceFields, + isPageType: true, + urlPattern: '/services/{slug}', + hasVersions: true, + hasDraft: true, + icon: 'briefcase', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + listPage: { + route: '/services', + }, + detailPage: { + routePattern: '/services/{slug}', + }, +}; + +const productConfig: ContentTypeConfig = { + model: { + id: 'model-product', + code: 'product', + name: '产品', + description: '企业级产品、专业产品等', + fields: productFields, + isPageType: true, + urlPattern: '/products/{slug}', + hasVersions: true, + hasDraft: true, + icon: 'box', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + listPage: { + route: '/products', + }, + detailPage: { + routePattern: '/products/{slug}', + }, +}; + +const solutionConfig: ContentTypeConfig = { + model: { + id: 'model-solution', + code: 'solution', + name: '解决方案', + description: '行业解决方案、场景解决方案', + fields: solutionFields, + isPageType: true, + urlPattern: '/solutions/{slug}', + hasVersions: true, + hasDraft: true, + icon: 'layers', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + listPage: { + route: '/solutions', + }, + detailPage: { + routePattern: '/solutions/{slug}', + }, +}; + +const heroBannerConfig: ContentTypeConfig = { + model: { + id: 'model-hero-banner', + code: 'hero-banner', + name: 'Hero Banner', + description: '首页/落地页顶部大 Banner', + fields: heroBannerFields, + isPageType: false, + hasVersions: false, + hasDraft: true, + icon: 'image', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, +}; + +const statItemConfig: ContentTypeConfig = { + model: { + id: 'model-stat-item', + code: 'stat-item', + name: '数据指标', + description: '用于数据展示区的指标卡片', + fields: statItemFields, + isPageType: false, + hasVersions: false, + hasDraft: false, + icon: 'bar-chart', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, +}; + +const aboutPageConfig: ContentTypeConfig = { + model: { + id: 'model-about-page', + code: 'about-page', + name: '关于我们', + description: '关于我们页面内容配置', + fields: aboutPageFields, + isPageType: false, + hasVersions: false, + hasDraft: true, + icon: 'info', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, +}; + +const teamPageConfig: ContentTypeConfig = { + model: { + id: 'model-team-page', + code: 'team-page', + name: '团队介绍', + description: '团队介绍页面内容配置', + fields: teamPageFields, + isPageType: false, + hasVersions: false, + hasDraft: true, + icon: 'users', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, +}; + +const contactPageConfig: ContentTypeConfig = { + model: { + id: 'model-contact-page', + code: 'contact-page', + name: '联系我们', + description: '联系我们页面内容配置', + fields: contactPageFields, + isPageType: false, + hasVersions: false, + hasDraft: true, + icon: 'phone', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, +}; + +const legalPageConfig: ContentTypeConfig = { + model: { + id: 'model-legal-page', + code: 'legal-page', + name: '法律页面', + description: '隐私政策/服务条款页面内容配置', + fields: legalPageFields, + isPageType: false, + hasVersions: false, + hasDraft: true, + icon: 'file-text', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, +}; + +export function registerAllContentTypes(): void { + registerContentType(newsConfig); + registerContentType(serviceConfig); + registerContentType(productConfig); + registerContentType(solutionConfig); + registerContentType(heroBannerConfig); + registerContentType(statItemConfig); + registerContentType(aboutPageConfig); + registerContentType(teamPageConfig); + registerContentType(contactPageConfig); + registerContentType(legalPageConfig); +} + +export const CONTENT_TYPE_CONFIGS = { + news: newsConfig, + service: serviceConfig, + product: productConfig, + solution: solutionConfig, + 'hero-banner': heroBannerConfig, + 'stat-item': statItemConfig, + 'about-page': aboutPageConfig, + 'team-page': teamPageConfig, + 'contact-page': contactPageConfig, + 'legal-page': legalPageConfig, +}; diff --git a/src/lib/cms/data-server.ts b/src/lib/cms/data-server.ts new file mode 100644 index 0000000..ee58738 --- /dev/null +++ b/src/lib/cms/data-server.ts @@ -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): 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 => { + const items = await prisma.contentItem.findMany({ + where: { modelCode, status: 'published' }, + orderBy: { sortOrder: 'asc' }, + }); + return items.map((item) => toContentItem(item as unknown as Record)); +}); + +/** + * 根据 slug 获取单条已发布内容 + */ +export const getPublishedItemBySlug = cache(async (modelCode: string, slug: string): Promise => { + const item = await prisma.contentItem.findFirst({ + where: { modelCode, slug, status: 'published' }, + }); + if (!item) return null; + return toContentItem(item as unknown as Record); +}); + +/** + * 根据 ID 获取单条内容(不限状态) + */ +export const getItemById = cache(async (id: string): Promise => { + const item = await prisma.contentItem.findUnique({ where: { id } }); + if (!item) return null; + return toContentItem(item as unknown as Record); +}); + +// ============ 内容区域查询 ============ + +/** + * 获取指定页面的内容区域配置 + */ +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 })); +} \ No newline at end of file diff --git a/src/lib/cms/index.ts b/src/lib/cms/index.ts new file mode 100644 index 0000000..f8e540e --- /dev/null +++ b/src/lib/cms/index.ts @@ -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'; diff --git a/src/lib/cms/init.ts b/src/lib/cms/init.ts new file mode 100644 index 0000000..f5731bb --- /dev/null +++ b/src/lib/cms/init.ts @@ -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; +} diff --git a/src/lib/cms/mock-data.ts b/src/lib/cms/mock-data.ts new file mode 100644 index 0000000..93528ae --- /dev/null +++ b/src/lib/cms/mock-data.ts @@ -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>( + 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, + 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, 'news', 'model-news', i), +); +const serviceItems: ContentItem[] = SERVICES.map((s, i) => + toContentItem(s as unknown as Record, 'service', 'model-service', i), +); +const productItems: ContentItem[] = PRODUCTS.map((p, i) => + toContentItem(p as unknown as Record, 'product', 'model-product', i), +); +const solutionItems: ContentItem[] = SOLUTIONS.map((s, i) => + toContentItem(s as unknown as Record, 'solution', 'model-solution', i), +); + +// ============ 模型注册表 ============ + +const contentModels: Record = { + 'case-study': caseStudyModel, +}; + +// ============ 所有 Mock 数据集合 ============ + +/** + * 所有模型的 Mock 数据集合 + * 用于 cmsClient mock 模式和 storage 默认数据初始化 + */ +export const allMockItems: Record = { + '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 }; diff --git a/src/lib/cms/mock-home.ts b/src/lib/cms/mock-home.ts new file mode 100644 index 0000000..5b92db4 --- /dev/null +++ b/src/lib/cms/mock-home.ts @@ -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 { + 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, 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, 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 = { + 'home-stats': getMockHomeStatsZone(), + 'home-services': getMockHomeServicesZone(), + 'home-cases': getMockHomeCasesZone(), + 'home-news': getMockHomeNewsZone(), + 'home-solutions': getMockHomeSolutionsZone(), + }; + return zones[zoneKey] || null; +} diff --git a/src/lib/cms/registry.ts b/src/lib/cms/registry.ts new file mode 100644 index 0000000..4a4aef0 --- /dev/null +++ b/src/lib/cms/registry.ts @@ -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; + getById?: (id: string) => Promise; + }; + listPage?: { + route: string; + }; + detailPage?: { + routePattern: string; + }; +} + +const contentTypeRegistry = new Map(); + +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}', + }, +}); diff --git a/src/lib/cms/renderers.tsx b/src/lib/cms/renderers.tsx new file mode 100644 index 0000000..24a06ed --- /dev/null +++ b/src/lib/cms/renderers.tsx @@ -0,0 +1,264 @@ +'use client'; + +import type { ContentItemComponentProps } from './component-registry'; + +function getField(item: Record, 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; + 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 ( + + + {image && ( + + )} + + + + {category} + {date} + + {title} + {excerpt} + + + ); + } + + return ( + + + {image && ( + + + + )} + + + {category} + {date} + + {title} + + + + ); +} + +export function StatCardRenderer({ item, className = '' }: ContentItemComponentProps) { + const data = item.data as Record; + 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 = { + 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 = { + up: 'text-emerald-400', + down: 'text-red-400', + neutral: 'text-white/50', + }; + + return ( + + + + {icon && {icon}} + {formattedValue} + {label} + {description && {description}} + {trendValue && ( + + {trendDirection === 'up' && '↑'} + {trendDirection === 'down' && '↓'} + {trendDirection === 'neutral' && '→'} + {trendValue} + + )} + + + ); +} + +export function ServiceCardRenderer({ item, className = '' }: ContentItemComponentProps) { + const data = item.data as Record; + const title = getField(data, 'title') || item.title; + const description = getField(data, 'description'); + const icon = getField(data, 'icon'); + const link = `/services/${item.slug}`; + + return ( + + + + {icon && ( + + {icon} + + )} + {title} + {description} + + 了解详情 + → + + + + ); +} + +export function ProductCardRenderer({ item, className = '' }: ContentItemComponentProps) { + const data = item.data as Record; + 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 = { + '研发中': 'bg-amber-500/20 text-amber-400', + '内测中': 'bg-blue-500/20 text-blue-400', + '已发布': 'bg-emerald-500/20 text-emerald-400', + }; + + return ( + + {image && ( + + + {status && ( + + {status} + + )} + + )} + + {category && {category}} + {title} + {description} + + + ); +} + +export function SolutionCardRenderer({ item, className = '' }: ContentItemComponentProps) { + const data = item.data as Record; + 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 ( + + + {icon && ( + + {icon} + + )} + {industry && {industry}} + {title} + {description} + + 查看方案 + → + + + + ); +} + +export function HeroBannerRenderer({ item, className = '' }: ContentItemComponentProps) { + const data = item.data as Record; + 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 = { + 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 ( + + + + + + + + + {subheading && ( + + + {subheading} + + )} + + {heading} + + {description && ( + + {description} + + )} + + + {primaryCtaText} + → + + + {secondaryCtaText} + + + + + + ); +} diff --git a/src/lib/cms/storage.ts b/src/lib/cms/storage.ts new file mode 100644 index 0000000..0b75ac1 --- /dev/null +++ b/src/lib/cms/storage.ts @@ -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; + items: Record; + updatedAt: string; +} + +function getDefaultZones(): Record { + return { + 'home-stats': getMockHomeStatsZone(), + 'home-services': getMockHomeServicesZone(), + 'home-solutions': getMockHomeSolutionsZone(), + 'home-cases': getMockHomeCasesZone(), + 'home-news': getMockHomeNewsZone(), + }; +} + +function toContentItem(source: Record, 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 { + const statList = Array.isArray(STATS) ? STATS : (STATS as { stats?: unknown[] }).stats || []; + return { + 'case-study': getFeaturedCases().map((cs, i) => toContentItem(cs as unknown as Record, 'case-study', i)), + news: NEWS.map((n, i) => toContentItem(n as unknown as Record, 'news', i)), + service: SERVICES.map((s, i) => toContentItem(s as unknown as Record, 'service', i)), + product: PRODUCTS.map((p, i) => toContentItem(p as unknown as Record, 'product', i)), + solution: SOLUTIONS.map((s, i) => toContentItem(s as unknown as Record, 'solution', i)), + 'hero-banner': [toContentItem(getMockHomeHeroBanner().data, 'hero-banner', 0)], + 'stat-item': statList.map((s, i) => toContentItem({ ...(s as Record), accentColor: (s as Record).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 { + 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): void { + const zone = getContentZone(zoneKey); + if (!zone) return; + zone.settings = { ...(zone.settings || {}), ...settings }; + saveContentZone(zoneKey, zone); +} diff --git a/src/lib/cms/types.ts b/src/lib/cms/types.ts new file mode 100644 index 0000000..c98ac48 --- /dev/null +++ b/src/lib/cms/types.ts @@ -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; + 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; +} + +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; +} + +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; + search?: string; +} + +export interface PaginatedResult { + 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; + afterData?: Record; + ip?: string; + userAgent?: string; + createdAt: string; +} diff --git a/src/lib/constants/cases.ts b/src/lib/constants/cases.ts new file mode 100644 index 0000000..0ad1356 --- /dev/null +++ b/src/lib/constants/cases.ts @@ -0,0 +1,284 @@ +export interface CaseMetric { + value: string; + label: string; + highlight?: boolean; +} + +export interface CaseTimelinePhase { + phase: string; + duration: string; + description: string; +} + +export interface CaseService { + id: string; + title: string; + description: string; +} + +export interface CaseTestimonial { + quote: string; + author: string; + role: string; + company: string; +} + +export interface CaseStudy { + id: string; + slug: string; + client: string; + industry: string; + companySize: string; + title: string; + subtitle: string; + challenge: string; + solution: string; + result: string; + metrics: CaseMetric[]; + timeline: CaseTimelinePhase[]; + services: CaseService[]; + testimonial?: CaseTestimonial; + color: 'brand' | 'blue' | 'teal' | 'amber' | 'purple'; + featured?: boolean; +} + +export const CASE_STUDIES: CaseStudy[] = [ + { + id: 'case-001', + slug: 'manufacturing-erp-upgrade', + client: '某上市制造企业', + industry: '制造业', + companySize: '1000人以上', + title: '大型制造企业 ERP 升级与数字化转型', + subtitle: '从传统 ERP 到智能运营平台的跨越', + challenge: + '该企业原有 ERP 系统已运行 8 年,无法支撑新业务模式,数据孤岛严重,决策效率低下。生产、财务、供应链各系统独立运作,数据口径不统一,管理层难以获取实时运营洞察。', + solution: + '采用分步迁移策略,以数据中台为核心,重构业务流程,实现从生产到财务的全链路数字化。引入敏捷开发方法论,分 6 个阶段推进,确保业务连续性与系统稳定性。', + result: + '项目一次性上线成功,业务中断时间控制在 48 小时内,关键指标全面超越预期。建立了数据驱动的运营体系,决策效率提升 3 倍以上。', + metrics: [ + { value: '40%', label: '生产效率提升', highlight: true }, + { value: '25%', label: '库存成本下降' }, + { value: '99.5%', label: '数据准确率' }, + { value: '3x', label: '决策效率提升' }, + ], + timeline: [ + { phase: '诊断评估', duration: '4周', description: '全面调研现有系统与业务流程,识别核心痛点与机会点' }, + { phase: '方案设计', duration: '6周', description: '制定技术架构与业务流程重构方案,明确实施路径' }, + { phase: '系统建设', duration: '4个月', description: '分模块开发与配置,同步开展数据清洗与迁移准备' }, + { phase: '上线切换', duration: '2个月', description: '用户培训、试点运行、全量切换,确保平稳过渡' }, + ], + services: [ + { id: 'digital-consulting', title: '数字化转型咨询', description: '战略规划与路线图设计,帮助企业明确数字化方向' }, + { id: 'erp-implementation', title: 'ERP 实施与升级', description: '全流程 ERP 实施服务,确保系统落地见效' }, + { id: 'data-platform', title: '数据中台建设', description: '构建统一数据底座,打通数据孤岛,释放数据价值' }, + ], + testimonial: { + quote: + '睿新团队不仅完成了系统升级,更帮助我们建立了数据驱动的运营体系,这是超出预期的价值。整个项目过程专业、高效,是真正值得信赖的合作伙伴。', + author: '王总', + role: 'CTO', + company: '某上市制造企业', + }, + color: 'brand', + featured: true, + }, + { + id: 'case-002', + slug: 'retail-omnichannel', + client: '某区域零售龙头', + industry: '贸易零售', + companySize: '500-1000人', + title: '连锁零售全渠道数字化升级', + subtitle: '线上线下一体化,重塑零售新体验', + challenge: + '线上线下渠道割裂,会员体系不统一,库存数据不同步,导致客户体验差、运营成本高。促销活动无法跨渠道联动,会员复购率低,库存周转慢。', + solution: + '构建全渠道中台,打通会员、库存、订单、营销体系,实现线上线下一体化运营。建立统一的会员中心与库存中心,支持 200+ 门店实时同步。', + result: + '6 个月内完成 200+ 门店的系统切换,营收实现快速增长,会员复购率显著提升。全渠道库存准确率达到 99% 以上,缺货率大幅下降。', + metrics: [ + { value: '25%', label: '营收增长', highlight: true }, + { value: '50%', label: '库存周转优化' }, + { value: '3x', label: '会员复购率' }, + { value: '200+', label: '门店覆盖' }, + ], + timeline: [ + { phase: '业务诊断', duration: '3周', description: '全渠道现状评估,识别核心瓶颈与优化方向' }, + { phase: '中台建设', duration: '3个月', description: '全渠道中台核心模块开发,打通会员、库存、订单' }, + { phase: '门店接入', duration: '2个月', description: '200+ 门店系统切换,人员培训与运营支持' }, + { phase: '运营优化', duration: '持续', description: '数据驱动的持续运营优化,营销活动精细化' }, + ], + services: [ + { id: 'omnichannel', title: '全渠道零售解决方案', description: '构建线上线下一体化的零售运营体系' }, + { id: 'crm', title: 'CRM 与会员体系', description: '打造以客户为中心的会员运营体系' }, + { id: 'digital-consulting', title: '数字化转型咨询', description: '零售行业数字化转型战略与路径规划' }, + ], + testimonial: { + quote: + '从咨询到落地,睿新团队展现出了超出预期的专业能力和责任心。他们不仅懂技术,更懂零售业务,是值得信赖的长期合作伙伴。', + author: '李总', + role: 'CEO', + company: '某区域零售龙头', + }, + color: 'blue', + featured: true, + }, + { + id: 'case-003', + slug: 'healthcare-data-platform', + client: '某三甲医院', + industry: '医疗健康', + companySize: '1000人以上', + title: '医院数据中台与智慧运营平台建设', + subtitle: '以数据驱动医疗质量与运营效率双提升', + challenge: + '医院各业务系统(HIS、EMR、LIS、PACS等)独立建设,数据分散,难以形成统一视图。运营决策依赖人工统计报表,时效性差、准确性低,无法支撑精细化管理需求。', + solution: + '构建医院数据中台,打通 20+ 业务系统数据,建立统一的数据标准与治理体系。基于数据中台建设智慧运营平台,实现运营指标实时监控、智能预警与辅助决策。', + result: + '运营数据从 T+1 变为实时,决策响应速度提升 10 倍以上。医疗质量指标持续改善,运营效率显著提升,成为区域智慧医院标杆。', + metrics: [ + { value: '10x', label: '决策效率提升', highlight: true }, + { value: '20+', label: '系统打通' }, + { value: '99.9%', label: '数据准确率' }, + { value: '30%', label: '运营成本下降' }, + ], + timeline: [ + { phase: '现状调研', duration: '4周', description: '全面梳理 20+ 业务系统,建立数据资产目录' }, + { phase: '中台建设', duration: '5个月', description: '数据中台与数据治理体系建设,构建统一数据底座' }, + { phase: '应用开发', duration: '3个月', description: '智慧运营平台开发,涵盖运营、质控、后勤等模块' }, + { phase: '推广运营', duration: '持续', description: '全院推广使用,持续优化迭代' }, + ], + services: [ + { id: 'data-platform', title: '数据中台建设', description: '构建统一数据底座,释放医疗数据价值' }, + { id: 'bi-analytics', title: 'BI 与数据分析', description: '智慧运营与数据可视化平台建设' }, + { id: 'digital-consulting', title: '数字化转型咨询', description: '医疗行业数字化转型规划与实施指导' }, + ], + color: 'teal', + featured: true, + }, + { + id: 'case-004', + slug: 'education-crm-system', + client: '某教育科技集团', + industry: '教育培训', + companySize: '500-1000人', + title: '教育集团 CRM 与营销自动化系统建设', + subtitle: '从线索到续费的全链路数字化运营', + challenge: + '招生线索管理分散,各校区独立运营,无法实现统一的线索分配与跟进。营销活动效果难以追踪,客户生命周期管理缺失,导致线索转化率低、续费率不稳定。', + solution: + '建设集团统一的 CRM 系统,覆盖线索管理、客户跟进、营销自动化、续费管理全链路。打通线上线下营销渠道,建立数据驱动的营销运营体系。', + result: + '线索转化率提升 45%,平均跟进周期缩短 60%,营销 ROI 提升 2 倍以上。实现了集团层面的统一客户管理与数据洞察。', + metrics: [ + { value: '45%', label: '线索转化率提升', highlight: true }, + { value: '60%', label: '跟进周期缩短' }, + { value: '2x', label: '营销 ROI 提升' }, + { value: '35%', label: '续费率提升' }, + ], + timeline: [ + { phase: '业务梳理', duration: '3周', description: '全链路业务流程梳理,明确需求与优化方向' }, + { phase: '系统建设', duration: '4个月', description: 'CRM 核心系统开发,集成营销自动化能力' }, + { phase: '推广使用', duration: '2个月', description: '各校区推广培训,建立运营规范与考核体系' }, + { phase: '持续优化', duration: '持续', description: '数据驱动的持续运营优化,提升转化效果' }, + ], + services: [ + { id: 'crm', title: 'CRM 与会员体系', description: '全渠道客户关系管理与营销自动化' }, + { id: 'digital-marketing', title: '数字营销服务', description: '数据驱动的营销体系建设与优化' }, + { id: 'digital-consulting', title: '数字化转型咨询', description: '教育行业数字化转型规划与实施' }, + ], + color: 'amber', + }, + { + id: 'case-005', + slug: 'finance-erp-upgrade', + client: '某金融科技公司', + industry: '金融服务', + companySize: '200-500人', + title: '金融科技公司财务共享中心建设', + subtitle: '打造高效、合规、智能的财务运营体系', + challenge: + '随着业务快速发展,传统财务模式难以支撑多业务线、多主体的复杂核算需求。财务流程不规范、数据不及时,合规风险高,财务团队疲于应付事务性工作。', + solution: + '建设财务共享中心,统一核算标准与流程,引入财务 RPA 自动化处理,建立业财一体化的数据平台。实现从核算型财务向价值型财务的转型。', + result: + '月结时间从 10 天缩短到 3 天,财务处理效率提升 70%。合规风险显著降低,财务团队得以更多投入业务分析与决策支持工作。', + metrics: [ + { value: '70%', label: '财务效率提升', highlight: true }, + { value: '3天', label: '月结周期缩短' }, + { value: '50%', label: '人力成本节约' }, + { value: '0', label: '重大合规事故' }, + ], + timeline: [ + { phase: '现状评估', duration: '3周', description: '财务流程全面诊断,识别优化点与风险点' }, + { phase: '方案设计', duration: '4周', description: '财务共享中心方案设计,明确组织、流程、系统规划' }, + { phase: '系统建设', duration: '4个月', description: '财务共享系统与 RPA 机器人开发部署' }, + { phase: '上线运营', duration: '3个月', description: '共享中心正式运营,持续优化流程与系统' }, + ], + services: [ + { id: 'erp-implementation', title: 'ERP 实施与升级', description: '财务模块深度实施与业财一体化' }, + { id: 'digital-consulting', title: '数字化转型咨询', description: '财务数字化转型与共享中心建设咨询' }, + { id: 'rpa', title: 'RPA 智能自动化', description: '财务场景 RPA 机器人开发与运营' }, + ], + color: 'purple', + }, + { + id: 'case-006', + slug: 'logistics-supply-chain', + client: '某物流供应链企业', + industry: '物流运输', + companySize: '500-1000人', + title: '物流企业供应链协同平台建设', + subtitle: '端到端供应链可视化与智能调度', + challenge: + '物流环节信息不透明,运输状态无法实时追踪,客户体验差。调度依赖人工经验,车辆空驶率高,成本难以下降。上下游协同效率低,异常处理响应慢。', + solution: + '建设供应链协同平台,实现订单、运输、仓储全链路可视化。引入智能调度算法,优化运力资源配置。建立上下游协同机制,提升整体供应链效率。', + result: + '运输全程可视化率达到 95% 以上,车辆空驶率降低 20%,客户满意度提升 30%。调度效率大幅提升,异常响应时间从 2 小时缩短到 15 分钟。', + metrics: [ + { value: '95%+', label: '可视化覆盖率', highlight: true }, + { value: '20%', label: '空驶率降低' }, + { value: '30%', label: '客户满意度提升' }, + { value: '15min', label: '异常响应时间' }, + ], + timeline: [ + { phase: '业务诊断', duration: '3周', description: '供应链全链路调研,识别核心痛点与优化机会' }, + { phase: '平台建设', duration: '5个月', description: '供应链协同平台开发,集成 TMS、WMS 等系统' }, + { phase: '智能升级', duration: '2个月', description: '智能调度算法上线,持续优化调优' }, + { phase: '全面推广', duration: '持续', description: '全业务线推广使用,上下游协同扩展' }, + ], + services: [ + { id: 'supply-chain', title: '供应链数字化', description: '端到端供应链数字化与智能协同' }, + { id: 'data-platform', title: '数据中台建设', description: '物流数据中台与可视化平台建设' }, + { id: 'digital-consulting', title: '数字化转型咨询', description: '物流行业数字化转型战略规划' }, + ], + color: 'brand', + }, +]; + +export const CASE_INDUSTRIES = [ + { id: 'all', label: '全部行业' }, + { id: '制造业', label: '制造业' }, + { id: '贸易零售', label: '贸易零售' }, + { id: '医疗健康', label: '医疗健康' }, + { id: '教育培训', label: '教育培训' }, + { id: '金融服务', label: '金融服务' }, + { id: '物流运输', label: '物流运输' }, +]; + +export function getCaseBySlug(slug: string): CaseStudy | undefined { + return CASE_STUDIES.find((c) => c.slug === slug); +} + +export function getFeaturedCases(): CaseStudy[] { + return CASE_STUDIES.filter((c) => c.featured); +} + +export function getCasesByIndustry(industry: string): CaseStudy[] { + if (industry === 'all') return CASE_STUDIES; + return CASE_STUDIES.filter((c) => c.industry === industry); +} diff --git a/src/lib/constants/cross-references.ts b/src/lib/constants/cross-references.ts new file mode 100644 index 0000000..2288c2b --- /dev/null +++ b/src/lib/constants/cross-references.ts @@ -0,0 +1,80 @@ +import { PRODUCTS } from './products'; +import { SOLUTIONS } from './solutions'; +import { SERVICES } from './services'; + +export interface CrossReference { + id: string; + title: string; + type: 'product' | 'solution' | 'service'; + href: string; + reason: string; +} + +export function getProductCrossRefs(productId: string): CrossReference[] { + const product = PRODUCTS.find(p => p.id === productId); + if (!product) return []; + + const relatedSolutions = SOLUTIONS.filter(s => s.relatedProducts.includes(productId)); + const otherProducts = PRODUCTS.filter(p => + p.id !== productId && + SOLUTIONS.some(s => s.relatedProducts.includes(productId) && s.relatedProducts.includes(p.id)) + ); + + return [ + ...relatedSolutions.map(s => ({ + id: s.id, + title: s.title, + type: 'solution' as const, + href: `/solutions/${s.id}`, + reason: `${product.title} 是「${s.title}」的核心组件`, + })), + ...otherProducts.slice(0, 3).map(p => ({ + id: p.id, + title: p.title, + type: 'product' as const, + href: `/products/${p.id}`, + reason: `常与 ${product.title} 一起使用`, + })), + ]; +} + +export function getSolutionCrossRefs(solutionId: string): CrossReference[] { + const solution = SOLUTIONS.find(s => s.id === solutionId); + if (!solution) return []; + + const relatedProducts = PRODUCTS.filter(p => solution.relatedProducts.includes(p.id)); + + return relatedProducts.map(p => ({ + id: p.id, + title: p.title, + type: 'product' as const, + href: `/products/${p.id}`, + reason: `「${solution.title}」推荐使用 ${p.title}`, + })); +} + +export function getServiceCrossRefs(serviceId: string): CrossReference[] { + const service = SERVICES.find(s => s.id === serviceId); + if (!service) return []; + + const mapping: Record = { + software: { solutionIds: ['manufacturing', 'retail'], label: '软件开发服务支撑各行业方案落地' }, + data: { solutionIds: ['manufacturing', 'retail', 'healthcare'], label: '数据分析为方案提供决策支持' }, + consulting: { solutionIds: ['manufacturing', 'education'], label: '咨询规划方案实施路径' }, + solutions: { solutionIds: ['manufacturing', 'retail', 'education', 'healthcare'], label: '行业方案实施全流程覆盖' }, + }; + + const config = mapping[serviceId]; + if (!config) return []; + + return SOLUTIONS + .filter(s => config.solutionIds.includes(s.id)) + .slice(0, 3) + .map(s => ({ + id: s.id, + title: s.title, + type: 'solution' as const, + href: `/solutions/${s.id}`, + reason: config.label, + })); +} diff --git a/src/lib/constants/methodology.ts b/src/lib/constants/methodology.ts new file mode 100644 index 0000000..5aee19d --- /dev/null +++ b/src/lib/constants/methodology.ts @@ -0,0 +1,93 @@ +export interface MethodologyPhase { + id: string; + number: number; + title: string; + subtitle: string; + description: string; + activities: string[]; + deliverables: string[]; +} + +/** + * 数字化转型方法论 — 四阶段模型 + * 基于"评估→规划→实施→优化"的行业通用框架 + */ +export const METHODOLOGY: MethodologyPhase[] = [ + { + id: 'assess', + number: 1, + title: '诊断评估', + subtitle: '了解现状,找准痛点', + description: '深入调研企业业务现状、技术基础和组织能力,识别数字化转型中的关键痛点和机会点。', + activities: [ + '业务流程调研', + 'IT基础设施评估', + '数据资产盘点', + '组织能力诊断', + '行业对标分析', + ], + deliverables: [ + '数字化转型成熟度评估报告', + '关键痛点清单', + '机会优先级矩阵', + ], + }, + { + id: 'plan', + number: 2, + title: '战略规划', + subtitle: '明确方向,制定路线', + description: '基于评估结果,制定匹配企业发展战略的数字化转型路线图,明确技术选型和实施优先级。', + activities: [ + 'IT战略对齐', + '技术选型评估', + '实施路线图制定', + '投资回报分析', + '风险预案制定', + ], + deliverables: [ + '数字化转型战略规划书', + '技术架构蓝图', + '分阶段实施计划', + ], + }, + { + id: 'implement', + number: 3, + title: '落地实施', + subtitle: '敏捷交付,快速见效', + description: '采用敏捷方法论,分阶段推进实施,确保每个迭代都能交付可衡量的业务价值。', + activities: [ + '方案详细设计', + '敏捷开发迭代', + '系统集成测试', + '用户培训赋能', + '数据迁移验证', + ], + deliverables: [ + '系统功能模块', + '集成测试报告', + '用户操作手册', + '培训记录', + ], + }, + { + id: 'optimize', + number: 4, + title: '持续优化', + subtitle: '数据驱动,迭代提升', + description: '上线后持续跟踪系统运行效果,基于数据反馈进行优化迭代,确保数字化转型的长期成功。', + activities: [ + '运行效果监控', + '用户反馈收集', + '性能调优迭代', + '功能持续升级', + '季度复盘总结', + ], + deliverables: [ + '运行效果分析报告', + '优化建议清单', + '季度复盘报告', + ], + }, +]; diff --git a/src/lib/constants/team.ts b/src/lib/constants/team.ts new file mode 100644 index 0000000..dc8cc47 --- /dev/null +++ b/src/lib/constants/team.ts @@ -0,0 +1,65 @@ +export interface TeamMember { + id: string; + name: string; + title: string; + avatar?: string; + specialties: string[]; + bio: string; + yearsOfExperience?: number; + certifications?: string[]; + isCore?: boolean; +} + +export const TEAM_MEMBERS: TeamMember[] = [ + { + id: 'member-1', + name: '创始人', + title: '创始人兼CEO', + avatar: undefined, + specialties: ['企业战略', '数字化转型', '组织管理'], + bio: '核心团队长期从事技术咨询、企业数字化等领域,服务覆盖金融、制造、零售、政务、农业等多个行业。以"客户业务是否真正改善"为衡量标准,追求可量化的业务价值。', + yearsOfExperience: 15, + certifications: [], + isCore: true, + }, + { + id: 'member-2', + name: '技术负责人', + title: '联合创始人兼CTO', + avatar: undefined, + specialties: ['系统架构', '云原生', '微服务'], + bio: '开发团队成员来自多个大型传统IT企业,具备扎实的工程能力和规范化的交付经验。掌握从前端到后端、从云原生到数据智能的全栈技术能力。', + yearsOfExperience: 12, + certifications: [], + isCore: true, + }, + { + id: 'member-3', + name: '技术总监', + title: '技术总监', + avatar: undefined, + specialties: ['全栈开发', '数据工程', 'DevOps'], + bio: '团队成员既懂技术又懂业务,能够深入理解客户的真实场景和痛点。以务实态度交付每一个项目,确保技术方案真正落地见效。', + yearsOfExperience: 10, + certifications: [], + isCore: true, + }, + { + id: 'member-4', + name: '咨询总监', + title: '咨询总监', + avatar: undefined, + specialties: ['业务咨询', '流程优化', '项目管理'], + bio: '不追逐风口,只做真正为客户创造价值的事。交付只是开始,长期陪跑才是我们的承诺。用扎实的工程能力和行业经验赢得信任。', + yearsOfExperience: 10, + certifications: [], + isCore: true, + }, +]; + +export const TEAM_STATS = [ + { value: '12+', label: '年核心团队经验' }, + { value: '5+', label: '覆盖行业' }, + { value: '6', label: '自研产品' }, + { value: '10+', label: '团队成员' }, +]; diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..3d05360 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,69 @@ +import crypto from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; +const AUTH_TAG_LENGTH = 16; +const SALT = 'novalon-cms-crypto-salt'; + +/** + * 从 JWT token 派生加密密钥 + */ +function deriveKey(token: string): Buffer { + return crypto.pbkdf2Sync(token, SALT, 10000, 32, 'sha256'); +} + +/** + * 加密数据 + */ +export function encrypt(data: unknown, token: string): string { + const key = deriveKey(token); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + const json = JSON.stringify(data); + const encrypted = Buffer.concat([ + cipher.update(json, 'utf8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + // 格式: iv + authTag + encrypted (全部 base64) + const combined = Buffer.concat([iv, authTag, encrypted]); + return combined.toString('base64'); +} + +/** + * 解密数据 + */ +export function decrypt(encryptedData: string, token: string): T { + const key = deriveKey(token); + const combined = Buffer.from(encryptedData, 'base64'); + + const iv = combined.subarray(0, IV_LENGTH); + const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const encrypted = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]); + + return JSON.parse(decrypted.toString('utf8')) as T; +} + +/** + * 生成随机 Token(用于前端存储的加密密钥) + */ +export function generateCryptoToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +/** + * 对敏感字段进行哈希(用于日志脱敏) + */ +export function hashSensitive(data: string): string { + return crypto.createHash('sha256').update(data + SALT).digest('hex').slice(0, 16); +} \ No newline at end of file diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..f479a84 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,11 @@ +import { PrismaClient } from '@/generated/prisma/client'; + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined; +}; + +export const prisma = globalForPrisma.prisma ?? new PrismaClient(); + +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = prisma; +} \ No newline at end of file diff --git a/src/lib/site-config.tsx b/src/lib/site-config.tsx new file mode 100644 index 0000000..8fd38be --- /dev/null +++ b/src/lib/site-config.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { createContext, useContext, type ReactNode } from 'react'; + +export interface NavigationItem { + id: string; + label: string; + href: string; + hasDropdown?: boolean; + dropdownKey?: string; +} + +export interface MegaDropdownItem { + id: string; + title: string; + description: string; + href: string; + badge?: string; +} + +export interface MegaDropdownGroup { + id: string; + title: string; + description?: string; + items: MegaDropdownItem[]; + highlight?: boolean; +} + +export interface SiteConfig { + /** Company info (matches COMPANY_INFO field names) */ + name: string; + shortName: string; + displayName: string; + slogan: string; + description: string; + founded: string; + location: string; + email: string; + address: string; + icp: string; + police: string; + + /** Navigation */ + mainNav: NavigationItem[]; + megaDropdown: Record; +} + +const DEFAULT_CONFIG: SiteConfig = { + name: '', + shortName: '', + displayName: '', + slogan: '', + description: '', + founded: '', + location: '', + email: '', + address: '', + icp: '', + police: '', + mainNav: [], + megaDropdown: {}, +}; + +const SiteConfigContext = createContext(DEFAULT_CONFIG); + +export function useSiteConfig(): SiteConfig { + return useContext(SiteConfigContext); +} + +export function SiteConfigProvider({ + config, + children, +}: { + config: SiteConfig; + children: ReactNode; +}) { + return ( + + {children} + + ); +}
未知内容类型: {item.modelCode}
{excerpt}
{description}
+ {description} +