feat(assets): 更新 Logo 与图片资源,添加数据层服务模块

- 更新 logo.svg/logo-light.svg/logo-white.svg 品牌标识
- 新增 logo-calligraphy.svg 书法体 Logo 变体
- 新增新闻、二维码、微信业务等图片资源
- 删除旧版 JPEG 测试图片
- 新增 CMS 数据层服务模块 (admin-api, auth, cms, crypto, db)
- 新增 cases/cross-references/methodology/team 常量数据
- 新增 site-config 站点配置
This commit is contained in:
张翔
2026-07-07 06:54:47 +08:00
parent 636bc4ecde
commit 49dbced0cb
32 changed files with 4219 additions and 23 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

+11 -11
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+10 -10
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

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