chore: sync marketing pages, CMS extensions, tests and project docs
同步工作区剩余变更,主要包括: - 营销页面组件与布局持续优化(about/news/services/solutions/team 等) - 详情页四层叙事组件、布局组件、UI 组件调整 - CMS 数据模型、API 路由、权限、工作流、站内通知、媒体管理扩展 - 新增/补充单元测试与 E2E 测试(cms-workflow.spec.ts 等) - ESLint 9 迁移、jest/tsconfig 配置更新、依赖调整 - 新增 ADR、CMS 评估文档、Release Review / Acceptance 报告 - 移除水墨装饰组件与大体积未使用字体文件
This commit is contained in:
@@ -288,22 +288,6 @@ describe('Animation Components', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('InkCard', () => {
|
||||
it('should render children correctly', async () => {
|
||||
const { InkCard } = await import('./animations');
|
||||
render(<InkCard>Card Content</InkCard>);
|
||||
expect(screen.getByText('Card Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom hoverScale', async () => {
|
||||
const { InkCard } = await import('./animations');
|
||||
render(<InkCard hoverScale={1.1}>Test</InkCard>);
|
||||
const element = screen.getByTestId('motion-div');
|
||||
const whileHover = JSON.parse(element.getAttribute('data-while-hover') || '{}');
|
||||
expect(whileHover.scale).toBe(1.1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CountUp', () => {
|
||||
it('should render with prefix and suffix', async () => {
|
||||
const { CountUp } = await import('./animations');
|
||||
|
||||
@@ -291,32 +291,7 @@ export function RippleButton({ children, onClick, className = '', rippleColor =
|
||||
);
|
||||
}
|
||||
|
||||
interface InkCardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
hoverScale?: number;
|
||||
hoverShadow?: string;
|
||||
}
|
||||
|
||||
export function InkCard({ children, className = '', hoverScale = 1.02, hoverShadow = '0 20px 40px rgba(28, 28, 28, 0.1)' }: InkCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{
|
||||
scale: hoverScale,
|
||||
boxShadow: hoverShadow,
|
||||
y: -4,
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 20,
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useParallax(value: MotionValue<number>, distance: number) {
|
||||
return useTransform(value, [0, 1], [-distance, distance]);
|
||||
|
||||
+777
-40
@@ -43,9 +43,8 @@ const newsFields: FieldDefinition[] = [
|
||||
label: '分类',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: '公司动态', value: 'company' },
|
||||
{ label: '行业资讯', value: 'industry' },
|
||||
{ label: '技术分享', value: 'tech' },
|
||||
{ label: '公司新闻', value: '公司新闻' },
|
||||
{ label: '研发动态', value: '研发动态' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -70,6 +69,19 @@ const newsFields: FieldDefinition[] = [
|
||||
];
|
||||
|
||||
const serviceFields: FieldDefinition[] = [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
label: '服务 ID',
|
||||
required: true,
|
||||
description: 'URL 标识,如 consulting、software',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
label: '服务标题',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
@@ -83,6 +95,26 @@ const serviceFields: FieldDefinition[] = [
|
||||
required: true,
|
||||
description: '图标名称,对应图标库',
|
||||
},
|
||||
{
|
||||
name: 'serviceNumber',
|
||||
type: 'text',
|
||||
label: '服务编号',
|
||||
description: '列表展示编号,如 01、02',
|
||||
},
|
||||
{
|
||||
name: 'color',
|
||||
type: 'text',
|
||||
label: '色条编码',
|
||||
description: '服务卡片/详情页主题色标识',
|
||||
defaultValue: 'brand',
|
||||
options: [
|
||||
{ label: '品牌红', value: 'brand' },
|
||||
{ label: '蓝', value: 'blue' },
|
||||
{ label: '青', value: 'teal' },
|
||||
{ label: '琥珀', value: 'amber' },
|
||||
{ label: '紫', value: 'purple' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'overview',
|
||||
type: 'textarea',
|
||||
@@ -91,31 +123,125 @@ const serviceFields: FieldDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: 'features',
|
||||
type: 'array',
|
||||
type: 'json',
|
||||
label: '核心能力',
|
||||
fields: [{ name: 'name', type: 'text', label: '能力名称', required: true }],
|
||||
required: true,
|
||||
description: 'JSON 字符串数组,如 ["能力标题:能力描述", ...]',
|
||||
},
|
||||
{
|
||||
name: 'benefits',
|
||||
type: 'array',
|
||||
type: 'json',
|
||||
label: '服务价值',
|
||||
fields: [{ name: 'name', type: 'text', label: '价值点', required: true }],
|
||||
required: true,
|
||||
description: 'JSON 字符串数组',
|
||||
},
|
||||
{
|
||||
name: 'capabilities',
|
||||
type: 'json',
|
||||
label: '列表页能力标签',
|
||||
required: true,
|
||||
description: 'JSON 字符串数组,用于服务列表卡片展示',
|
||||
},
|
||||
{
|
||||
name: 'process',
|
||||
type: 'array',
|
||||
type: 'json',
|
||||
label: '服务流程',
|
||||
fields: [{ name: 'step', type: 'text', label: '步骤名称', required: true }],
|
||||
required: true,
|
||||
description: 'JSON 字符串数组,如 ["步骤标题:步骤描述", ...]',
|
||||
},
|
||||
{
|
||||
name: 'heroThemeId',
|
||||
type: 'text',
|
||||
label: 'Hero 主题 ID',
|
||||
description: '关联的 Hero 区域主题配置',
|
||||
defaultValue: 'service',
|
||||
},
|
||||
{
|
||||
name: 'metrics',
|
||||
type: 'array',
|
||||
label: '列表页指标',
|
||||
description: '服务列表卡片展示的关键数据指标',
|
||||
fields: [
|
||||
{ name: 'value', type: 'text', label: '数值', required: true },
|
||||
{ name: 'label', type: 'text', label: '标签', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'caseStudies',
|
||||
type: 'array',
|
||||
label: '服务案例',
|
||||
fields: [
|
||||
{ name: 'client', type: 'text', label: '客户名称', required: true },
|
||||
{ name: 'industry', type: 'text', label: '所属行业', required: true },
|
||||
{ name: 'challenge', type: 'textarea', label: '挑战', required: true },
|
||||
{ name: 'solution', type: 'textarea', label: '解决方案', required: true },
|
||||
{ name: 'result', type: 'textarea', label: '成果', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'dataProofs',
|
||||
type: 'array',
|
||||
label: '数据证明',
|
||||
fields: [
|
||||
{ name: 'metric', type: 'text', label: '指标名称', required: true },
|
||||
{ name: 'value', type: 'text', label: '数值', required: true },
|
||||
{ name: 'description', type: 'text', label: '描述', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'methodology',
|
||||
type: 'array',
|
||||
label: '方法论',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', label: '层级标题', required: true },
|
||||
{ name: 'description', type: 'textarea', label: '层级描述', required: true },
|
||||
{ name: 'items', type: 'json', label: '要点列表', description: 'JSON 字符串数组', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'techStack',
|
||||
type: 'array',
|
||||
label: '技术栈',
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', label: '分类名称', required: true },
|
||||
{ name: 'items', type: 'json', label: '技术项', description: 'JSON 字符串数组', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'faqs',
|
||||
type: 'array',
|
||||
label: '常见问题',
|
||||
fields: [
|
||||
{ name: 'question', type: 'text', label: '问题', required: true },
|
||||
{ name: 'answer', type: 'textarea', label: '回答', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'certifications',
|
||||
type: 'array',
|
||||
label: '资质认证',
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', label: '认证名称', required: true },
|
||||
{ name: 'issuer', type: 'text', label: '颁发机构', required: true },
|
||||
{ name: 'link', type: 'text', label: '链接' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const productFields: FieldDefinition[] = [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
label: '产品 ID',
|
||||
required: true,
|
||||
description: 'URL 标识,如 erp、crm、novavis',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
label: '产品标题',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
@@ -144,6 +270,16 @@ const productFields: FieldDefinition[] = [
|
||||
{ label: '专业产品', value: 'specialized' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'bundle',
|
||||
type: 'text',
|
||||
label: '产品组合',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: '企业套装', value: 'enterprise' },
|
||||
{ label: '独立产品', value: 'standalone' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'text',
|
||||
@@ -161,23 +297,314 @@ const productFields: FieldDefinition[] = [
|
||||
label: '产品概述',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'scenario',
|
||||
type: 'text',
|
||||
label: '适用场景',
|
||||
},
|
||||
{
|
||||
name: 'heroThemeId',
|
||||
type: 'text',
|
||||
label: 'Hero 主题 ID',
|
||||
description: '关联的 Hero 区域主题配置',
|
||||
defaultValue: 'product',
|
||||
},
|
||||
{
|
||||
name: 'features',
|
||||
type: 'array',
|
||||
type: 'json',
|
||||
label: '核心功能',
|
||||
fields: [{ name: 'name', type: 'text', label: '功能名称', required: true }],
|
||||
required: true,
|
||||
description: 'JSON 字符串数组',
|
||||
},
|
||||
{
|
||||
name: 'benefits',
|
||||
type: 'array',
|
||||
type: 'json',
|
||||
label: '产品价值',
|
||||
fields: [{ name: 'name', type: 'text', label: '价值点', required: true }],
|
||||
required: true,
|
||||
description: 'JSON 字符串数组',
|
||||
},
|
||||
{
|
||||
name: 'process',
|
||||
type: 'json',
|
||||
label: '实施流程',
|
||||
required: true,
|
||||
description: 'JSON 字符串数组',
|
||||
},
|
||||
{
|
||||
name: 'specs',
|
||||
type: 'json',
|
||||
label: '技术规格',
|
||||
required: true,
|
||||
description: 'JSON 字符串数组',
|
||||
},
|
||||
{
|
||||
name: 'capabilities',
|
||||
type: 'json',
|
||||
label: '能力标签',
|
||||
description: 'JSON 字符串数组,用于列表卡片展示',
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'array',
|
||||
type: 'json',
|
||||
label: '标签',
|
||||
fields: [{ name: 'name', type: 'text', label: '标签名', required: true }],
|
||||
description: 'JSON 字符串数组',
|
||||
},
|
||||
{
|
||||
name: 'metrics',
|
||||
type: 'array',
|
||||
label: '列表页指标',
|
||||
description: '产品列表卡片展示的关键数据指标',
|
||||
fields: [
|
||||
{ name: 'value', type: 'text', label: '数值', required: true },
|
||||
{ name: 'label', type: 'text', label: '标签', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'caseStudies',
|
||||
type: 'array',
|
||||
label: '客户案例',
|
||||
fields: [
|
||||
{ name: 'client', type: 'text', label: '客户名称', required: true },
|
||||
{ name: 'industry', type: 'text', label: '所属行业', required: true },
|
||||
{ name: 'challenge', type: 'textarea', label: '挑战', required: true },
|
||||
{ name: 'solution', type: 'textarea', label: '解决方案', required: true },
|
||||
{ name: 'result', type: 'textarea', label: '成果', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'dataProofs',
|
||||
type: 'array',
|
||||
label: '数据证明',
|
||||
fields: [
|
||||
{ name: 'metric', type: 'text', label: '指标名称', required: true },
|
||||
{ name: 'value', type: 'text', label: '数值', required: true },
|
||||
{ name: 'description', type: 'text', label: '描述', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'certifications',
|
||||
type: 'array',
|
||||
label: '资质认证',
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', label: '认证名称', required: true },
|
||||
{ name: 'issuer', type: 'text', label: '颁发机构', required: true },
|
||||
{ name: 'link', type: 'text', label: '链接' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'methodology',
|
||||
type: 'array',
|
||||
label: '方法论',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', label: '层级标题', required: true },
|
||||
{ name: 'description', type: 'textarea', label: '层级描述', required: true },
|
||||
{ name: 'items', type: 'json', label: '要点列表', description: 'JSON 字符串数组', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'techStack',
|
||||
type: 'array',
|
||||
label: '技术栈',
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', label: '分类名称', required: true },
|
||||
{ name: 'items', type: 'json', label: '技术项', description: 'JSON 字符串数组', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'faqs',
|
||||
type: 'array',
|
||||
label: '常见问题',
|
||||
fields: [
|
||||
{ name: 'question', type: 'text', label: '问题', required: true },
|
||||
{ name: 'answer', type: 'textarea', label: '回答', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'featured',
|
||||
type: 'boolean',
|
||||
label: '精选产品',
|
||||
defaultValue: false,
|
||||
},
|
||||
];
|
||||
|
||||
const standaloneProductFields: FieldDefinition[] = [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
label: '产品 ID',
|
||||
required: true,
|
||||
description: 'URL 标识,如 novavis',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
label: '产品标题',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: '产品简介',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'image',
|
||||
label: '产品图片',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'category',
|
||||
type: 'text',
|
||||
label: '分类名称',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'text',
|
||||
label: '产品状态',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: '研发中', value: '研发中' },
|
||||
{ label: '内测中', value: '内测中' },
|
||||
{ label: '已发布', value: '已发布' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'overview',
|
||||
type: 'textarea',
|
||||
label: '产品概述',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'scenario',
|
||||
type: 'text',
|
||||
label: '适用场景',
|
||||
},
|
||||
{
|
||||
name: 'heroThemeId',
|
||||
type: 'text',
|
||||
label: 'Hero 主题 ID',
|
||||
description: '关联的 Hero 区域主题配置',
|
||||
defaultValue: 'product',
|
||||
},
|
||||
{
|
||||
name: 'features',
|
||||
type: 'json',
|
||||
label: '核心能力',
|
||||
required: true,
|
||||
description: 'JSON 字符串数组',
|
||||
},
|
||||
{
|
||||
name: 'benefits',
|
||||
type: 'json',
|
||||
label: '产品价值',
|
||||
required: true,
|
||||
description: 'JSON 字符串数组',
|
||||
},
|
||||
{
|
||||
name: 'process',
|
||||
type: 'json',
|
||||
label: '实施流程',
|
||||
required: true,
|
||||
description: 'JSON 字符串数组',
|
||||
},
|
||||
{
|
||||
name: 'specs',
|
||||
type: 'json',
|
||||
label: '技术规格',
|
||||
required: true,
|
||||
description: 'JSON 字符串数组',
|
||||
},
|
||||
{
|
||||
name: 'capabilities',
|
||||
type: 'json',
|
||||
label: '能力标签',
|
||||
description: 'JSON 字符串数组,用于列表卡片展示',
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'json',
|
||||
label: '标签',
|
||||
description: 'JSON 字符串数组',
|
||||
},
|
||||
{
|
||||
name: 'metrics',
|
||||
type: 'array',
|
||||
label: '列表页指标',
|
||||
description: '产品列表卡片展示的关键数据指标',
|
||||
fields: [
|
||||
{ name: 'value', type: 'text', label: '数值', required: true },
|
||||
{ name: 'label', type: 'text', label: '标签', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'caseStudies',
|
||||
type: 'array',
|
||||
label: '客户案例',
|
||||
fields: [
|
||||
{ name: 'client', type: 'text', label: '客户名称', required: true },
|
||||
{ name: 'industry', type: 'text', label: '所属行业', required: true },
|
||||
{ name: 'challenge', type: 'textarea', label: '挑战', required: true },
|
||||
{ name: 'solution', type: 'textarea', label: '解决方案', required: true },
|
||||
{ name: 'result', type: 'textarea', label: '成果', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'dataProofs',
|
||||
type: 'array',
|
||||
label: '数据证明',
|
||||
fields: [
|
||||
{ name: 'metric', type: 'text', label: '指标名称', required: true },
|
||||
{ name: 'value', type: 'text', label: '数值', required: true },
|
||||
{ name: 'description', type: 'text', label: '描述', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'certifications',
|
||||
type: 'array',
|
||||
label: '资质认证',
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', label: '认证名称', required: true },
|
||||
{ name: 'issuer', type: 'text', label: '颁发机构', required: true },
|
||||
{ name: 'link', type: 'text', label: '链接' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'technicalParameters',
|
||||
type: 'array',
|
||||
label: '技术参数',
|
||||
description: '独立产品核心技术与性能指标',
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', label: '参数名称', required: true },
|
||||
{ name: 'value', type: 'text', label: '参数值', required: true },
|
||||
{ name: 'description', type: 'text', label: '参数说明' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'complianceCertifications',
|
||||
type: 'array',
|
||||
label: '合规认证',
|
||||
description: '安全、保密、行业合规认证信息',
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', label: '认证名称', required: true },
|
||||
{ name: 'issuer', type: 'text', label: '颁发机构', required: true },
|
||||
{ name: 'standard', type: 'text', label: '认证标准' },
|
||||
{ name: 'validUntil', type: 'text', label: '有效期至' },
|
||||
{ name: 'link', type: 'text', label: '链接' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'deploymentCases',
|
||||
type: 'array',
|
||||
label: '部署案例',
|
||||
description: '典型部署场景与客户成果',
|
||||
fields: [
|
||||
{ name: 'client', type: 'text', label: '客户名称', required: true },
|
||||
{ name: 'industry', type: 'text', label: '所属行业', required: true },
|
||||
{ name: 'scenario', type: 'textarea', label: '应用场景', required: true },
|
||||
{ name: 'result', type: 'textarea', label: '部署成果', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'featured',
|
||||
@@ -188,6 +615,25 @@ const productFields: FieldDefinition[] = [
|
||||
];
|
||||
|
||||
const solutionFields: FieldDefinition[] = [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
label: '方案 ID',
|
||||
required: true,
|
||||
description: 'URL 标识,如 manufacturing、retail',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
label: '方案标题',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'subtitle',
|
||||
type: 'text',
|
||||
label: '方案副标题',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
@@ -199,11 +645,13 @@ const solutionFields: FieldDefinition[] = [
|
||||
type: 'text',
|
||||
label: '图标标识',
|
||||
required: true,
|
||||
description: '图标名称,对应图标库',
|
||||
},
|
||||
{
|
||||
name: 'industry',
|
||||
type: 'text',
|
||||
label: '适用行业',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'overview',
|
||||
@@ -212,16 +660,96 @@ const solutionFields: FieldDefinition[] = [
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'painPoints',
|
||||
type: 'array',
|
||||
name: 'challenges',
|
||||
type: 'json',
|
||||
label: '行业痛点',
|
||||
fields: [{ name: 'text', type: 'text', label: '痛点描述', required: true }],
|
||||
required: true,
|
||||
description: 'JSON 字符串数组,如 ["痛点一", "痛点二"]',
|
||||
},
|
||||
{
|
||||
name: 'valueProps',
|
||||
name: 'solutions',
|
||||
type: 'json',
|
||||
label: '解决方案',
|
||||
required: true,
|
||||
description: 'JSON 字符串数组,如 ["方案一", "方案二"]',
|
||||
},
|
||||
{
|
||||
name: 'relatedProducts',
|
||||
type: 'json',
|
||||
label: '相关产品',
|
||||
description: 'JSON 字符串数组,关联 product ID,如 ["erp", "bi"]',
|
||||
},
|
||||
{
|
||||
name: 'heroThemeId',
|
||||
type: 'text',
|
||||
label: 'Hero 主题 ID',
|
||||
description: '关联的 Hero 区域主题配置',
|
||||
defaultValue: 'solution',
|
||||
},
|
||||
{
|
||||
name: 'valueProposition',
|
||||
type: 'object',
|
||||
label: '方案价值主张',
|
||||
required: true,
|
||||
fields: [
|
||||
{ name: 'headline', type: 'text', label: '主标题', required: true },
|
||||
{
|
||||
name: 'points',
|
||||
type: 'array',
|
||||
label: '价值点',
|
||||
required: true,
|
||||
fields: [
|
||||
{ name: 'icon', type: 'text', label: '图标', required: true },
|
||||
{ name: 'title', type: 'text', label: '标题', required: true },
|
||||
{ name: 'description', type: 'text', label: '描述', required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'suiteCombination',
|
||||
type: 'object',
|
||||
label: '推荐产品组合',
|
||||
required: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'primaryProducts',
|
||||
type: 'json',
|
||||
label: '核心产品',
|
||||
required: true,
|
||||
description: 'JSON 字符串数组,如 ["erp", "bi"]',
|
||||
},
|
||||
{
|
||||
name: 'complementaryServices',
|
||||
type: 'json',
|
||||
label: '配套服务',
|
||||
required: true,
|
||||
description: 'JSON 字符串数组,如 ["consulting", "solutions"]',
|
||||
},
|
||||
{ name: 'rationale', type: 'textarea', label: '组合 rationale', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'painPoints',
|
||||
type: 'json',
|
||||
label: '列表页痛点',
|
||||
description: 'JSON 字符串数组,用于列表卡片展示',
|
||||
},
|
||||
{
|
||||
name: 'outcomes',
|
||||
type: 'array',
|
||||
label: '方案价值',
|
||||
fields: [{ name: 'text', type: 'text', label: '价值描述', required: true }],
|
||||
label: '列表页成果指标',
|
||||
description: '方案列表卡片展示的关键数据指标',
|
||||
fields: [
|
||||
{ name: 'value', type: 'text', label: '数值', required: true },
|
||||
{ name: 'label', type: 'text', label: '标签', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'recommendedProducts',
|
||||
type: 'json',
|
||||
label: '推荐产品',
|
||||
description: 'JSON 字符串数组,用于列表卡片推荐产品标签,如 ["erp", "bi", "sds"]',
|
||||
},
|
||||
{
|
||||
name: 'featured',
|
||||
@@ -231,27 +759,180 @@ const solutionFields: FieldDefinition[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const caseStudyFields: FieldDefinition[] = [
|
||||
{
|
||||
name: 'client',
|
||||
type: 'text',
|
||||
label: '客户名称',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'industry',
|
||||
type: 'text',
|
||||
label: '所属行业',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'companySize',
|
||||
type: 'text',
|
||||
label: '公司规模',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'subtitle',
|
||||
type: 'text',
|
||||
label: '副标题',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'challenge',
|
||||
type: 'textarea',
|
||||
label: '挑战',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'solution',
|
||||
type: 'textarea',
|
||||
label: '解决方案',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'result',
|
||||
type: 'textarea',
|
||||
label: '实施成果',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
name: 'projectDuration',
|
||||
type: 'text',
|
||||
label: '项目周期',
|
||||
},
|
||||
{
|
||||
name: 'departments',
|
||||
type: 'json',
|
||||
label: '涉及部门',
|
||||
description: 'JSON 数组,如 ["生产", "财务", "供应链"]',
|
||||
},
|
||||
{
|
||||
name: 'dataScale',
|
||||
type: 'text',
|
||||
label: '数据规模',
|
||||
},
|
||||
{
|
||||
name: 'businessProblem',
|
||||
type: 'textarea',
|
||||
label: '具体业务问题',
|
||||
},
|
||||
{
|
||||
name: 'verified',
|
||||
type: 'boolean',
|
||||
label: '客户授权公开',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: 'metrics',
|
||||
type: 'array',
|
||||
label: '关键指标',
|
||||
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: '阶段描述', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'services',
|
||||
type: 'array',
|
||||
label: '相关服务',
|
||||
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: '客户证言',
|
||||
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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const heroBannerFields: FieldDefinition[] = [
|
||||
{
|
||||
name: 'eyebrow',
|
||||
type: 'text',
|
||||
label: '眉标(Badge)',
|
||||
description: '标题上方品牌红标签文字,如「企业数字化转型」',
|
||||
ui: { width: 'full' },
|
||||
},
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
label: '主标题',
|
||||
required: true,
|
||||
description: 'Hero 区核心大标题。「答案优先」:直接给出价值主张,如「让每一家企业都拥有数据驱动的决策能力」',
|
||||
ui: { width: 'full' },
|
||||
},
|
||||
{
|
||||
name: 'subheading',
|
||||
type: 'text',
|
||||
label: '副标题',
|
||||
description: '主标题下方的补充说明,1-2 句话',
|
||||
ui: { width: 'full' },
|
||||
},
|
||||
{
|
||||
name: 'headingTop',
|
||||
type: 'text',
|
||||
label: '顶部小标题',
|
||||
required: true,
|
||||
description: '显示在主标题上方的引导语',
|
||||
label: '顶部小标题(兼容旧版)',
|
||||
description: '已废弃,请使用 eyebrow 和 heading',
|
||||
ui: { width: 'full' },
|
||||
},
|
||||
{
|
||||
name: 'headingBottom',
|
||||
type: 'text',
|
||||
label: '主标题',
|
||||
required: true,
|
||||
description: 'Hero 区核心大标题',
|
||||
label: '主标题(兼容旧版)',
|
||||
description: '已废弃,请使用 heading',
|
||||
ui: { width: 'full' },
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
label: '描述文本',
|
||||
label: '描述文本(兼容旧版)',
|
||||
description: '已废弃,请使用 subheading',
|
||||
},
|
||||
{
|
||||
name: 'ctaLabel',
|
||||
@@ -265,17 +946,28 @@ const heroBannerFields: FieldDefinition[] = [
|
||||
label: '主按钮链接',
|
||||
defaultValue: '/contact',
|
||||
},
|
||||
{
|
||||
name: 'secondaryCtaLabel',
|
||||
type: 'text',
|
||||
label: '次按钮文案',
|
||||
description: '如「了解我们的方法论」',
|
||||
},
|
||||
{
|
||||
name: 'secondaryCtaHref',
|
||||
type: 'text',
|
||||
label: '次按钮链接',
|
||||
description: '次按钮跳转地址',
|
||||
},
|
||||
{
|
||||
name: 'statValue',
|
||||
type: 'text',
|
||||
label: '统计数值',
|
||||
description: 'Hero 区右侧可选数据指标,如「500+」',
|
||||
label: '统计数值(兼容旧版)',
|
||||
description: '已废弃,Hero 数据指标请使用 stat-item 内容类型',
|
||||
},
|
||||
{
|
||||
name: 'statSubtext',
|
||||
type: 'text',
|
||||
label: '统计说明',
|
||||
description: '与统计数值配套的说明文字',
|
||||
label: '统计说明(兼容旧版)',
|
||||
},
|
||||
{
|
||||
name: 'ctaTitleLine1',
|
||||
@@ -830,14 +1522,11 @@ const legalPageFields: FieldDefinition[] = [
|
||||
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 },
|
||||
],
|
||||
name: 'content',
|
||||
type: 'richtext',
|
||||
label: '页面正文(HTML)',
|
||||
required: true,
|
||||
description: '隐私政策/服务条款完整正文,支持 HTML 标签',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -910,6 +1599,29 @@ const productConfig: ContentTypeConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const standaloneProductConfig: ContentTypeConfig = {
|
||||
model: {
|
||||
id: 'model-standalone-product',
|
||||
code: 'standalone-product',
|
||||
name: '独立产品',
|
||||
description: '自成体系的独立产品线,支持技术参数与合规认证等硬核字段',
|
||||
fields: standaloneProductFields,
|
||||
isPageType: true,
|
||||
urlPattern: '/products/standalone/{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/standalone/{slug}',
|
||||
},
|
||||
};
|
||||
|
||||
const solutionConfig: ContentTypeConfig = {
|
||||
model: {
|
||||
id: 'model-solution',
|
||||
@@ -933,6 +1645,29 @@ const solutionConfig: ContentTypeConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const caseStudyConfig: ContentTypeConfig = {
|
||||
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',
|
||||
},
|
||||
listPage: {
|
||||
route: '/cases',
|
||||
},
|
||||
detailPage: {
|
||||
routePattern: '/cases/{slug}',
|
||||
},
|
||||
};
|
||||
|
||||
const heroBannerConfig: ContentTypeConfig = {
|
||||
model: {
|
||||
id: 'model-hero-banner',
|
||||
@@ -1038,7 +1773,9 @@ export const CONTENT_TYPE_CONFIGS = {
|
||||
news: newsConfig,
|
||||
service: serviceConfig,
|
||||
product: productConfig,
|
||||
'standalone-product': standaloneProductConfig,
|
||||
solution: solutionConfig,
|
||||
'case-study': caseStudyConfig,
|
||||
'hero-banner': heroBannerConfig,
|
||||
'stat-item': statItemConfig,
|
||||
'about-page': aboutPageConfig,
|
||||
|
||||
@@ -273,6 +273,25 @@ describe('data-server - CMS 数据访问层', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('locale support', () => {
|
||||
it('should return locale from content item', async () => {
|
||||
mockContentItem.findMany.mockResolvedValue([{ ...mockPrismaItem, locale: 'zh-CN' }]);
|
||||
|
||||
const result = await dataServer.getPublishedItems('product');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.locale).toBe('zh-CN');
|
||||
});
|
||||
|
||||
it('should default locale to zh-CN when not present in database', async () => {
|
||||
mockContentItem.findMany.mockResolvedValue([mockPrismaItem]);
|
||||
|
||||
const result = await dataServer.getPublishedItems('product');
|
||||
|
||||
expect(result[0]!.locale).toBe('zh-CN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllPublishedSlugs', () => {
|
||||
it('should fetch all slugs for published items', async () => {
|
||||
mockContentItem.findMany.mockResolvedValue([
|
||||
@@ -377,12 +396,125 @@ describe('data-server - CMS 数据访问层', () => {
|
||||
expect(spy).toHaveBeenCalledWith('news', 'test-news');
|
||||
});
|
||||
|
||||
it('getHomePageZones should call getPageZones with home', async () => {
|
||||
const spy = jest.spyOn(dataServer, 'getPageZones');
|
||||
spy.mockResolvedValue([]);
|
||||
it('getServiceBySlug should call getPublishedItemBySlug with service', async () => {
|
||||
const spy = jest.spyOn(dataServer, 'getPublishedItemBySlug');
|
||||
spy.mockResolvedValue(null);
|
||||
|
||||
await dataServer.getHomePageZones();
|
||||
expect(spy).toHaveBeenCalledWith('home');
|
||||
await dataServer.getServiceBySlug('test-service');
|
||||
expect(spy).toHaveBeenCalledWith('service', 'test-service');
|
||||
});
|
||||
|
||||
it('getProductBySlug should call getPublishedItemBySlug with product', async () => {
|
||||
const spy = jest.spyOn(dataServer, 'getPublishedItemBySlug');
|
||||
spy.mockResolvedValue(null);
|
||||
|
||||
await dataServer.getProductBySlug('test-product');
|
||||
expect(spy).toHaveBeenCalledWith('product', 'test-product');
|
||||
});
|
||||
|
||||
it('getSolutionBySlug should call getPublishedItemBySlug with solution', async () => {
|
||||
const spy = jest.spyOn(dataServer, 'getPublishedItemBySlug');
|
||||
spy.mockResolvedValue(null);
|
||||
|
||||
await dataServer.getSolutionBySlug('test-solution');
|
||||
expect(spy).toHaveBeenCalledWith('solution', 'test-solution');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResolvedHomeZones', () => {
|
||||
function createZoneOverride(code: string, items: Array<{ itemId?: string; sortOrder?: number; variant?: string }>, zoneKey?: string) {
|
||||
return {
|
||||
...mockPrismaZone,
|
||||
code,
|
||||
name: `${code} Zone`,
|
||||
zoneKey,
|
||||
allowedModels: JSON.stringify(['hero-banner']),
|
||||
items: JSON.stringify(items),
|
||||
settings: JSON.stringify({ layout: 'carousel' }),
|
||||
};
|
||||
}
|
||||
|
||||
it('should return empty object when no zones configured', async () => {
|
||||
mockContentZone.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await dataServer.getResolvedHomeZones();
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(mockContentItem.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty arrays when zone items reference no published content', async () => {
|
||||
mockContentZone.findMany.mockResolvedValue([
|
||||
createZoneOverride('home-hero', [{ itemId: 'hero-1', sortOrder: 1, variant: 'default' }]),
|
||||
]);
|
||||
mockContentItem.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await dataServer.getResolvedHomeZones();
|
||||
|
||||
expect(result).toEqual({ 'home-hero': [] });
|
||||
});
|
||||
|
||||
it('should resolve published items for each zone using zoneKey when available', async () => {
|
||||
mockContentZone.findMany.mockResolvedValue([
|
||||
createZoneOverride('home-hero', [{ itemId: 'hero-1', sortOrder: 1, variant: 'default' }], 'hero'),
|
||||
]);
|
||||
mockContentItem.findMany.mockResolvedValue([
|
||||
{
|
||||
...mockPrismaItem,
|
||||
id: 'hero-1',
|
||||
modelCode: 'hero-banner',
|
||||
title: 'Hero Banner',
|
||||
slug: 'hero-banner',
|
||||
data: JSON.stringify({ headline: 'Welcome' }),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await dataServer.getResolvedHomeZones();
|
||||
|
||||
expect(result).toHaveProperty('hero');
|
||||
expect(result['hero']).toHaveLength(1);
|
||||
expect(result['hero']![0]!.title).toBe('Hero Banner');
|
||||
expect(result['hero']![0]!.data).toEqual({ headline: 'Welcome' });
|
||||
});
|
||||
|
||||
it('should filter out items missing itemId', async () => {
|
||||
mockContentZone.findMany.mockResolvedValue([
|
||||
createZoneOverride('home-hero', [
|
||||
{ sortOrder: 1, variant: 'default' },
|
||||
{ itemId: 'hero-2', sortOrder: 2, variant: 'default' },
|
||||
]),
|
||||
]);
|
||||
mockContentItem.findMany.mockResolvedValue([
|
||||
{
|
||||
...mockPrismaItem,
|
||||
id: 'hero-2',
|
||||
modelCode: 'hero-banner',
|
||||
title: 'Second Banner',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await dataServer.getResolvedHomeZones();
|
||||
|
||||
expect(result['home-hero']).toHaveLength(1);
|
||||
expect(result['home-hero']![0]!.id).toBe('hero-2');
|
||||
});
|
||||
|
||||
it('should handle multiple zones', async () => {
|
||||
mockContentZone.findMany.mockResolvedValue([
|
||||
createZoneOverride('home-services', [{ itemId: 'svc-1', sortOrder: 1 }]),
|
||||
createZoneOverride('home-solutions', [{ itemId: 'sol-1', sortOrder: 1 }]),
|
||||
]);
|
||||
mockContentItem.findMany.mockResolvedValue([
|
||||
{ ...mockPrismaItem, id: 'svc-1', modelCode: 'service', title: 'Service A' },
|
||||
{ ...mockPrismaItem, id: 'sol-1', modelCode: 'solution', title: 'Solution B' },
|
||||
]);
|
||||
|
||||
const result = await dataServer.getResolvedHomeZones();
|
||||
|
||||
expect(result['home-services']).toHaveLength(1);
|
||||
expect(result['home-services']![0]!.title).toBe('Service A');
|
||||
expect(result['home-solutions']).toHaveLength(1);
|
||||
expect(result['home-solutions']![0]!.title).toBe('Solution B');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ function toContentItem(item: Record<string, unknown>): ContentItem {
|
||||
modelCode: item.modelCode as string,
|
||||
title: item.title as string,
|
||||
slug: (item.slug as string | null) ?? undefined,
|
||||
locale: (item.locale as string | null) ?? 'zh-CN',
|
||||
status: item.status as ContentStatus,
|
||||
data: JSON.parse(item.data as string),
|
||||
version: item.version as number,
|
||||
@@ -160,6 +161,40 @@ export async function getHomePageZones() {
|
||||
return getPageZones('home');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取首页各 Zone 并解析引用的已发布内容条目
|
||||
* 返回以 zoneKey 为键的内容条目数组映射
|
||||
*/
|
||||
export async function getResolvedHomeZones(): Promise<Record<string, ContentItem[]>> {
|
||||
const zones = await getHomePageZones();
|
||||
const result: Record<string, ContentItem[]> = {};
|
||||
|
||||
if (zones.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const itemIds = zones
|
||||
.flatMap((z) => z.items.map((i: { itemId?: string }) => i.itemId).filter(Boolean));
|
||||
|
||||
if (itemIds.length > 0) {
|
||||
const items = await prisma.contentItem.findMany({
|
||||
where: { id: { in: itemIds as string[] }, status: 'published' },
|
||||
});
|
||||
const itemMap = new Map(
|
||||
items.map((item) => [item.id, toContentItem(item as unknown as Record<string, unknown>)]),
|
||||
);
|
||||
|
||||
for (const zone of zones) {
|
||||
const key = zone.zoneKey || zone.code;
|
||||
result[key] = zone.items
|
||||
.map((zi: { itemId?: string }) => (zi.itemId ? itemMap.get(zi.itemId) : undefined))
|
||||
.filter(Boolean) as ContentItem[];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============ 静态生成辅助 ============
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
// ─── Mock @/lib/db 提供可控的 prisma 实例 ──────────────────────────────
|
||||
const mockNotificationCreate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockNotificationCreateMany = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockNotificationFindMany = jest.fn<(args: unknown) => Promise<unknown[]>>();
|
||||
const mockNotificationCount = jest.fn<(args: unknown) => Promise<number>>();
|
||||
const mockNotificationUpdate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockNotificationUpdateMany = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockPermissionFindMany = jest.fn<(args: unknown) => Promise<unknown[]>>();
|
||||
const mockUserRoleFindMany = jest.fn<(args: unknown) => Promise<unknown[]>>();
|
||||
const mockUserFindUnique = jest.fn<(args: unknown) => Promise<unknown | null>>();
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
notification: {
|
||||
create: mockNotificationCreate,
|
||||
createMany: mockNotificationCreateMany,
|
||||
findMany: mockNotificationFindMany,
|
||||
count: mockNotificationCount,
|
||||
update: mockNotificationUpdate,
|
||||
updateMany: mockNotificationUpdateMany,
|
||||
},
|
||||
permission: {
|
||||
findMany: mockPermissionFindMany,
|
||||
},
|
||||
userRole: {
|
||||
findMany: mockUserRoleFindMany,
|
||||
},
|
||||
user: {
|
||||
findUnique: mockUserFindUnique,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.unmock('./notifications');
|
||||
|
||||
import {
|
||||
createNotification,
|
||||
getUserNotifications,
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
getUnreadCount,
|
||||
findUsersWithPermission,
|
||||
notifyReviewers,
|
||||
notifyCreator,
|
||||
} from './notifications';
|
||||
import type { ContentItem } from './types';
|
||||
|
||||
const mockItem = {
|
||||
id: 'item-1',
|
||||
modelCode: 'news',
|
||||
title: '测试新闻',
|
||||
createdBy: 'editor',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createNotification', () => {
|
||||
it('creates notification with related item info', async () => {
|
||||
mockNotificationCreate.mockResolvedValue({
|
||||
id: 'notif-1',
|
||||
userId: 'user-1',
|
||||
type: 'review_pending',
|
||||
title: '待审核',
|
||||
message: 'test',
|
||||
relatedItemId: 'item-1',
|
||||
relatedModelCode: 'news',
|
||||
read: false,
|
||||
});
|
||||
|
||||
const result = await createNotification({
|
||||
userId: 'user-1',
|
||||
type: 'review_pending',
|
||||
title: '待审核',
|
||||
message: 'test',
|
||||
relatedItemId: 'item-1',
|
||||
relatedModelCode: 'news',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
userId: 'user-1',
|
||||
type: 'review_pending',
|
||||
relatedItemId: 'item-1',
|
||||
relatedModelCode: 'news',
|
||||
});
|
||||
expect(mockNotificationCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
type: 'review_pending',
|
||||
relatedItemId: 'item-1',
|
||||
relatedModelCode: 'news',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserNotifications', () => {
|
||||
it('returns paginated notifications ordered by createdAt desc', async () => {
|
||||
mockNotificationFindMany.mockResolvedValue([
|
||||
{ id: 'notif-1', userId: 'user-1', read: false },
|
||||
{ id: 'notif-2', userId: 'user-1', read: true },
|
||||
]);
|
||||
mockNotificationCount.mockResolvedValue(2);
|
||||
|
||||
const result = await getUserNotifications('user-1', { page: 1, pageSize: 10 });
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.totalPages).toBe(1);
|
||||
expect(mockNotificationFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: 'user-1' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('filters unread notifications when unreadOnly is true', async () => {
|
||||
mockNotificationFindMany.mockResolvedValue([{ id: 'notif-1', read: false }]);
|
||||
mockNotificationCount.mockResolvedValue(1);
|
||||
|
||||
await getUserNotifications('user-1', { unreadOnly: true });
|
||||
|
||||
expect(mockNotificationFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: 'user-1', read: false },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markNotificationAsRead', () => {
|
||||
it('updates read status for owned notification', async () => {
|
||||
mockNotificationUpdate.mockResolvedValue({ id: 'notif-1', read: true });
|
||||
|
||||
const result = await markNotificationAsRead('notif-1', 'user-1');
|
||||
|
||||
expect(result.read).toBe(true);
|
||||
expect(mockNotificationUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'notif-1', userId: 'user-1' },
|
||||
data: { read: true },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAllNotificationsAsRead', () => {
|
||||
it('updates all unread notifications for user', async () => {
|
||||
mockNotificationUpdateMany.mockResolvedValue({ count: 3 });
|
||||
|
||||
const result = await markAllNotificationsAsRead('user-1');
|
||||
|
||||
expect(result).toBe(3);
|
||||
expect(mockNotificationUpdateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: 'user-1', read: false },
|
||||
data: { read: true },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnreadCount', () => {
|
||||
it('returns count of unread notifications', async () => {
|
||||
mockNotificationCount.mockResolvedValue(5);
|
||||
|
||||
const result = await getUnreadCount('user-1');
|
||||
|
||||
expect(result).toBe(5);
|
||||
expect(mockNotificationCount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: 'user-1', read: false },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUsersWithPermission', () => {
|
||||
it('returns unique user ids with publish permission for model', async () => {
|
||||
mockPermissionFindMany.mockResolvedValue([
|
||||
{ roleCode: 'content_admin' },
|
||||
{ roleCode: 'reviewer' },
|
||||
]);
|
||||
mockUserRoleFindMany.mockResolvedValue([
|
||||
{ userId: 'user-1' },
|
||||
{ userId: 'user-2' },
|
||||
{ userId: 'user-1' },
|
||||
]);
|
||||
|
||||
const result = await findUsersWithPermission('news', 'publish');
|
||||
|
||||
expect(result).toEqual(['user-1', 'user-2']);
|
||||
expect(mockPermissionFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { modelCode: 'news', action: 'publish' },
|
||||
select: { roleCode: true },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty array when no roles have permission', async () => {
|
||||
mockPermissionFindMany.mockResolvedValue([]);
|
||||
|
||||
const result = await findUsersWithPermission('news', 'publish');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockUserRoleFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyReviewers', () => {
|
||||
it('creates review_pending notifications for reviewers excluding actor', async () => {
|
||||
mockPermissionFindMany.mockResolvedValue([{ roleCode: 'reviewer' }]);
|
||||
mockUserRoleFindMany.mockResolvedValue([
|
||||
{ userId: 'actor-id' },
|
||||
{ userId: 'reviewer-1' },
|
||||
]);
|
||||
mockNotificationCreateMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
const count = await notifyReviewers(mockItem as ContentItem, {
|
||||
userId: 'actor-id',
|
||||
username: 'editor',
|
||||
});
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(mockNotificationCreateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
userId: 'reviewer-1',
|
||||
type: 'review_pending',
|
||||
title: '待审核内容',
|
||||
message: 'editor 提交了《测试新闻》,请尽快审核。',
|
||||
relatedItemId: 'item-1',
|
||||
relatedModelCode: 'news',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 0 when no reviewers found', async () => {
|
||||
mockPermissionFindMany.mockResolvedValue([]);
|
||||
|
||||
const count = await notifyReviewers(mockItem as ContentItem, {
|
||||
userId: 'actor-id',
|
||||
username: 'editor',
|
||||
});
|
||||
|
||||
expect(count).toBe(0);
|
||||
expect(mockNotificationCreateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyCreator', () => {
|
||||
it('creates approved notification for creator', async () => {
|
||||
mockUserFindUnique.mockResolvedValue({ id: 'creator-id', username: 'editor' });
|
||||
mockNotificationCreate.mockResolvedValue({ id: 'notif-1' });
|
||||
|
||||
const result = await notifyCreator(
|
||||
mockItem as ContentItem,
|
||||
'review_approved',
|
||||
{ userId: 'reviewer-id', username: 'reviewer' }
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ id: 'notif-1' });
|
||||
expect(mockNotificationCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: 'creator-id',
|
||||
type: 'review_approved',
|
||||
title: '内容审核通过',
|
||||
message: '《测试新闻》已通过审核并发布。',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('creates rejected notification with reason for creator', async () => {
|
||||
mockUserFindUnique.mockResolvedValue({ id: 'creator-id', username: 'editor' });
|
||||
mockNotificationCreate.mockResolvedValue({ id: 'notif-1' });
|
||||
|
||||
await notifyCreator(
|
||||
mockItem as ContentItem,
|
||||
'review_rejected',
|
||||
{ userId: 'reviewer-id', username: 'reviewer' },
|
||||
'需要修改标题'
|
||||
);
|
||||
|
||||
expect(mockNotificationCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
type: 'review_rejected',
|
||||
title: '内容被驳回',
|
||||
message: '《测试新闻》未通过审核,原因:需要修改标题',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when creator user not found', async () => {
|
||||
mockUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await notifyCreator(
|
||||
mockItem as ContentItem,
|
||||
'review_rejected',
|
||||
{ userId: 'reviewer-id', username: 'reviewer' },
|
||||
'需要修改'
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockNotificationCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when actor is the creator', async () => {
|
||||
mockUserFindUnique.mockResolvedValue({ id: 'creator-id', username: 'editor' });
|
||||
|
||||
const result = await notifyCreator(
|
||||
mockItem as ContentItem,
|
||||
'review_approved',
|
||||
{ userId: 'creator-id', username: 'editor' }
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockNotificationCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import type { Notification as PrismaNotification } from '@/generated/prisma/client';
|
||||
import type { ContentItem } from './types';
|
||||
|
||||
export type NotificationType = 'review_pending' | 'review_approved' | 'review_rejected' | 'item_archived';
|
||||
|
||||
export interface NotificationCreateInput {
|
||||
userId: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
relatedItemId?: string;
|
||||
relatedModelCode?: string;
|
||||
}
|
||||
|
||||
export interface NotificationListOptions {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
unreadOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedNotifications {
|
||||
items: PrismaNotification[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单条站内通知
|
||||
*/
|
||||
export async function createNotification(input: NotificationCreateInput): Promise<PrismaNotification> {
|
||||
return prisma.notification.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
relatedItemId: input.relatedItemId || '',
|
||||
relatedModelCode: input.relatedModelCode || '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定用户的通知列表,支持分页与未读筛选
|
||||
*/
|
||||
export async function getUserNotifications(
|
||||
userId: string,
|
||||
options: NotificationListOptions = {}
|
||||
): Promise<PaginatedNotifications> {
|
||||
const { page = 1, pageSize = 20, unreadOnly = false } = options;
|
||||
const where = { userId, ...(unreadOnly ? { read: false } : {}) };
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.notification.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.notification.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items as PrismaNotification[],
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单条通知标记为已读(仅允许通知所有者操作)
|
||||
*/
|
||||
export async function markNotificationAsRead(id: string, userId: string): Promise<PrismaNotification> {
|
||||
return prisma.notification.update({
|
||||
where: { id, userId },
|
||||
data: { read: true },
|
||||
}) as Promise<PrismaNotification>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指定用户的全部未读通知标记为已读
|
||||
*/
|
||||
export async function markAllNotificationsAsRead(userId: string): Promise<number> {
|
||||
const result = await prisma.notification.updateMany({
|
||||
where: { userId, read: false },
|
||||
data: { read: true },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定用户未读通知数量
|
||||
*/
|
||||
export async function getUnreadCount(userId: string): Promise<number> {
|
||||
return prisma.notification.count({
|
||||
where: { userId, read: false },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找具备指定模型和操作的用户的 userId 列表
|
||||
*/
|
||||
export async function findUsersWithPermission(
|
||||
modelCode: string,
|
||||
action: 'publish' | 'update' | 'delete' | 'create' | 'read'
|
||||
): Promise<string[]> {
|
||||
const permissions = await prisma.permission.findMany({
|
||||
where: { modelCode, action },
|
||||
select: { roleCode: true },
|
||||
});
|
||||
const roleCodes = permissions.map((p) => (p as { roleCode: string }).roleCode);
|
||||
|
||||
if (roleCodes.length === 0) return [];
|
||||
|
||||
const userRoles = await prisma.userRole.findMany({
|
||||
where: { roleCode: { in: roleCodes } },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
return [...new Set(userRoles.map((ur) => (ur as { userId: string }).userId))];
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容提交审核时,向具备 publish 权限的审核人生成待审核通知
|
||||
*/
|
||||
export async function notifyReviewers(
|
||||
item: ContentItem,
|
||||
actor: { userId: string; username: string }
|
||||
): Promise<number> {
|
||||
const reviewerIds = await findUsersWithPermission(item.modelCode, 'publish');
|
||||
const targetIds = reviewerIds.filter((id) => id !== actor.userId);
|
||||
|
||||
if (targetIds.length === 0) return 0;
|
||||
|
||||
await prisma.notification.createMany({
|
||||
data: targetIds.map((userId) => ({
|
||||
userId,
|
||||
type: 'review_pending' as const,
|
||||
title: '待审核内容',
|
||||
message: `${actor.username} 提交了《${item.title}》,请尽快审核。`,
|
||||
relatedItemId: item.id,
|
||||
relatedModelCode: item.modelCode,
|
||||
})),
|
||||
});
|
||||
|
||||
return targetIds.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过/驳回/归档时,向内容创建人生成结果通知
|
||||
*/
|
||||
export async function notifyCreator(
|
||||
item: ContentItem,
|
||||
type: 'review_approved' | 'review_rejected' | 'item_archived',
|
||||
actor: { userId: string; username: string },
|
||||
reason?: string
|
||||
): Promise<PrismaNotification | null> {
|
||||
const creator = await prisma.user.findUnique({
|
||||
where: { username: item.createdBy },
|
||||
});
|
||||
|
||||
if (!creator) return null;
|
||||
if (creator.id === actor.userId) return null;
|
||||
|
||||
const titleMap: Record<typeof type, string> = {
|
||||
review_approved: '内容审核通过',
|
||||
review_rejected: '内容被驳回',
|
||||
item_archived: '内容已归档',
|
||||
};
|
||||
|
||||
const messageMap: Record<typeof type, string> = {
|
||||
review_approved: `《${item.title}》已通过审核并发布。`,
|
||||
review_rejected: `《${item.title}》未通过审核${reason ? `,原因:${reason}` : '。'}`,
|
||||
item_archived: `《${item.title}》已被归档。`,
|
||||
};
|
||||
|
||||
return createNotification({
|
||||
userId: creator.id,
|
||||
type,
|
||||
title: titleMap[type],
|
||||
message: messageMap[type],
|
||||
relatedItemId: item.id,
|
||||
relatedModelCode: item.modelCode,
|
||||
});
|
||||
}
|
||||
@@ -86,6 +86,7 @@ export interface ContentItem {
|
||||
modelCode: string;
|
||||
title: string;
|
||||
slug?: string;
|
||||
locale: string;
|
||||
status: ContentStatus;
|
||||
data: Record<string, unknown>;
|
||||
version: number;
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import type { ContentItem } from './types';
|
||||
|
||||
// ─── Mock @/lib/db 提供可控的 prisma 实例 ──────────────────────────────
|
||||
const mockContentItemFindUnique = jest.fn<(args: unknown) => Promise<unknown | null>>();
|
||||
const mockContentItemUpdate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockAuditLogCreate = jest.fn<(args: unknown) => Promise<unknown>>();
|
||||
const mockNotifyReviewers = jest.fn<(item: ContentItem, actor: { userId: string; username: string }) => Promise<number>>();
|
||||
const mockNotifyCreator = jest.fn<
|
||||
(
|
||||
item: ContentItem,
|
||||
type: 'review_approved' | 'review_rejected' | 'item_archived',
|
||||
actor: { userId: string; username: string },
|
||||
reason?: string
|
||||
) => Promise<unknown | null>
|
||||
>();
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
contentItem: {
|
||||
findUnique: mockContentItemFindUnique,
|
||||
update: mockContentItemUpdate,
|
||||
},
|
||||
auditLog: {
|
||||
create: mockAuditLogCreate,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./notifications', () => ({
|
||||
notifyReviewers: mockNotifyReviewers,
|
||||
notifyCreator: mockNotifyCreator,
|
||||
}));
|
||||
|
||||
jest.unmock('./workflow');
|
||||
|
||||
import {
|
||||
submitForReview,
|
||||
approve,
|
||||
reject,
|
||||
archive,
|
||||
isValidTransition,
|
||||
WorkflowAction,
|
||||
} from './workflow';
|
||||
|
||||
const mockUser = { userId: 'user-1', username: 'editor' };
|
||||
const mockReviewer = { userId: 'user-2', username: 'reviewer' };
|
||||
|
||||
function createMockItem(status: ContentItem['status'], overrides: Partial<ContentItem> = {}) {
|
||||
return {
|
||||
id: 'item-1',
|
||||
modelId: 'model-1',
|
||||
modelCode: 'news',
|
||||
title: '测试内容',
|
||||
slug: 'test-news',
|
||||
locale: 'zh-CN',
|
||||
status,
|
||||
data: { body: 'hello' },
|
||||
version: 1,
|
||||
sortOrder: 0,
|
||||
createdBy: 'editor',
|
||||
updatedBy: 'editor',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
...overrides,
|
||||
} as ContentItem;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockContentItemUpdate.mockImplementation(async (args: unknown) => {
|
||||
const { data, where } = args as { data: Record<string, unknown>; where: { id: string } };
|
||||
return { id: where.id, ...data };
|
||||
});
|
||||
mockAuditLogCreate.mockResolvedValue({ id: 'log-1' });
|
||||
mockNotifyReviewers.mockResolvedValue(1);
|
||||
mockNotifyCreator.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe('isValidTransition', () => {
|
||||
it.each<[ContentItem['status'], WorkflowAction, boolean]>([
|
||||
['draft', 'submit', true],
|
||||
['review', 'approve', true],
|
||||
['review', 'reject', true],
|
||||
['published', 'archive', true],
|
||||
['draft', 'approve', false],
|
||||
['draft', 'reject', false],
|
||||
['draft', 'archive', false],
|
||||
['review', 'submit', false],
|
||||
['review', 'archive', false],
|
||||
['published', 'submit', false],
|
||||
['published', 'approve', false],
|
||||
['published', 'reject', false],
|
||||
['archived', 'submit', false],
|
||||
['archived', 'approve', false],
|
||||
['archived', 'reject', false],
|
||||
['archived', 'archive', false],
|
||||
])('%s + %s => %s', (from, action, expected) => {
|
||||
expect(isValidTransition(from, action)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitForReview', () => {
|
||||
it('transitions draft to review and increments version', async () => {
|
||||
mockContentItemFindUnique.mockResolvedValue(createMockItem('draft'));
|
||||
|
||||
const result = await submitForReview('item-1', mockUser);
|
||||
|
||||
expect(result.status).toBe('review');
|
||||
expect(result.version).toBe(2);
|
||||
expect(mockContentItemUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'item-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'review',
|
||||
version: 2,
|
||||
updatedBy: 'editor',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockAuditLogCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
module: 'item',
|
||||
targetId: 'item-1',
|
||||
action: 'update',
|
||||
operator: 'editor',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockNotifyReviewers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'item-1', title: '测试内容' }),
|
||||
mockUser
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when item not found', async () => {
|
||||
mockContentItemFindUnique.mockResolvedValue(null);
|
||||
await expect(submitForReview('missing', mockUser)).rejects.toThrow('内容不存在');
|
||||
});
|
||||
|
||||
it('throws for illegal transition', async () => {
|
||||
mockContentItemFindUnique.mockResolvedValue(createMockItem('published'));
|
||||
await expect(submitForReview('item-1', mockUser)).rejects.toThrow(
|
||||
'当前状态 published 不允许提交审核'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approve', () => {
|
||||
it('transitions review to published and sets publishedAt', async () => {
|
||||
mockContentItemFindUnique.mockResolvedValue(createMockItem('review'));
|
||||
|
||||
const result = await approve('item-1', mockReviewer);
|
||||
|
||||
expect(result.status).toBe('published');
|
||||
expect(result.version).toBe(2);
|
||||
expect(mockContentItemUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'published',
|
||||
publishedAt: expect.any(Date),
|
||||
version: 2,
|
||||
updatedBy: 'reviewer',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockNotifyCreator).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'item-1', title: '测试内容' }),
|
||||
'review_approved',
|
||||
mockReviewer
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for non-review item', async () => {
|
||||
mockContentItemFindUnique.mockResolvedValue(createMockItem('draft'));
|
||||
await expect(approve('item-1', mockReviewer)).rejects.toThrow(
|
||||
'当前状态 draft 不允许审核通过'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reject', () => {
|
||||
it('transitions review back to draft', async () => {
|
||||
mockContentItemFindUnique.mockResolvedValue(createMockItem('review'));
|
||||
|
||||
const result = await reject('item-1', mockReviewer, '需要补充数据来源');
|
||||
|
||||
expect(result.status).toBe('draft');
|
||||
expect(result.version).toBe(2);
|
||||
expect(mockAuditLogCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
afterData: expect.stringContaining('需要补充数据来源'),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockNotifyCreator).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'item-1', title: '测试内容' }),
|
||||
'review_rejected',
|
||||
mockReviewer,
|
||||
'需要补充数据来源'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for non-review item', async () => {
|
||||
mockContentItemFindUnique.mockResolvedValue(createMockItem('published'));
|
||||
await expect(reject('item-1', mockReviewer)).rejects.toThrow(
|
||||
'当前状态 published 不允许驳回'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archive', () => {
|
||||
it('transitions published to archived', async () => {
|
||||
mockContentItemFindUnique.mockResolvedValue(createMockItem('published'));
|
||||
|
||||
const result = await archive('item-1', mockReviewer);
|
||||
|
||||
expect(result.status).toBe('archived');
|
||||
expect(result.version).toBe(2);
|
||||
expect(mockNotifyCreator).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'item-1', title: '测试内容' }),
|
||||
'item_archived',
|
||||
mockReviewer
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for non-published item', async () => {
|
||||
mockContentItemFindUnique.mockResolvedValue(createMockItem('review'));
|
||||
await expect(archive('item-1', mockReviewer)).rejects.toThrow(
|
||||
'当前状态 review 不允许归档'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import type { ContentItem, ContentStatus } from './types';
|
||||
import { notifyReviewers, notifyCreator } from './notifications';
|
||||
|
||||
export type WorkflowAction = 'submit' | 'approve' | 'reject' | 'archive';
|
||||
|
||||
interface WorkflowUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface WorkflowResult {
|
||||
id: string;
|
||||
status: ContentStatus;
|
||||
version: number;
|
||||
publishedAt?: Date | null;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
const VALID_TRANSITIONS: Record<ContentStatus, WorkflowAction[]> = {
|
||||
draft: ['submit'],
|
||||
review: ['approve', 'reject'],
|
||||
published: ['archive'],
|
||||
archived: [],
|
||||
};
|
||||
|
||||
const TARGET_STATUS: Record<WorkflowAction, ContentStatus> = {
|
||||
submit: 'review',
|
||||
approve: 'published',
|
||||
reject: 'draft',
|
||||
archive: 'archived',
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<WorkflowAction, string> = {
|
||||
submit: '提交审核',
|
||||
approve: '审核通过',
|
||||
reject: '驳回',
|
||||
archive: '归档',
|
||||
};
|
||||
|
||||
export function isValidTransition(from: ContentStatus, action: WorkflowAction): boolean {
|
||||
return VALID_TRANSITIONS[from]?.includes(action) ?? false;
|
||||
}
|
||||
|
||||
async function fetchItem(itemId: string) {
|
||||
const item = await prisma.contentItem.findUnique({ where: { id: itemId } });
|
||||
if (!item) {
|
||||
throw new Error('内容不存在');
|
||||
}
|
||||
return item as unknown as ContentItem;
|
||||
}
|
||||
|
||||
async function transition(
|
||||
itemId: string,
|
||||
action: WorkflowAction,
|
||||
user: WorkflowUser,
|
||||
reason?: string
|
||||
): Promise<WorkflowResult> {
|
||||
const item = await fetchItem(itemId);
|
||||
|
||||
if (!isValidTransition(item.status, action)) {
|
||||
throw new Error(`当前状态 ${item.status} 不允许${ACTION_LABELS[action]}`);
|
||||
}
|
||||
|
||||
const targetStatus = TARGET_STATUS[action];
|
||||
const nextVersion = item.version + 1;
|
||||
const now = new Date();
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
status: targetStatus,
|
||||
version: nextVersion,
|
||||
updatedBy: user.username,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (targetStatus === 'published') {
|
||||
updateData.publishedAt = now;
|
||||
}
|
||||
|
||||
const updated = await prisma.contentItem.update({
|
||||
where: { id: itemId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
const afterData: Record<string, unknown> = { ...updated };
|
||||
if (reason) {
|
||||
afterData.reason = reason;
|
||||
}
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
module: 'item',
|
||||
targetId: itemId,
|
||||
action: action === 'approve' || action === 'archive' ? action : 'update',
|
||||
operator: user.username,
|
||||
beforeData: JSON.stringify(item),
|
||||
afterData: JSON.stringify(afterData),
|
||||
},
|
||||
});
|
||||
|
||||
// 生成站内通知:提交审核时通知审核人;审核结果/归档时通知创建人
|
||||
if (action === 'submit') {
|
||||
await notifyReviewers(item, user);
|
||||
} else if (action === 'approve') {
|
||||
await notifyCreator(item, 'review_approved', user);
|
||||
} else if (action === 'reject') {
|
||||
await notifyCreator(item, 'review_rejected', user, reason);
|
||||
} else if (action === 'archive') {
|
||||
await notifyCreator(item, 'item_archived', user);
|
||||
}
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
status: updated.status as ContentStatus,
|
||||
version: updated.version,
|
||||
publishedAt: updated.publishedAt,
|
||||
updatedBy: updated.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
export async function submitForReview(
|
||||
itemId: string,
|
||||
user: WorkflowUser
|
||||
): Promise<WorkflowResult> {
|
||||
return transition(itemId, 'submit', user);
|
||||
}
|
||||
|
||||
export async function approve(
|
||||
itemId: string,
|
||||
user: WorkflowUser
|
||||
): Promise<WorkflowResult> {
|
||||
return transition(itemId, 'approve', user);
|
||||
}
|
||||
|
||||
export async function reject(
|
||||
itemId: string,
|
||||
user: WorkflowUser,
|
||||
reason?: string
|
||||
): Promise<WorkflowResult> {
|
||||
return transition(itemId, 'reject', user, reason);
|
||||
}
|
||||
|
||||
export async function archive(
|
||||
itemId: string,
|
||||
user: WorkflowUser
|
||||
): Promise<WorkflowResult> {
|
||||
return transition(itemId, 'archive', user);
|
||||
}
|
||||
@@ -17,7 +17,6 @@ export {
|
||||
NEWS,
|
||||
type NewsItem,
|
||||
type NewsCategory,
|
||||
TEAM_MEMBERS,
|
||||
type TeamMember,
|
||||
METHODOLOGY,
|
||||
type MethodologyPhase,
|
||||
|
||||
@@ -1,105 +1,5 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import {
|
||||
CASE_STUDIES,
|
||||
CASE_INDUSTRIES,
|
||||
getCaseBySlug,
|
||||
getFeaturedCases,
|
||||
getCasesByIndustry,
|
||||
} from './cases';
|
||||
|
||||
describe('CASE_STUDIES', () => {
|
||||
it('should have 6 case studies', () => {
|
||||
expect(CASE_STUDIES.length).toBe(6);
|
||||
});
|
||||
|
||||
it('should have required properties on each case', () => {
|
||||
CASE_STUDIES.forEach((c) => {
|
||||
expect(c).toHaveProperty('id');
|
||||
expect(c).toHaveProperty('slug');
|
||||
expect(c).toHaveProperty('client');
|
||||
expect(c).toHaveProperty('industry');
|
||||
expect(c).toHaveProperty('companySize');
|
||||
expect(c).toHaveProperty('title');
|
||||
expect(c).toHaveProperty('subtitle');
|
||||
expect(c).toHaveProperty('challenge');
|
||||
expect(c).toHaveProperty('solution');
|
||||
expect(c).toHaveProperty('result');
|
||||
expect(c).toHaveProperty('metrics');
|
||||
expect(c).toHaveProperty('timeline');
|
||||
expect(c).toHaveProperty('services');
|
||||
expect(c).toHaveProperty('color');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have unique slugs', () => {
|
||||
const slugs = CASE_STUDIES.map((c) => c.slug);
|
||||
expect(new Set(slugs).size).toBe(slugs.length);
|
||||
});
|
||||
|
||||
it('should have unique ids', () => {
|
||||
const ids = CASE_STUDIES.map((c) => c.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('should have valid color values', () => {
|
||||
const validColors = ['brand', 'blue', 'teal', 'amber', 'purple'];
|
||||
CASE_STUDIES.forEach((c) => {
|
||||
expect(validColors).toContain(c.color);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have at least one metric per case', () => {
|
||||
CASE_STUDIES.forEach((c) => {
|
||||
expect(c.metrics.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have at least one timeline phase per case', () => {
|
||||
CASE_STUDIES.forEach((c) => {
|
||||
expect(c.timeline.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have at least one service per case', () => {
|
||||
CASE_STUDIES.forEach((c) => {
|
||||
expect(c.services.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have valid metric structure', () => {
|
||||
CASE_STUDIES.forEach((c) => {
|
||||
c.metrics.forEach((m) => {
|
||||
expect(m).toHaveProperty('value');
|
||||
expect(m).toHaveProperty('label');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should have valid timeline structure', () => {
|
||||
CASE_STUDIES.forEach((c) => {
|
||||
c.timeline.forEach((t) => {
|
||||
expect(t).toHaveProperty('phase');
|
||||
expect(t).toHaveProperty('duration');
|
||||
expect(t).toHaveProperty('description');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should have valid service structure', () => {
|
||||
CASE_STUDIES.forEach((c) => {
|
||||
c.services.forEach((s) => {
|
||||
expect(s).toHaveProperty('id');
|
||||
expect(s).toHaveProperty('title');
|
||||
expect(s).toHaveProperty('description');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should have 3 featured cases', () => {
|
||||
const featured = CASE_STUDIES.filter((c) => c.featured);
|
||||
expect(featured.length).toBe(3);
|
||||
});
|
||||
});
|
||||
import { CASE_INDUSTRIES } from './cases';
|
||||
|
||||
describe('CASE_INDUSTRIES', () => {
|
||||
it('should have 7 industries', () => {
|
||||
@@ -125,79 +25,3 @@ describe('CASE_INDUSTRIES', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCaseBySlug', () => {
|
||||
it('should return the correct case by slug', () => {
|
||||
const result = getCaseBySlug('manufacturing-erp-upgrade');
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe('case-001');
|
||||
expect(result?.client).toBe('某上市制造企业');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent slug', () => {
|
||||
const result = getCaseBySlug('non-existent-slug');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = getCaseBySlug('');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFeaturedCases', () => {
|
||||
it('should return only featured cases', () => {
|
||||
const result = getFeaturedCases();
|
||||
expect(result.length).toBe(3);
|
||||
result.forEach((c) => {
|
||||
expect(c.featured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include manufacturing case', () => {
|
||||
const result = getFeaturedCases();
|
||||
const slugs = result.map((c) => c.slug);
|
||||
expect(slugs).toContain('manufacturing-erp-upgrade');
|
||||
});
|
||||
|
||||
it('should include retail case', () => {
|
||||
const result = getFeaturedCases();
|
||||
const slugs = result.map((c) => c.slug);
|
||||
expect(slugs).toContain('retail-omnichannel');
|
||||
});
|
||||
|
||||
it('should include healthcare case', () => {
|
||||
const result = getFeaturedCases();
|
||||
const slugs = result.map((c) => c.slug);
|
||||
expect(slugs).toContain('healthcare-data-platform');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCasesByIndustry', () => {
|
||||
it('should return all cases when industry is "all"', () => {
|
||||
const result = getCasesByIndustry('all');
|
||||
expect(result.length).toBe(6);
|
||||
});
|
||||
|
||||
it('should filter by 制造业', () => {
|
||||
const result = getCasesByIndustry('制造业');
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]?.slug).toBe('manufacturing-erp-upgrade');
|
||||
});
|
||||
|
||||
it('should filter by 医疗健康', () => {
|
||||
const result = getCasesByIndustry('医疗健康');
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]?.slug).toBe('healthcare-data-platform');
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent industry', () => {
|
||||
const result = getCasesByIndustry('非存在行业');
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty array for empty string', () => {
|
||||
const result = getCasesByIndustry('');
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -47,254 +47,6 @@ export interface CaseStudy {
|
||||
verified?: 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,
|
||||
projectDuration: '8 个月',
|
||||
departments: ['生产', '财务', '供应链', 'IT'],
|
||||
dataScale: '10 万级 SKU、日均 5 万笔生产工单',
|
||||
businessProblem: '月结耗时 5 天以上,库存准确率长期低于 95%,无法支撑多组织核算。',
|
||||
verified: false,
|
||||
},
|
||||
{
|
||||
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,
|
||||
projectDuration: '6 个月',
|
||||
departments: ['运营', '门店管理', '供应链', '会员运营'],
|
||||
dataScale: '200+ 门店、日均 50 万笔订单、千万级会员',
|
||||
businessProblem: '线上线下库存不同步,会员体系割裂,促销无法跨渠道联动。',
|
||||
verified: false,
|
||||
},
|
||||
{
|
||||
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,
|
||||
projectDuration: '10 个月',
|
||||
departments: ['信息科', '医务部', '运营管理部', '财务科'],
|
||||
dataScale: '20+ 业务系统、日均 100 万条诊疗记录',
|
||||
businessProblem: 'HIS、EMR、LIS、PACS 等系统数据分散,运营报表 T+1 且口径不一致。',
|
||||
verified: false,
|
||||
},
|
||||
{
|
||||
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',
|
||||
projectDuration: '5 个月',
|
||||
departments: ['招生部', '校区运营', '市场部', 'IT'],
|
||||
dataScale: '50+ 校区、日均 10 万条线索与跟进记录',
|
||||
businessProblem: '线索分配依赖人工,各校区独立运营,营销 ROI 无法追踪。',
|
||||
verified: false,
|
||||
},
|
||||
{
|
||||
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',
|
||||
projectDuration: '7 个月',
|
||||
departments: ['财务', '业务线', '合规', 'IT'],
|
||||
dataScale: '多业务线、多主体,月均 50 万笔财务凭证',
|
||||
businessProblem: '多业务线核算标准不统一,月结 10 天,合规风险高。',
|
||||
verified: false,
|
||||
},
|
||||
{
|
||||
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',
|
||||
projectDuration: '9 个月',
|
||||
departments: ['调度中心', '仓储', '运输', 'IT'],
|
||||
dataScale: '日均 10 万单运输、100+ 仓库节点',
|
||||
businessProblem: '物流状态无法实时追踪,调度依赖人工经验,异常响应慢。',
|
||||
verified: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const CASE_INDUSTRIES = [
|
||||
{ id: 'all', label: '全部行业' },
|
||||
{ id: '制造业', label: '制造业' },
|
||||
@@ -304,16 +56,3 @@ export const CASE_INDUSTRIES = [
|
||||
{ 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);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface HeroTheme {
|
||||
export const HERO_THEMES: Record<string, HeroTheme> = {
|
||||
erp: {
|
||||
id: 'erp',
|
||||
name: 'ERP 水墨雅致',
|
||||
name: 'ERP',
|
||||
gradientFrom: '#f8f6f3',
|
||||
gradientTo: '#f0ebe4',
|
||||
gradientAngle: 135,
|
||||
@@ -34,7 +34,7 @@ export const HERO_THEMES: Record<string, HeroTheme> = {
|
||||
},
|
||||
crm: {
|
||||
id: 'crm',
|
||||
name: 'CRM 水墨雅致',
|
||||
name: 'CRM',
|
||||
gradientFrom: '#f7f5f2',
|
||||
gradientTo: '#eee9e2',
|
||||
gradientAngle: 120,
|
||||
@@ -46,7 +46,7 @@ export const HERO_THEMES: Record<string, HeroTheme> = {
|
||||
},
|
||||
bi: {
|
||||
id: 'bi',
|
||||
name: 'BI 水墨雅致',
|
||||
name: 'BI',
|
||||
gradientFrom: '#f6f8fa',
|
||||
gradientTo: '#eef1f5',
|
||||
gradientAngle: 145,
|
||||
@@ -58,7 +58,7 @@ export const HERO_THEMES: Record<string, HeroTheme> = {
|
||||
},
|
||||
cms: {
|
||||
id: 'cms',
|
||||
name: 'CMS 水墨雅致',
|
||||
name: 'CMS',
|
||||
gradientFrom: '#f5f8f5',
|
||||
gradientTo: '#eaf0ea',
|
||||
gradientAngle: 130,
|
||||
@@ -70,7 +70,7 @@ export const HERO_THEMES: Record<string, HeroTheme> = {
|
||||
},
|
||||
sds: {
|
||||
id: 'sds',
|
||||
name: 'SDS 水墨雅致',
|
||||
name: 'SDS',
|
||||
gradientFrom: '#faf7f4',
|
||||
gradientTo: '#f3ede5',
|
||||
gradientAngle: 140,
|
||||
@@ -82,7 +82,7 @@ export const HERO_THEMES: Record<string, HeroTheme> = {
|
||||
},
|
||||
oa: {
|
||||
id: 'oa',
|
||||
name: 'OA 水墨雅致',
|
||||
name: 'OA',
|
||||
gradientFrom: '#f7f5fa',
|
||||
gradientTo: '#efeaf5',
|
||||
gradientAngle: 125,
|
||||
@@ -94,7 +94,7 @@ export const HERO_THEMES: Record<string, HeroTheme> = {
|
||||
},
|
||||
novavis: {
|
||||
id: 'novavis',
|
||||
name: 'NovaVis 水墨雅致',
|
||||
name: 'NovaVis',
|
||||
gradientFrom: '#f4fafa',
|
||||
gradientTo: '#e8f3f3',
|
||||
gradientAngle: 150,
|
||||
@@ -106,7 +106,7 @@ export const HERO_THEMES: Record<string, HeroTheme> = {
|
||||
},
|
||||
solution: {
|
||||
id: 'solution',
|
||||
name: '解决方案水墨雅致',
|
||||
name: '解决方案',
|
||||
gradientFrom: '#f8f6f3',
|
||||
gradientTo: '#f0ebe4',
|
||||
gradientAngle: 150,
|
||||
@@ -117,7 +117,7 @@ export const HERO_THEMES: Record<string, HeroTheme> = {
|
||||
},
|
||||
service: {
|
||||
id: 'service',
|
||||
name: '服务水墨雅致',
|
||||
name: '服务',
|
||||
gradientFrom: '#f7f5f2',
|
||||
gradientTo: '#eee9e2',
|
||||
gradientAngle: 135,
|
||||
@@ -128,7 +128,7 @@ export const HERO_THEMES: Record<string, HeroTheme> = {
|
||||
},
|
||||
security: {
|
||||
id: 'security',
|
||||
name: '安全产品水墨雅致',
|
||||
name: '安全产品',
|
||||
gradientFrom: '#faf5f5',
|
||||
gradientTo: '#f5eded',
|
||||
gradientAngle: 135,
|
||||
@@ -140,7 +140,7 @@ export const HERO_THEMES: Record<string, HeroTheme> = {
|
||||
},
|
||||
default: {
|
||||
id: 'default',
|
||||
name: '默认水墨雅致',
|
||||
name: '默认',
|
||||
gradientFrom: '#f8f6f3',
|
||||
gradientTo: '#f0ebe4',
|
||||
gradientAngle: 140,
|
||||
|
||||
@@ -6,9 +6,9 @@ export { SERVICES } from './services';
|
||||
export { PRODUCTS, PRODUCT_CATEGORIES, type Product, type ProductCategory } from './products';
|
||||
export { STANDALONE_PRODUCTS, type StandaloneProduct } from './standalone-products';
|
||||
export { NEWS, type NewsItem, type NewsCategory } from './news';
|
||||
export { TEAM_MEMBERS, type TeamMember } from './team';
|
||||
export type { TeamMember } from './team';
|
||||
export { METHODOLOGY, type MethodologyPhase } from './methodology';
|
||||
export { SOLUTIONS, type Solution } from './solutions';
|
||||
export { CASE_STUDIES, CASE_INDUSTRIES, getCaseBySlug, getFeaturedCases, getCasesByIndustry, type CaseStudy, type CaseMetric, type CaseTimelinePhase, type CaseService, type CaseTestimonial } from './cases';
|
||||
export { CASE_INDUSTRIES, type CaseStudy, type CaseMetric, type CaseTimelinePhase, type CaseService, type CaseTestimonial } from './cases';
|
||||
export { HERO_THEMES, getHeroTheme, type HeroTheme, type HeroLayout, type HeroTexture } from './hero-themes';
|
||||
export { getProductCrossRefs, getSolutionCrossRefs, getServiceCrossRefs, type CrossReference } from './cross-references';
|
||||
|
||||
+38
-469
@@ -34,6 +34,27 @@ export interface FAQItem {
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface TechnicalParameter {
|
||||
name: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ComplianceCertification {
|
||||
name: string;
|
||||
issuer: string;
|
||||
standard?: string;
|
||||
validUntil?: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface DeploymentCase {
|
||||
client: string;
|
||||
industry: string;
|
||||
scenario: string;
|
||||
result: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -67,6 +88,12 @@ export interface ProductCategory {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface StandaloneProduct extends Product {
|
||||
technicalParameters?: TechnicalParameter[];
|
||||
complianceCertifications?: ComplianceCertification[];
|
||||
deploymentCases?: DeploymentCase[];
|
||||
}
|
||||
|
||||
export const PRODUCT_CATEGORIES: ProductCategory[] = [
|
||||
{
|
||||
id: 'enterprise',
|
||||
@@ -80,472 +107,14 @@ export const PRODUCT_CATEGORIES: ProductCategory[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const PRODUCTS: Product[] = [
|
||||
{
|
||||
id: 'erp',
|
||||
title: '睿新ERP管理系统',
|
||||
description: '打通财务、采购、销售、库存、生产全链路,让各部门数据实时流转、业务流程自动协同。',
|
||||
image: '/images/products/erp.jpg',
|
||||
category: '企业套装',
|
||||
categoryId: 'enterprise',
|
||||
tags: ['ERP', '资源管理', '全链路'],
|
||||
status: '研发中',
|
||||
heroThemeId: 'erp',
|
||||
scenario: '面向中大型制造与零售企业的核心运营系统',
|
||||
metrics: [
|
||||
{ value: '10 万级', label: 'SKU 支撑' },
|
||||
{ value: '200+', label: '标准报表模板' },
|
||||
{ value: '99.5%', label: '数据准确率' },
|
||||
],
|
||||
bundle: 'enterprise',
|
||||
capabilities: ['财务核算', '采购管理', '生产计划', '库存控制'],
|
||||
caseStudies: [
|
||||
{
|
||||
client: '某大型制造集团',
|
||||
industry: '制造业',
|
||||
challenge: '财务、采购、销售、库存数据分散在多个独立系统,月结耗时5天以上',
|
||||
solution: '部署睿新ERP,打通全链路业务流程,实现数据实时流转',
|
||||
result: '月结时间从5天缩短至1天,库存准确率提升至99.2%',
|
||||
},
|
||||
],
|
||||
dataProofs: [
|
||||
{ metric: '运营效率提升', value: '40%+', description: '流程自动化减少人工操作' },
|
||||
{ metric: '数据准确性', value: '99.2%', description: '实时数据同步消除信息孤岛' },
|
||||
{ metric: '部署周期', value: '3-6个月', description: '从需求调研到正式上线' },
|
||||
],
|
||||
certifications: [
|
||||
{ name: '等保三级', issuer: '公安部', link: '#' },
|
||||
{ name: 'ISO 27001', issuer: '国际标准化组织', link: '#' },
|
||||
{ name: '信创适配认证', issuer: '中国信息安全测评中心', link: '#' },
|
||||
],
|
||||
overview: '睿新ERP管理系统是面向企业打造的一体化资源管理平台,打通财务、采购、销售、库存、生产等核心模块,让各部门数据实时流转、业务流程自动协同,帮助企业告别信息孤岛。',
|
||||
features: [
|
||||
'财务管理:总账、应收应付、成本核算、固定资产管理',
|
||||
'采购管理:供应商管理、采购订单、入库验收、采购分析',
|
||||
'销售管理:客户管理、销售订单、发货管理、销售分析',
|
||||
'库存管理:库存查询、出入库管理、库存预警、盘点管理',
|
||||
'生产管理:生产计划、物料需求、生产订单、成本核算',
|
||||
'报表分析:丰富的报表模板,支持自定义报表',
|
||||
],
|
||||
benefits: [
|
||||
'数据完全自主可控,满足安全合规要求',
|
||||
'支持本地/私有云/混合云多种部署方式',
|
||||
'提升企业运营效率,减少人工操作',
|
||||
'灵活订阅按需付费,无隐性收费,成本可控',
|
||||
],
|
||||
process: [
|
||||
'需求调研:深入了解企业业务流程和管理需求',
|
||||
'环境评估:评估服务器环境与网络架构',
|
||||
'方案设计:制定部署方案与数据迁移策略',
|
||||
'系统部署:私有化部署与系统配置',
|
||||
'数据迁移:安全迁移历史数据,确保完整性',
|
||||
'培训交付:用户培训与正式交付上线',
|
||||
],
|
||||
specs: [
|
||||
'支持本地服务器、私有云、混合云部署',
|
||||
'支持多组织、多账套管理',
|
||||
'适配信创环境(国产操作系统/数据库)',
|
||||
'提供API接口,对接现有系统',
|
||||
'支持移动端访问(APP/微信/钉钉)',
|
||||
'数据加密存储与传输',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'crm',
|
||||
title: '睿新客户关系管理系统',
|
||||
description: '从线索到成交的全流程可视化管理,让销售团队清楚每一步该做什么、每个机会卡在哪里。',
|
||||
image: '/images/products/crm.jpg',
|
||||
category: '企业套装',
|
||||
categoryId: 'enterprise',
|
||||
tags: ['CRM', '客户管理', '销售漏斗'],
|
||||
status: '研发中',
|
||||
heroThemeId: 'crm',
|
||||
scenario: '面向销售驱动型企业的一体化客户运营管理平台',
|
||||
metrics: [
|
||||
{ value: '360°', label: '客户画像' },
|
||||
{ value: '3x', label: '转化率提升' },
|
||||
{ value: '<2h', label: '线索响应' },
|
||||
],
|
||||
bundle: 'enterprise',
|
||||
capabilities: ['线索管理', '商机跟进', '合同审批', '售后工单'],
|
||||
caseStudies: [
|
||||
{
|
||||
client: '某连锁零售集团',
|
||||
industry: '零售业',
|
||||
challenge: '客户数据分散在Excel和多个系统,销售团队跟进效率低,商机转化率仅15%',
|
||||
solution: '部署睿新CRM,整合全渠道客户数据,建立标准化销售流程',
|
||||
result: '销售转化率提升至32%,客户满意度提升25%',
|
||||
},
|
||||
],
|
||||
dataProofs: [
|
||||
{ metric: '销售转化率', value: '+113%', description: '从15%提升至32%' },
|
||||
{ metric: '客户数据整合', value: '100%', description: '多渠道数据统一管理' },
|
||||
{ metric: '响应速度', value: '<2小时', description: '线索到首次联系平均时间' },
|
||||
],
|
||||
certifications: [
|
||||
{ name: '等保三级', issuer: '公安部', link: '#' },
|
||||
{ name: 'ISO 27001', issuer: '国际标准化组织', link: '#' },
|
||||
],
|
||||
overview: '睿新客户关系管理系统是面向企业销售团队的全渠道客户管理平台,覆盖从线索到成交的全流程可视化管理,让销售团队清楚每一步该做什么、每个机会卡在哪里。系统支持多渠道客户数据整合,提供全面的客户画像和销售漏斗分析。',
|
||||
features: [
|
||||
'客户档案:完整的客户信息管理,支持自定义字段',
|
||||
'销售漏斗:可视化的销售机会管理,实时跟进销售进度',
|
||||
'商机管理:商机创建、跟进、转化全流程管理',
|
||||
'合同管理:合同创建、审批、执行、归档全流程',
|
||||
'售后服务:工单管理、服务记录、客户满意度调查',
|
||||
'数据分析:客户分析、销售分析、业绩报表',
|
||||
],
|
||||
benefits: [
|
||||
'客户数据私有化存储,保障商业机密',
|
||||
'与企业现有系统深度集成',
|
||||
'提升销售转化率,提高销售效率',
|
||||
'灵活定制销售流程,适配业务变化',
|
||||
],
|
||||
process: [
|
||||
'需求调研:了解企业销售流程和客户管理需求',
|
||||
'环境评估:评估IT基础设施与集成需求',
|
||||
'方案设计:制定部署方案与系统集成策略',
|
||||
'系统部署:私有化部署与流程配置',
|
||||
'数据导入:导入历史客户数据与销售记录',
|
||||
'培训交付:销售团队培训与系统上线',
|
||||
],
|
||||
specs: [
|
||||
'支持本地服务器、私有云部署',
|
||||
'支持多渠道客户数据整合',
|
||||
'适配信创环境',
|
||||
'提供API接口,对接ERP/OA等系统',
|
||||
'支持移动端访问',
|
||||
'数据加密与权限隔离',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cms',
|
||||
title: '睿新内容管理系统',
|
||||
description: '所见即所得的编辑体验,多站点统一管理,让内容发布像写文档一样简单。',
|
||||
image: '/images/products/cms.jpg',
|
||||
category: '企业套装',
|
||||
categoryId: 'enterprise',
|
||||
tags: ['CMS', '内容管理', '多站点'],
|
||||
status: '研发中',
|
||||
heroThemeId: 'cms',
|
||||
scenario: '面向多品牌、多站点企业的统一内容管理平台',
|
||||
metrics: [
|
||||
{ value: '50+', label: '站点统管' },
|
||||
{ value: '60%', label: '发布提效' },
|
||||
{ value: '等保二级', label: '安全合规' },
|
||||
],
|
||||
bundle: 'enterprise',
|
||||
capabilities: ['多站点管理', '工作流审批', '版本控制', 'SEO 优化'],
|
||||
caseStudies: [
|
||||
{
|
||||
client: '某教育集团',
|
||||
industry: '教育行业',
|
||||
challenge: '旗下20+学校网站各自独立管理,内容更新不及时,品牌形象不统一',
|
||||
solution: '部署睿新CMS,统一管理所有站点内容,建立标准化发布流程',
|
||||
result: '内容发布效率提升60%,品牌一致性显著改善',
|
||||
},
|
||||
],
|
||||
dataProofs: [
|
||||
{ metric: '发布效率提升', value: '60%', description: '多站点统一管理' },
|
||||
{ metric: '站点支持数', value: '50+', description: '单系统支持多站点' },
|
||||
{ metric: '安全合规', value: '等保三级', description: '满足政务/企业合规要求' },
|
||||
],
|
||||
certifications: [
|
||||
{ name: '等保二级', issuer: '公安部', link: '#' },
|
||||
{ name: 'ISO 27001', issuer: '国际标准化组织', link: '#' },
|
||||
],
|
||||
overview: '睿新内容管理系统是企业级内容管理平台,提供所见即所得的编辑体验和多站点统一管理能力,让内容发布像写文档一样简单。系统提供强大的内容编辑功能和灵活的权限管理机制。',
|
||||
features: [
|
||||
'内容编辑:所见即所得的富文本编辑器,支持多媒体内容',
|
||||
'多站点管理:统一管理多个站点的内容',
|
||||
'权限控制:细粒度的权限管理,支持角色和用户权限',
|
||||
'版本管理:内容版本控制,支持版本对比和回滚',
|
||||
'内容发布:定时发布、工作流审批、多渠道分发',
|
||||
'SEO优化:内置SEO工具,优化搜索引擎排名',
|
||||
],
|
||||
benefits: [
|
||||
'内容数据自主管理,支持等保合规',
|
||||
'支持私有化部署,内外网隔离访问',
|
||||
'提升内容发布效率,加快内容更新',
|
||||
'灵活的权限体系,满足多部门协作',
|
||||
],
|
||||
process: [
|
||||
'需求分析:了解企业内容管理需求与安全要求',
|
||||
'环境评估:评估部署环境与网络隔离需求',
|
||||
'方案设计:制定部署方案与内容架构',
|
||||
'系统部署:私有化部署与权限配置',
|
||||
'内容迁移:迁移历史内容与媒体资源',
|
||||
'培训交付:内容团队培训与系统上线',
|
||||
],
|
||||
specs: [
|
||||
'支持本地服务器、私有云部署',
|
||||
'支持多站点统一管理',
|
||||
'适配信创环境',
|
||||
'提供API接口集成',
|
||||
'支持自定义模板与组件',
|
||||
'满足等保二级/三级安全要求',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bi',
|
||||
title: '睿新商业智能平台',
|
||||
description: '拖拽式自助分析,让业务人员无需写SQL也能从数据中找到答案。',
|
||||
image: '/images/products/bi.jpg',
|
||||
category: '企业套装',
|
||||
categoryId: 'enterprise',
|
||||
tags: ['BI', '数据分析', '可视化'],
|
||||
status: '研发中',
|
||||
heroThemeId: 'bi',
|
||||
scenario: '面向业务人员的自助式数据洞察与决策支持平台',
|
||||
metrics: [
|
||||
{ value: '70%+', label: '自助分析' },
|
||||
{ value: '<1天', label: '决策响应' },
|
||||
{ value: '85%', label: '报表减负' },
|
||||
],
|
||||
bundle: 'enterprise',
|
||||
capabilities: ['拖拽分析', '实时看板', '智能预警', '移动报表'],
|
||||
caseStudies: [
|
||||
{
|
||||
client: '某快消品牌',
|
||||
industry: '消费品',
|
||||
challenge: '销售数据分散在各地分公司,总部无法实时掌握经营状况,决策滞后',
|
||||
solution: '部署睿新BI平台,整合全渠道数据,建立实时经营看板',
|
||||
result: '决策周期从周级缩短至天级,库存周转率提升28%',
|
||||
},
|
||||
],
|
||||
dataProofs: [
|
||||
{ metric: '数据整合效率', value: '85%', description: '减少手工报表工作量' },
|
||||
{ metric: '决策响应时间', value: '<1天', description: '从周级缩短至实时' },
|
||||
{ metric: '自助分析占比', value: '70%+', description: '业务人员独立完成分析' },
|
||||
],
|
||||
certifications: [
|
||||
{ name: '等保三级', issuer: '公安部', link: '#' },
|
||||
{ name: 'ISO 27001', issuer: '国际标准化组织', link: '#' },
|
||||
],
|
||||
overview: '睿新商业智能平台是数据可视化分析平台,提供拖拽式自助分析能力,让业务人员无需写SQL也能从数据中找到答案。平台支持自助式数据分析和实时数据监控。',
|
||||
features: [
|
||||
'数据整合:对接多种数据源,统一数据平台',
|
||||
'可视化报表:丰富的图表类型,支持自定义报表',
|
||||
'自助分析:拖拽式数据分析,降低使用门槛',
|
||||
'实时监控:实时数据监控和智能预警',
|
||||
'智能预警:基于规则的智能预警,及时发现异常',
|
||||
'移动看板:支持移动端访问,随时随地查看数据',
|
||||
],
|
||||
benefits: [
|
||||
'分析能力私有化部署,数据不出企业',
|
||||
'对接内部数据源,无需数据上云',
|
||||
'提升决策效率,加快决策速度',
|
||||
'自助式分析,降低数据使用门槛',
|
||||
],
|
||||
process: [
|
||||
'数据评估:评估数据源、数据质量与安全要求',
|
||||
'环境评估:评估服务器性能与数据规模',
|
||||
'方案设计:制定部署方案与数据仓库架构',
|
||||
'系统部署:私有化部署与数据集成',
|
||||
'模型开发:开发分析模型与可视化报表',
|
||||
'培训交付:数据分析培训与系统上线',
|
||||
],
|
||||
specs: [
|
||||
'支持本地服务器、私有云部署',
|
||||
'支持多种数据源(数据库/文件/API)',
|
||||
'适配信创环境',
|
||||
'支持实时与离线数据分析',
|
||||
'提供API接口集成',
|
||||
'数据加密与访问审计',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sds',
|
||||
title: '睿新供应链决策支持系统',
|
||||
description: '自动预测销量、智能补货建议,帮您把库存成本降下来、把缺货损失挽回来。',
|
||||
image: '/images/products/sds.jpg',
|
||||
category: '企业套装',
|
||||
categoryId: 'enterprise',
|
||||
tags: ['供应链', '智能预测', '库存优化'],
|
||||
status: '研发中',
|
||||
heroThemeId: 'sds',
|
||||
scenario: '面向零售与制造企业的智能销量预测与库存优化系统',
|
||||
metrics: [
|
||||
{ value: '73%', label: '缺货率降低' },
|
||||
{ value: '38%', label: '周转提升' },
|
||||
{ value: '<6个月', label: 'ROI 周期' },
|
||||
],
|
||||
bundle: 'enterprise',
|
||||
capabilities: ['销量预测', '智能补货', '库存诊断', '供应商协同'],
|
||||
caseStudies: [
|
||||
{
|
||||
client: '某服装品牌',
|
||||
industry: '服装零售',
|
||||
challenge: 'SKU超5000个,缺货率12%同时库存积压严重,采购决策依赖经验',
|
||||
solution: '部署睿新SDS,建立智能销量预测和自动补货体系',
|
||||
result: '缺货率降至3.2%,库存周转天数从45天降至28天,年节省成本超200万',
|
||||
},
|
||||
],
|
||||
dataProofs: [
|
||||
{ metric: '缺货率降低', value: '73%', description: '从12%降至3.2%' },
|
||||
{ metric: '库存周转提升', value: '38%', description: '周转天数从45天降至28天' },
|
||||
{ metric: 'ROI回报周期', value: '<6个月', description: '投入产出清晰可算' },
|
||||
],
|
||||
certifications: [
|
||||
{ name: '等保三级', issuer: '公安部', link: '#' },
|
||||
{ name: 'ISO 27001', issuer: '国际标准化组织', link: '#' },
|
||||
],
|
||||
overview: '睿新供应链决策支持系统是智能供应链管理平台,自动预测销量、智能补货建议,帮您把库存成本降下来、把缺货损失挽回来。系统采用智能算法,实现从经验驱动到数据驱动的供应链管理升级。',
|
||||
features: [
|
||||
'智能销量预测:自动分析历史销售趋势、季节规律和促销影响,预测每个商品销量',
|
||||
'库存健康诊断:自动识别库存过多(积压资金)和即将缺货(错失销售)的商品',
|
||||
'精准补货建议:系统自动计算补货时机和数量,按紧急程度排好优先级',
|
||||
'原因追溯分析:提供预测偏差原因分析,帮助理解销量波动因素',
|
||||
'供应商协同:与供应商系统对接,实现采购订单自动推送和交期跟踪',
|
||||
'数据看板:实时展示库存周转率、缺货率、积压金额等关键指标',
|
||||
],
|
||||
benefits: [
|
||||
'降低库存成本和缺货率,直接转化为利润提升',
|
||||
'无需专业数据团队,采购员、库管员等日常操作人员即可独立使用',
|
||||
'简单三步完成核心操作,系统全程引导,零基础也能快速上手',
|
||||
'投入产出清晰可算,仪表盘实时展示节省了多少钱、挽回了多少销售',
|
||||
],
|
||||
process: [
|
||||
'需求调研:了解企业供应链现状、库存管理痛点和补货流程',
|
||||
'数据评估:评估历史销售数据质量和可用性',
|
||||
'方案设计:制定部署方案与预测模型架构',
|
||||
'系统部署:私有化部署与数据集成',
|
||||
'模型训练:基于历史数据训练销量预测模型',
|
||||
'培训交付:供应链团队培训与系统上线',
|
||||
],
|
||||
specs: [
|
||||
'支持本地服务器、私有云部署',
|
||||
'对接多种ERP/WMS系统数据',
|
||||
'适配信创环境',
|
||||
'支持多品类、多仓点并行管理',
|
||||
'提供API接口集成',
|
||||
'数据加密与访问审计',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'oa',
|
||||
title: '睿新协同办公系统',
|
||||
description: '审批流程电子化、公文管理合规化、知识资产沉淀化,一站式解决办公协作难题。',
|
||||
image: '/images/products/oa.jpg',
|
||||
category: '企业套装',
|
||||
categoryId: 'enterprise',
|
||||
tags: ['OA', '协同办公', '流程审批'],
|
||||
status: '研发中',
|
||||
heroThemeId: 'oa',
|
||||
scenario: '面向中大型组织的一站式流程审批与协同办公平台',
|
||||
metrics: [
|
||||
{ value: '95%', label: '审批提效' },
|
||||
{ value: '100%', label: '移动覆盖' },
|
||||
{ value: '10万+', label: '日流程承载' },
|
||||
],
|
||||
bundle: 'enterprise',
|
||||
capabilities: ['流程审批', '公文管理', '知识中心', '移动办公'],
|
||||
caseStudies: [
|
||||
{
|
||||
client: '某中型制造企业',
|
||||
industry: '制造业',
|
||||
challenge: '审批流程依赖纸质流转,平均耗时3-5天,跨部门协作效率低',
|
||||
solution: '部署睿新OA,实现全流程电子化审批和移动办公',
|
||||
result: '平均审批时间从4天缩短至4小时,年节省纸张成本超10万',
|
||||
},
|
||||
],
|
||||
dataProofs: [
|
||||
{ metric: '审批效率提升', value: '95%', description: '从4天缩短至4小时' },
|
||||
{ metric: '移动办公覆盖率', value: '100%', description: 'PC+移动端全覆盖' },
|
||||
{ metric: '流程电子化率', value: '100%', description: '告别纸质流转' },
|
||||
],
|
||||
certifications: [
|
||||
{ name: '等保三级', issuer: '公安部', link: '#' },
|
||||
{ name: 'ISO 27001', issuer: '国际标准化组织', link: '#' },
|
||||
{ name: '信创适配认证', issuer: '中国信息安全测评中心', link: '#' },
|
||||
],
|
||||
overview: '睿新协同办公系统是综合型企业协同办公平台,融合传统办公自动化与现代协作理念。集成流程审批、公文管理、即时通讯、知识管理、会议日程等核心模块,支持PC端与移动端全场景覆盖,帮助企业实现办公数字化、流程规范化、协作高效化。',
|
||||
features: [
|
||||
'流程审批:请假、报销、采购、合同等全流程电子化审批,支持自定义审批流程和条件分支',
|
||||
'公文管理:收文登记、发文审批、公文流转、归档管理,满足政务合规和档案管理要求',
|
||||
'智能协作:与钉钉、企业微信、飞书等主流平台深度整合,实现统一消息推送与协作',
|
||||
'知识中心:企业知识库、文档管理、知识检索、权限管控,沉淀组织智慧资产',
|
||||
'会议日程:会议室预约、会议通知、纪要管理、个人与团队日程同步',
|
||||
'移动办公:全功能移动端,支持APP、微信企业号、钉钉等多端接入',
|
||||
],
|
||||
benefits: [
|
||||
'提升流程审批效率,告别纸质流转,降低管理成本',
|
||||
'知识资产沉淀共享,避免"人走知识丢",提升组织学习能力',
|
||||
'移动办公全覆盖,决策不等待,响应更及时',
|
||||
'一站式办公平台,减少系统切换成本,提升工作效率',
|
||||
],
|
||||
process: [
|
||||
'需求调研:了解企业组织架构、审批流程和办公协作需求',
|
||||
'环境评估:评估IT基础设施与安全合规要求',
|
||||
'方案设计:制定部署方案、流程配置与集成策略',
|
||||
'系统部署:私有化部署与组织架构初始化',
|
||||
'流程配置:配置审批流程、公文模板、权限体系',
|
||||
'培训交付:全员培训与系统正式上线',
|
||||
],
|
||||
specs: [
|
||||
'支持本地服务器、私有云、混合云部署',
|
||||
'支持多组织、多层级组织架构管理',
|
||||
'适配信创环境(国产操作系统/数据库/中间件)',
|
||||
'提供API接口集成,对接ERP/CRM等业务系统',
|
||||
'支持移动端APP、微信企业号、钉钉等多端接入',
|
||||
'数据加密、访问审计、安全水印等安全特性',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'novavis',
|
||||
title: '睿视 NovaVis',
|
||||
description: '面向执法机关的新一代智能数据分析平台,完全离线运行,AI驱动分析,百万级数据秒级响应。',
|
||||
image: '/images/products/novavis.jpg',
|
||||
category: '专业工具系列',
|
||||
categoryId: 'specialized',
|
||||
tags: ['资金追踪', '关系图谱', 'AI分析', '离线运行', '执法情报'],
|
||||
status: '内测中',
|
||||
heroThemeId: 'novavis',
|
||||
scenario: '面向执法机关的离线智能数据分析与资金关系图谱平台',
|
||||
metrics: [
|
||||
{ value: '100万+', label: '秒级处理' },
|
||||
{ value: '<30秒', label: '分析耗时' },
|
||||
{ value: '20层+', label: '资金追踪' },
|
||||
],
|
||||
bundle: 'standalone',
|
||||
capabilities: ['资金追踪', '关系图谱', 'AI 分析', '离线运行'],
|
||||
caseStudies: [],
|
||||
dataProofs: [
|
||||
{ metric: '数据处理能力', value: '100万+', description: '百万级记录秒级处理' },
|
||||
{ metric: '分析耗时', value: '<30秒', description: '百万级交易完成分析' },
|
||||
{ metric: '内存占用降低', value: '70%', description: '相比传统方案显著优化' },
|
||||
{ metric: '部署时间', value: '5分钟', description: '即插即用,快速启动' },
|
||||
],
|
||||
certifications: [],
|
||||
overview: '睿视 NovaVis 是我们即将推出的面向执法机关的新一代智能数据分析平台。以完全离线架构保障数据安全——所有计算均在本地完成,数据不出本地、不触网络。融合 AI 智能分析与专业关系网络图谱,提供资金追踪、团伙分析、时空碰撞等核心能力,让海量涉案数据秒级响应,为经济犯罪侦查、反腐败调查、涉黑涉恶打击等场景提供全链路数据分析支撑。',
|
||||
features: [
|
||||
'完全离线运行:零网络依赖,断网可用,金融级加密存储,U盘可携带即插即用',
|
||||
'AI 智能分析引擎:异常交易自动识别与预警、智能团伙分群与风险画像、可选配本地大语言模型',
|
||||
'智能关系网络图谱:自动识别人物关系与资金往来,团伙结构自动分析与层级梳理,关键人物智能定位',
|
||||
'全链路资金追踪:支持 20 层以上深度追踪,异常交易模式自动标注,闭环转账与资金归集识别',
|
||||
'一键报告生成:PDF / Word 双格式输出,自定义报告模板,分析过程全留痕,满足执法证据要求',
|
||||
],
|
||||
benefits: [
|
||||
'数据完全不出本地,从根本上杜绝泄露风险,满足最高等级安全合规要求',
|
||||
'百万级数据秒级响应,相比传统工具分析效率提升 10 倍以上',
|
||||
'向导式操作,非技术人员也能快速上手,单案分析从 3-5 天缩短至小时级',
|
||||
'极具性价比,相比国际竞品(i2 等)成本降低一个数量级',
|
||||
],
|
||||
process: [
|
||||
'需求沟通:了解分析场景、数据规模和安全要求',
|
||||
'环境准备:准备本地服务器或高性能PC',
|
||||
'安装部署:5分钟完成安装,即插即用',
|
||||
'数据导入:支持多种数据源导入(Excel/CSV/数据库)',
|
||||
'培训使用:操作培训与试用分析',
|
||||
'正式交付:授权激活并提供持续技术支持',
|
||||
],
|
||||
specs: [
|
||||
'完全离线运行,零网络依赖',
|
||||
'支持 Windows 本地部署',
|
||||
'金融级加密存储与传输',
|
||||
'满足等保三级安全合规要求',
|
||||
'可选配本地大语言模型',
|
||||
'提供 30 天免费全功能试用',
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
/**
|
||||
* 企业套装产品数据已从硬编码迁移至 CMS 种子文件
|
||||
* @see /prisma/seeds/products.ts
|
||||
*/
|
||||
export { PRODUCTS } from '../../../prisma/seeds/products';
|
||||
|
||||
/**
|
||||
* 独立产品数据已从硬编码迁移至 CMS 种子文件
|
||||
* @see /prisma/seeds/standalone-products.ts
|
||||
*/
|
||||
export { STANDALONE_PRODUCTS } from '../../../prisma/seeds/standalone-products';
|
||||
|
||||
+11
-243
@@ -34,6 +34,11 @@ export interface FAQItem {
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface ServiceMetric {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -51,248 +56,11 @@ export interface Service {
|
||||
techStack?: TechStackCategory[];
|
||||
faqs?: FAQItem[];
|
||||
capabilities?: string[];
|
||||
metrics?: ServiceMetric[];
|
||||
serviceNumber?: string;
|
||||
color?: 'brand' | 'blue' | 'teal' | 'amber' | 'purple';
|
||||
}
|
||||
|
||||
export const SERVICES: Service[] = [
|
||||
{
|
||||
id: 'consulting',
|
||||
title: '战略咨询',
|
||||
description: '数字化转型战略规划与落地路径设计,让技术投资真正驱动业务增长。从愿景制定到执行落地,陪伴企业穿越转型周期。',
|
||||
icon: 'Lightbulb',
|
||||
overview: '凭借12年行业经验和技术积累,为企业提供专业的技术咨询服务,帮助企业理清数字化转型方向,规避技术风险,实现技术与业务的深度融合。',
|
||||
features: [
|
||||
'数字化成熟度评估:全面评估企业数字化现状,识别核心痛点与机会点',
|
||||
'转型路线图设计:制定分阶段、可落地的数字化转型路径',
|
||||
'技术选型评估:客观评估技术方案,选择最优技术栈',
|
||||
'组织变革管理:指导企业组织能力建设与变革管理',
|
||||
'技术架构评审:评审现有架构,提出优化建议',
|
||||
],
|
||||
benefits: [
|
||||
'明确数字化转型方向,避免盲目投入',
|
||||
'降低技术选型风险,减少试错成本',
|
||||
'提升技术团队整体能力',
|
||||
'加速数字化转型进程,抢占市场先机',
|
||||
],
|
||||
capabilities: [
|
||||
'现状诊断与数字化成熟度评估',
|
||||
'业务流程重构与路线图设计',
|
||||
'技术架构选型与投资回报测算',
|
||||
'变革管理与落地陪跑',
|
||||
],
|
||||
process: [
|
||||
'现状调研:深入了解企业现状和痛点',
|
||||
'需求分析:梳理业务需求和技术需求',
|
||||
'方案制定:制定详细的技术咨询方案',
|
||||
'评审汇报:组织专家评审,汇报方案成果',
|
||||
'落地指导:指导方案实施,确保落地效果',
|
||||
'持续跟踪:定期回访,持续优化建议',
|
||||
],
|
||||
heroThemeId: 'service',
|
||||
caseStudies: [
|
||||
{
|
||||
client: '某传统制造企业',
|
||||
industry: '制造业',
|
||||
challenge: '数字化转型方向不清晰,多次投入未见成效',
|
||||
solution: '系统性评估现状,制定三年数字化转型路线图',
|
||||
result: '首年完成核心系统升级,ROI达150%',
|
||||
},
|
||||
],
|
||||
dataProofs: [
|
||||
{ metric: '咨询项目数', value: '50+', description: '累计服务企业数量' },
|
||||
{ metric: '方案落地率', value: '90%+', description: '咨询方案成功执行比例' },
|
||||
{ metric: '行业覆盖', value: '6个', description: '制造/零售/金融/医疗/教育/物流' },
|
||||
],
|
||||
methodology: [
|
||||
{
|
||||
title: '战略对齐层',
|
||||
description: '从业务战略出发,确保技术投资与业务目标深度对齐',
|
||||
items: ['业务战略解读与数字化成熟度评估', '技术愿景与目标设定', '投资回报分析与优先级排序', '组织能力差距分析'],
|
||||
},
|
||||
{
|
||||
title: '架构设计层',
|
||||
description: '基于行业最佳实践,设计可演进的技术架构体系',
|
||||
items: ['应用架构设计', '数据架构规划', '技术栈选型与评估', '集成架构设计'],
|
||||
},
|
||||
{
|
||||
title: '落地执行层',
|
||||
description: '制定可执行的落地路线图,确保方案从PPT走向现实',
|
||||
items: ['分阶段实施路线图', '里程碑与关键节点设计', '风险管理与应对策略', '组织变革与团队赋能'],
|
||||
},
|
||||
{
|
||||
title: '持续优化层',
|
||||
description: '建立持续优化机制,保障数字化能力持续成长',
|
||||
items: ['效果评估与度量体系', '技术债务管理机制', '最佳实践沉淀与复用', '持续改进闭环'],
|
||||
},
|
||||
],
|
||||
techStack: [
|
||||
{ name: '战略框架', items: ['TOGAF', 'Zachman', 'FEA', 'Gartner'] },
|
||||
{ name: '架构工具', items: ['ArchiMate', 'UML', 'C4 Model', 'TOGAF'] },
|
||||
{ name: '评估模型', items: ['数字化成熟度模型', '技术债务评估', '团队能力评估'] },
|
||||
],
|
||||
faqs: [
|
||||
{
|
||||
question: '战略咨询一般需要多长时间?',
|
||||
answer: '根据项目范围和深度不同,通常在 4-12 周。小型评估类项目 2-4 周,中型规划类项目 6-8 周,大型转型规划项目 8-12 周。具体周期会在初步沟通后给出精准评估。',
|
||||
},
|
||||
{
|
||||
question: '咨询方案能保证落地吗?',
|
||||
answer: '我们的咨询方案 90%+ 成功落地。关键在于方案设计时就充分考虑企业的实际能力、资源和约束条件,不做脱离现实的"完美方案"。我们还提供落地指导服务,确保方案真正产生价值。',
|
||||
},
|
||||
{
|
||||
question: '是否提供后续的技术支持?',
|
||||
answer: '是的。我们提供从咨询到落地的全链路服务。咨询完成后,可以选择我们的技术实施服务、持续运营服务,或者仅做定期的技术评审和指导。',
|
||||
},
|
||||
{
|
||||
question: '如何选择适合的咨询服务?',
|
||||
answer: '建议先预约一次免费的初步沟通(约 60 分钟),我们会了解您的现状和需求后,推荐最适合的服务类型和深度,不会为了卖服务而夸大需求。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'software',
|
||||
title: '企业软件',
|
||||
description: 'ERP、CRM、BI 等核心系统的评估、实施与定制化开发。基于行业最佳实践,打造适配企业实际的数字化底座。',
|
||||
icon: 'Code',
|
||||
overview: '从需求分析到上线运维,全栈定制化开发服务。覆盖企业级应用、移动应用、数据平台,95%+准时交付率。',
|
||||
features: [
|
||||
'ERP 实施与优化:核心ERP系统实施、升级与优化',
|
||||
'CRM 客户关系管理:全渠道客户管理与营销自动化',
|
||||
'BI 商业智能:数据可视化与经营分析平台',
|
||||
'低代码平台:快速构建业务应用,响应业务变化',
|
||||
'系统集成:打通各业务系统,消除数据孤岛',
|
||||
],
|
||||
benefits: [
|
||||
'提升业务效率,降低运营成本',
|
||||
'增强用户体验,提高客户满意度',
|
||||
'快速响应市场变化,保持竞争优势',
|
||||
'数据驱动决策,支持业务增长',
|
||||
],
|
||||
capabilities: [
|
||||
'ERP/CRM/BI 核心系统实施与优化',
|
||||
'企业级应用定制化开发',
|
||||
'系统集成与数据中台建设',
|
||||
'敏捷交付与质量保障',
|
||||
],
|
||||
process: [
|
||||
'需求分析:深入了解业务需求,制定详细需求文档',
|
||||
'方案设计:设计系统架构和技术方案',
|
||||
'开发实施:按照敏捷开发流程进行开发',
|
||||
'测试验收:全面测试,确保质量达标',
|
||||
'部署上线:协助部署,确保平稳上线',
|
||||
'运维支持:提供持续的技术支持和维护',
|
||||
],
|
||||
heroThemeId: 'service',
|
||||
caseStudies: [
|
||||
{
|
||||
client: '某上市制造企业',
|
||||
industry: '制造业',
|
||||
challenge: '原有 ERP 系统已运行 8 年,无法支撑新业务模式',
|
||||
solution: '采用分步迁移策略,以数据中台为核心重构业务流程',
|
||||
result: '生产效率提升40%,决策效率提升3倍',
|
||||
},
|
||||
],
|
||||
dataProofs: [
|
||||
{ metric: '准时交付率', value: '95%+', description: '严格的项目管理保障' },
|
||||
{ metric: '代码质量', value: 'A+', description: 'Code Review + 自动化测试' },
|
||||
{ metric: '平均响应时间', value: '<4小时', description: '工作日技术支持' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'data',
|
||||
title: '技术服务',
|
||||
description: '系统集成、数据中台、云原生架构设计与技术外包服务。以工程化能力保障系统的稳定性、可扩展性与持续迭代。',
|
||||
icon: 'BarChart3',
|
||||
overview: '让数据从"存着"变成"用着"。多源数据整合、可视化看板、预测模型,为经营决策提供可量化的洞察支撑。',
|
||||
features: [
|
||||
'系统集成与架构:企业级系统集成与架构设计',
|
||||
'数据中台建设:构建统一数据底座,打通数据孤岛',
|
||||
'云原生迁移:云原生架构设计与迁移服务',
|
||||
'研发效能提升:DevOps 体系建设与流程优化',
|
||||
'运维支持:7x24小时运维保障与技术支持',
|
||||
],
|
||||
benefits: [
|
||||
'发现隐藏的业务机会和风险',
|
||||
'提高决策的科学性和准确性',
|
||||
'优化业务流程,提升运营效率',
|
||||
'增强市场洞察力,把握市场趋势',
|
||||
],
|
||||
capabilities: [
|
||||
'多源数据整合与数据治理',
|
||||
'实时数据看板与可视化报表',
|
||||
'云原生架构设计与迁移',
|
||||
'7x24 运维保障与技术支持',
|
||||
],
|
||||
process: [
|
||||
'数据评估:评估数据质量和可用性',
|
||||
'平台搭建:构建数据中台与分析平台',
|
||||
'模型开发:开发数据分析模型与算法',
|
||||
'可视化展示:创建可视化仪表板与报表',
|
||||
'洞察挖掘:深入挖掘数据洞察与业务价值',
|
||||
'持续优化:持续优化分析模型与流程',
|
||||
],
|
||||
heroThemeId: 'service',
|
||||
caseStudies: [
|
||||
{
|
||||
client: '某三甲医院',
|
||||
industry: '医疗健康',
|
||||
challenge: '医院各业务系统独立建设,数据分散,难以形成统一视图',
|
||||
solution: '构建医院数据中台,打通 20+ 业务系统数据',
|
||||
result: '决策效率提升10倍,数据准确率达99.9%',
|
||||
},
|
||||
],
|
||||
dataProofs: [
|
||||
{ metric: '数据源对接', value: '20+种', description: '数据库/文件/API等' },
|
||||
{ metric: '技术方案交付', value: '100+', description: '累计交付的技术方案' },
|
||||
{ metric: '系统可用率', value: '99.9%', description: '生产环境稳定性' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'solutions',
|
||||
title: 'AI 赋能',
|
||||
description: 'AI 成熟度评估、场景落地与大模型应用开发。帮助企业找到 AI 与业务的最佳结合点,实现智能化升级。',
|
||||
icon: 'Puzzle',
|
||||
overview: 'AI 战略规划、大模型应用开发、智能流程自动化,帮助企业快速落地 AI 应用,释放智能化生产力。',
|
||||
features: [
|
||||
'AI 战略规划:AI 成熟度评估与落地路线图设计',
|
||||
'大模型应用开发:基于大模型的业务应用开发',
|
||||
'智能流程自动化:RPA + AI 智能自动化解决方案',
|
||||
'数据治理:AI 时代的数据治理与质量管理',
|
||||
'AI 模型训练:定制化 AI 模型训练与优化',
|
||||
],
|
||||
benefits: [
|
||||
'快速落地 AI 应用,释放智能化生产力',
|
||||
'降低 AI 落地门槛与试错成本',
|
||||
'获得端到端的 AI 解决方案',
|
||||
'持续获得 AI 技术前沿能力与支持',
|
||||
],
|
||||
capabilities: [
|
||||
'AI 成熟度评估与场景筛选',
|
||||
'大模型应用开发与私有化部署',
|
||||
'RPA + AI 智能流程自动化',
|
||||
'AI 效果评估与持续迭代',
|
||||
],
|
||||
process: [
|
||||
'AI 成熟度评估:全面评估企业 AI 应用现状与机会',
|
||||
'场景筛选:识别高价值 AI 应用场景',
|
||||
'原型验证:快速构建 AI 原型,验证可行性',
|
||||
'实施部署:分阶段实施,确保平稳落地',
|
||||
'效果评估:量化评估 AI 应用效果',
|
||||
'持续迭代:根据反馈持续优化 AI 能力',
|
||||
],
|
||||
heroThemeId: 'service',
|
||||
caseStudies: [
|
||||
{
|
||||
client: '某教育科技集团',
|
||||
industry: '教育培训',
|
||||
challenge: '招生线索管理分散,营销活动效果难以追踪',
|
||||
solution: '建设集团统一 CRM 系统,引入 AI 营销自动化',
|
||||
result: '线索转化率提升45%,营销ROI提升2倍',
|
||||
},
|
||||
],
|
||||
dataProofs: [
|
||||
{ metric: 'AI 场景落地', value: '30+', description: '累计交付的AI场景方案' },
|
||||
{ metric: '效率提升', value: '85%', description: '平均流程效率提升' },
|
||||
{ metric: '客户满意度', value: '98%', description: 'AI项目客户满意度' },
|
||||
],
|
||||
},
|
||||
];
|
||||
// 服务数据已迁移至 CMS 种子文件,常量文件仅保留类型定义。
|
||||
// 运行时使用的数据请从 CMS 读取(src/lib/cms/data-server.ts)。
|
||||
export { SERVICES } from '../../../prisma/seeds/services';
|
||||
|
||||
@@ -68,248 +68,7 @@ export interface Solution {
|
||||
}
|
||||
|
||||
/**
|
||||
* 行业解决方案数据
|
||||
* TODO: 替换为真实解决方案信息
|
||||
* 行业解决方案数据已从硬编码迁移至 CMS 种子文件
|
||||
* @see /prisma/seeds/solutions.ts
|
||||
*/
|
||||
export const SOLUTIONS: Solution[] = [
|
||||
{
|
||||
id: 'manufacturing',
|
||||
industry: '制造业',
|
||||
title: '智能制造解决方案',
|
||||
subtitle: '从传统制造到智慧工厂',
|
||||
description: '针对制造业数字化转型需求,提供从生产计划到质量管控的全流程数字化解决方案,助力企业实现降本增效和精益生产。',
|
||||
challenges: [
|
||||
'生产数据孤岛,各部门信息不互通,决策缺乏数据支撑',
|
||||
'库存管理粗放,物料浪费严重,资金占用成本高',
|
||||
'质量管控依赖人工经验,追溯困难,客诉处理效率低',
|
||||
'设备维护被动响应,非计划停机频发,影响交付周期',
|
||||
],
|
||||
solutions: [
|
||||
'建设统一的 MES 生产执行系统,实现生产全流程数字化管控',
|
||||
'部署智能仓储管理系统(WMS),优化库存周转率,降低资金占用',
|
||||
'引入 AI 质量检测与 SPC 统计过程控制,实现质量全链路追溯',
|
||||
'搭建设备物联网平台,实现预测性维护,降低非计划停机率',
|
||||
],
|
||||
relatedProducts: ['erp', 'bi'],
|
||||
heroThemeId: 'solution',
|
||||
valueProposition: {
|
||||
headline: '从车间到决策层,全链路数字化赋能',
|
||||
points: [
|
||||
{ icon: 'Factory', title: '生产透明化', description: '实时掌握生产进度、设备状态和质量数据' },
|
||||
{ icon: 'Package', title: '供应链协同', description: '采购、库存、物流全流程可视可控' },
|
||||
{ icon: 'BarChart3', title: '数据驱动决策', description: '多维度分析报表,支撑精益管理' },
|
||||
],
|
||||
},
|
||||
suiteCombination: {
|
||||
primaryProducts: ['erp', 'bi'],
|
||||
complementaryServices: ['consulting', 'solutions'],
|
||||
rationale: 'ERP打通生产-财务-供应链核心链路,BI提供实时生产看板和品质分析,配合咨询规划实施路径,确保方案落地见效。',
|
||||
},
|
||||
painPoints: ['生产数据孤岛,决策缺乏实时数据支撑', '库存管理粗放,资金占用成本高'],
|
||||
outcomes: [
|
||||
{ value: '30%', label: '库存周转提升' },
|
||||
{ value: '40%', label: '生产效率提升' },
|
||||
],
|
||||
recommendedProducts: ['erp', 'bi', 'sds'],
|
||||
},
|
||||
{
|
||||
id: 'retail',
|
||||
industry: '贸易零售',
|
||||
title: '新零售数字化解决方案',
|
||||
subtitle: '线上线下融合,数据驱动增长',
|
||||
description: '帮助零售企业打通线上线下渠道,构建全渠道会员体系和精准营销能力,实现从经验驱动到数据驱动的经营模式升级。',
|
||||
challenges: [
|
||||
'线上线下渠道割裂,会员体系不统一,客户体验碎片化',
|
||||
'营销活动效果难以量化,获客成本持续攀升,ROI 不明确',
|
||||
'供应链响应速度慢,库存积压与缺货并存',
|
||||
'缺乏客户画像和消费行为分析,无法实现精准营销',
|
||||
],
|
||||
solutions: [
|
||||
'搭建全渠道中台,统一会员、订单、库存数据,实现无缝购物体验',
|
||||
'建设 CDP 客户数据平台,构建 360° 客户画像,支撑精准营销',
|
||||
'部署智能供应链管理系统,实现需求预测和智能补货',
|
||||
'引入 BI 数据分析平台,实时监控经营指标,辅助经营决策',
|
||||
],
|
||||
relatedProducts: ['crm', 'bi'],
|
||||
heroThemeId: 'solution',
|
||||
valueProposition: {
|
||||
headline: '全渠道融合,数据驱动增长',
|
||||
points: [
|
||||
{ icon: 'Users', title: '客户统一视图', description: '线上线下会员数据打通,360°客户画像' },
|
||||
{ icon: 'TrendingUp', title: '精准营销', description: '基于数据分析的智能推荐和精准触达' },
|
||||
{ icon: 'ShoppingCart', title: '智能供应链', description: '需求预测+智能补货,降本增效' },
|
||||
],
|
||||
},
|
||||
suiteCombination: {
|
||||
primaryProducts: ['crm', 'bi'],
|
||||
complementaryServices: ['data', 'solutions'],
|
||||
rationale: 'CRM构建全渠道客户统一视图和销售漏斗管理,BI提供经营分析和客户洞察,数据服务支撑CDP建设和预测模型开发。',
|
||||
},
|
||||
painPoints: ['线上线下渠道割裂,会员体验碎片化', '供应链响应慢,库存积压与缺货并存'],
|
||||
outcomes: [
|
||||
{ value: '25%', label: '营收增长' },
|
||||
{ value: '99%', label: '全渠道库存准确率' },
|
||||
],
|
||||
recommendedProducts: ['crm', 'bi', 'sds'],
|
||||
},
|
||||
{
|
||||
id: 'education',
|
||||
industry: '教育培训',
|
||||
title: '智慧教育解决方案',
|
||||
subtitle: '科技赋能教育,数据驱动教学',
|
||||
description: '为教育机构提供从招生管理到教学评估的全场景数字化解决方案,提升教学质量和运营效率。',
|
||||
challenges: [
|
||||
'招生管理流程繁琐,数据分散,转化率难以提升',
|
||||
'教学资源管理混乱,优质内容难以沉淀和复用',
|
||||
'学情数据采集不完整,个性化教学难以落地',
|
||||
'家校沟通效率低,信息传达不及时',
|
||||
],
|
||||
solutions: [
|
||||
'建设一体化招生管理系统,实现线索到报名的全流程数字化',
|
||||
'搭建教学资源管理平台,支持课程内容的创建、共享和复用',
|
||||
'部署学情分析系统,基于学习行为数据提供个性化学习路径推荐',
|
||||
'构建家校互通平台,实现信息实时推送和在线沟通',
|
||||
],
|
||||
relatedProducts: ['cms', 'oa'],
|
||||
heroThemeId: 'solution',
|
||||
valueProposition: {
|
||||
headline: '科技赋能教学,数据驱动成长',
|
||||
points: [
|
||||
{ icon: 'GraduationCap', title: '招生数字化', description: '从线索到报名全流程可视化管理' },
|
||||
{ icon: 'BookOpen', title: '资源共享', description: '优质课程内容统一管理、高效复用' },
|
||||
{ icon: 'MessageSquare', title: '家校互通', description: '信息实时推送,沟通零距离' },
|
||||
],
|
||||
},
|
||||
suiteCombination: {
|
||||
primaryProducts: ['cms', 'oa'],
|
||||
complementaryServices: ['software', 'consulting'],
|
||||
rationale: 'CMS管理多校区网站和教学资源,OA支撑招生流程审批和家校协同,配合软件开发定制特色功能模块。',
|
||||
},
|
||||
painPoints: ['招生线索管理分散,转化率难以提升', '教学资源管理混乱,优质内容难以沉淀'],
|
||||
outcomes: [
|
||||
{ value: '45%', label: '线索转化率提升' },
|
||||
{ value: '60%', label: '内容发布效率提升' },
|
||||
],
|
||||
recommendedProducts: ['cms', 'oa', 'crm'],
|
||||
},
|
||||
{
|
||||
id: 'healthcare',
|
||||
industry: '医疗健康',
|
||||
title: '医疗数字化解决方案',
|
||||
subtitle: '智慧医疗,数据赋能健康',
|
||||
description: '为医疗机构提供从临床管理到运营分析的全栈数字化解决方案,提升医疗服务质量和运营效率。',
|
||||
challenges: [
|
||||
'临床数据分散在不同系统中,医生调阅效率低',
|
||||
'排班和资源调度依赖人工,效率低且容易出错',
|
||||
'患者就诊流程繁琐,等待时间长,体验差',
|
||||
'运营数据分析能力弱,管理决策缺乏数据支撑',
|
||||
],
|
||||
solutions: [
|
||||
'建设临床数据中心(CDR),整合多源临床数据,提升医生诊疗效率',
|
||||
'部署智能排班和资源调度系统,优化医疗资源配置',
|
||||
'搭建患者服务平台,实现预约、挂号、缴费、报告查询全流程线上化',
|
||||
'引入运营分析 BI 平台,实时监控关键运营指标,支撑精细化管理',
|
||||
],
|
||||
relatedProducts: ['erp', 'sds'],
|
||||
heroThemeId: 'solution',
|
||||
valueProposition: {
|
||||
headline: '智慧医疗,数据赋能健康',
|
||||
points: [
|
||||
{ icon: 'Heart', title: '临床协同', description: '多源数据整合,医生调阅效率提升' },
|
||||
{ icon: 'Calendar', title: '资源优化', description: '智能排班调度,资源利用率最大化' },
|
||||
{ icon: 'Smartphone', title: '患者体验', description: '全流程线上化,等待时间大幅缩短' },
|
||||
],
|
||||
},
|
||||
suiteCombination: {
|
||||
primaryProducts: ['erp', 'sds'],
|
||||
complementaryServices: ['software', 'data'],
|
||||
rationale: 'ERP管理医院运营核心(财务/采购/库存),SDS优化药品和耗材供应链,配合软件开发定制医疗特色模块。',
|
||||
},
|
||||
painPoints: ['临床数据分散,医生调阅效率低', '运营决策依赖人工统计,时效性差'],
|
||||
outcomes: [
|
||||
{ value: '10x', label: '决策效率提升' },
|
||||
{ value: '30%', label: '运营成本下降' },
|
||||
],
|
||||
recommendedProducts: ['erp', 'sds', 'bi'],
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
industry: '金融服务',
|
||||
title: '金融数字化解决方案',
|
||||
subtitle: '合规驱动,数据赋能',
|
||||
description: '为银行、保险、证券等金融机构提供全链路数字化解决方案,助力业务创新与风控能力提升,在合规前提下实现数字化转型。',
|
||||
challenges: [
|
||||
'监管要求持续升级,合规管理成本高、效率低',
|
||||
'客户期望全渠道服务体验,传统渠道难以满足',
|
||||
'风险管控依赖人工经验,时效性和准确性不足',
|
||||
'数据孤岛严重,客户画像和精准营销难以落地',
|
||||
],
|
||||
solutions: [
|
||||
'建设合规管理系统,实现监管报表自动化和合规流程线上化',
|
||||
'搭建全渠道客户服务平台,统一客户视图和服务体验',
|
||||
'引入智能风控系统,基于机器学习提升风险识别准确率',
|
||||
'构建金融数据中台,支撑客户画像、精准营销和经营决策',
|
||||
],
|
||||
relatedProducts: ['bi', 'sds'],
|
||||
heroThemeId: 'solution',
|
||||
valueProposition: {
|
||||
headline: '合规与创新并重,数字化驱动金融升级',
|
||||
points: [
|
||||
{ icon: 'Shield', title: '合规提效', description: '监管报表自动化,合规流程线上化,降低合规成本' },
|
||||
{ icon: 'Users', title: '客户体验', description: '全渠道统一服务,提升客户满意度和留存率' },
|
||||
{ icon: 'TrendingUp', title: '智能风控', description: 'AI驱动风险识别,提升风控效率和准确性' },
|
||||
],
|
||||
},
|
||||
suiteCombination: {
|
||||
primaryProducts: ['bi', 'sds'],
|
||||
complementaryServices: ['data', 'software'],
|
||||
rationale: 'BI构建经营分析和监管报表体系,SDS支撑供应链金融和资产管理,数据服务助力风控模型和客户画像建设。',
|
||||
},
|
||||
painPoints: ['监管要求持续升级,合规管理成本高', '数据孤岛严重,客户画像难以落地'],
|
||||
outcomes: [
|
||||
{ value: '70%', label: '财务效率提升' },
|
||||
{ value: '0', label: '重大合规事故' },
|
||||
],
|
||||
recommendedProducts: ['bi', 'sds', 'erp'],
|
||||
},
|
||||
{
|
||||
id: 'logistics',
|
||||
industry: '物流运输',
|
||||
title: '智慧物流解决方案',
|
||||
subtitle: '全链路可视,智能调度优化',
|
||||
description: '为物流运输企业提供从订单管理到运输调度的全流程数字化解决方案,实现运输全链路可视可控,降本增效。',
|
||||
challenges: [
|
||||
'运输过程不透明,货物追踪困难,客户体验差',
|
||||
'调度依赖人工经验,车辆空驶率高,运营成本居高不下',
|
||||
'仓储与运输协同不足,库存周转效率低',
|
||||
'数据分散在多个系统,经营决策缺乏数据支撑',
|
||||
],
|
||||
solutions: [
|
||||
'搭建运输管理系统(TMS),实现订单、调度、追踪全流程数字化',
|
||||
'引入智能调度算法,优化车辆配载和路径规划,降低空驶率',
|
||||
'建设仓储管理系统(WMS),打通仓储运输链路,提升库存周转率',
|
||||
'部署物流数据分析平台,实时监控运营指标,支撑精细化管理',
|
||||
],
|
||||
relatedProducts: ['erp', 'bi'],
|
||||
heroThemeId: 'solution',
|
||||
valueProposition: {
|
||||
headline: '全链路数字化,让物流更高效更透明',
|
||||
points: [
|
||||
{ icon: 'Truck', title: '全程可视', description: '订单-运输-签收全链路追踪,提升客户体验' },
|
||||
{ icon: 'Route', title: '智能调度', description: '算法优化配载和路径,降低运输成本' },
|
||||
{ icon: 'BarChart3', title: '数据驱动', description: '多维度运营分析,支撑精细化管理决策' },
|
||||
],
|
||||
},
|
||||
suiteCombination: {
|
||||
primaryProducts: ['erp', 'bi'],
|
||||
complementaryServices: ['software', 'data'],
|
||||
rationale: 'ERP管理财务和核心业务,BI提供运营分析和成本核算,配合软件开发定制TMS/WMS等物流特色模块。',
|
||||
},
|
||||
painPoints: ['运输过程不透明,客户体验差', '调度依赖人工经验,车辆空驶率高'],
|
||||
outcomes: [
|
||||
{ value: '20%', label: '空驶率降低' },
|
||||
{ value: '95%+', label: '全程可视化率' },
|
||||
],
|
||||
recommendedProducts: ['erp', 'bi', 'sds'],
|
||||
},
|
||||
];
|
||||
export { SOLUTIONS } from '../../../prisma/seeds/solutions';
|
||||
|
||||
@@ -9,57 +9,3 @@ export interface TeamMember {
|
||||
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: '团队成员' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeAll } from '@jest/globals';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
isImage,
|
||||
getImageMetadata,
|
||||
generateDerivatives,
|
||||
THUMBNAIL_SIZE,
|
||||
} from './image-processor';
|
||||
|
||||
describe('image-processor', () => {
|
||||
let testPng: Buffer;
|
||||
let testJpeg: Buffer;
|
||||
const txtBuffer = Buffer.from('not an image');
|
||||
|
||||
beforeAll(async () => {
|
||||
testPng = await sharp({
|
||||
create: {
|
||||
width: 800,
|
||||
height: 600,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 0, b: 0 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
testJpeg = await sharp({
|
||||
create: {
|
||||
width: 400,
|
||||
height: 300,
|
||||
channels: 3,
|
||||
background: { r: 0, g: 0, b: 255 },
|
||||
},
|
||||
})
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
});
|
||||
|
||||
describe('isImage', () => {
|
||||
it('returns true for common image mime types', () => {
|
||||
expect(isImage('image/png')).toBe(true);
|
||||
expect(isImage('image/jpeg')).toBe(true);
|
||||
expect(isImage('image/webp')).toBe(true);
|
||||
expect(isImage('image/avif')).toBe(true);
|
||||
expect(isImage('image/gif')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-image mime types', () => {
|
||||
expect(isImage('application/pdf')).toBe(false);
|
||||
expect(isImage('text/plain')).toBe(false);
|
||||
expect(isImage('video/mp4')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getImageMetadata', () => {
|
||||
it('returns width and height for a valid image buffer', async () => {
|
||||
const meta = await getImageMetadata(testPng);
|
||||
expect(meta).toEqual({ width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it('returns null for non-image buffer', async () => {
|
||||
const meta = await getImageMetadata(txtBuffer);
|
||||
expect(meta).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDerivatives', () => {
|
||||
it('generates thumbnail, webp and avif for image/png', async () => {
|
||||
const derivatives = await generateDerivatives(testPng, 'image/png');
|
||||
|
||||
expect(derivatives.thumbnail).toBeDefined();
|
||||
expect(derivatives.webp).toBeDefined();
|
||||
expect(derivatives.avif).toBeDefined();
|
||||
|
||||
expect(derivatives.thumbnail!.width).toBeLessThanOrEqual(THUMBNAIL_SIZE);
|
||||
expect(derivatives.thumbnail!.height).toBeLessThanOrEqual(THUMBNAIL_SIZE);
|
||||
expect(derivatives.thumbnail!.path).toMatch(/-thumbnail\./);
|
||||
expect(derivatives.thumbnail!.url).toMatch(/-thumbnail\./);
|
||||
|
||||
expect(derivatives.webp!.path).toMatch(/\.webp$/);
|
||||
expect(derivatives.webp!.url).toMatch(/\.webp$/);
|
||||
|
||||
expect(derivatives.avif!.path).toMatch(/\.avif$/);
|
||||
expect(derivatives.avif!.url).toMatch(/\.avif$/);
|
||||
});
|
||||
|
||||
it('keeps original aspect ratio for thumbnail', async () => {
|
||||
const derivatives = await generateDerivatives(testPng, 'image/png');
|
||||
const thumb = derivatives.thumbnail!;
|
||||
expect(thumb.width / thumb.height).toBeCloseTo(800 / 600, 1);
|
||||
});
|
||||
|
||||
it('returns empty derivatives for non-image files', async () => {
|
||||
const derivatives = await generateDerivatives(txtBuffer, 'text/plain');
|
||||
expect(Object.keys(derivatives)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('generates webp/avif with same dimensions as original', async () => {
|
||||
const derivatives = await generateDerivatives(testJpeg, 'image/jpeg');
|
||||
expect(derivatives.webp!.width).toBe(400);
|
||||
expect(derivatives.webp!.height).toBe(300);
|
||||
expect(derivatives.avif!.width).toBe(400);
|
||||
expect(derivatives.avif!.height).toBe(300);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import sharp from 'sharp';
|
||||
import type { GeneratedDerivatives, ImageDerivative } from './types';
|
||||
|
||||
export const THUMBNAIL_SIZE = 320;
|
||||
|
||||
const IMAGE_MIME_PREFIX = 'image/';
|
||||
|
||||
/**
|
||||
* 判断 MIME 类型是否为图片
|
||||
*/
|
||||
export function isImage(mimeType: string): boolean {
|
||||
return mimeType.startsWith(IMAGE_MIME_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片宽高元数据
|
||||
* 非图片或解析失败时返回 null
|
||||
*/
|
||||
export async function getImageMetadata(
|
||||
buffer: Buffer
|
||||
): Promise<{ width: number; height: number } | null> {
|
||||
try {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
if (!metadata.width || !metadata.height) return null;
|
||||
return { width: metadata.width, height: metadata.height };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDerivativeFileName(originalName: string, suffix: string, ext: string): string {
|
||||
const baseName = originalName.replace(/\.[^.]+$/, '');
|
||||
return `${baseName}-${suffix}.${ext}`;
|
||||
}
|
||||
|
||||
function buildDerivative(
|
||||
originalName: string,
|
||||
suffix: string,
|
||||
ext: string,
|
||||
width: number,
|
||||
height: number,
|
||||
buffer: Buffer
|
||||
): ImageDerivative {
|
||||
const fileName = buildDerivativeFileName(originalName, suffix, ext);
|
||||
return {
|
||||
path: `public/uploads/${fileName}`,
|
||||
url: `/uploads/${fileName}`,
|
||||
width,
|
||||
height,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 为图片生成缩略图、WebP、AVIF 派生格式
|
||||
* 非图片返回空对象
|
||||
*
|
||||
* 实现说明:
|
||||
* - 缩略图限制长边为 THUMBNAIL_SIZE,保持比例
|
||||
* - WebP/AVIF 保持原图尺寸,用于现代浏览器优化
|
||||
*/
|
||||
export async function generateDerivatives(
|
||||
buffer: Buffer,
|
||||
mimeType: string
|
||||
): Promise<GeneratedDerivatives> {
|
||||
if (!isImage(mimeType)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const metadata = await getImageMetadata(buffer);
|
||||
if (!metadata) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const originalName = `image-${Date.now()}`;
|
||||
const derivatives: GeneratedDerivatives = {};
|
||||
|
||||
// 缩略图
|
||||
const thumbnailBuffer = await sharp(buffer)
|
||||
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, { fit: 'inside', withoutEnlargement: true })
|
||||
.toBuffer();
|
||||
const thumbnailMeta = await sharp(thumbnailBuffer).metadata();
|
||||
derivatives.thumbnail = buildDerivative(
|
||||
originalName,
|
||||
'thumbnail',
|
||||
'webp',
|
||||
thumbnailMeta.width || THUMBNAIL_SIZE,
|
||||
thumbnailMeta.height || THUMBNAIL_SIZE,
|
||||
thumbnailBuffer
|
||||
);
|
||||
|
||||
// WebP
|
||||
const webpBuffer = await sharp(buffer).webp({ quality: 85 }).toBuffer();
|
||||
const webpMeta = await sharp(webpBuffer).metadata();
|
||||
derivatives.webp = buildDerivative(
|
||||
originalName,
|
||||
'webp',
|
||||
'webp',
|
||||
webpMeta.width || metadata.width,
|
||||
webpMeta.height || metadata.height,
|
||||
webpBuffer
|
||||
);
|
||||
|
||||
// AVIF
|
||||
const avifBuffer = await sharp(buffer).avif({ quality: 80 }).toBuffer();
|
||||
const avifMeta = await sharp(avifBuffer).metadata();
|
||||
derivatives.avif = buildDerivative(
|
||||
originalName,
|
||||
'avif',
|
||||
'avif',
|
||||
avifMeta.width || metadata.width,
|
||||
avifMeta.height || metadata.height,
|
||||
avifBuffer
|
||||
);
|
||||
|
||||
return derivatives;
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
// ─── Mock storage provider ───────────────────────────────────────────────
|
||||
const mockStorageSave = jest.fn() as jest.Mock<any>;
|
||||
const mockStorageDelete = jest.fn() as jest.Mock<any>;
|
||||
|
||||
jest.mock('./storage', () => ({
|
||||
getStorageProvider: jest.fn().mockReturnValue({
|
||||
type: 'local',
|
||||
save: mockStorageSave,
|
||||
delete: mockStorageDelete,
|
||||
}),
|
||||
}));
|
||||
|
||||
// ─── Mock image processor ────────────────────────────────────────────────
|
||||
const mockGenerateDerivatives = jest.fn() as jest.Mock<any>;
|
||||
const mockGetImageMetadata = jest.fn() as jest.Mock<any>;
|
||||
|
||||
jest.mock('./image-processor', () => ({
|
||||
generateDerivatives: mockGenerateDerivatives,
|
||||
getImageMetadata: mockGetImageMetadata,
|
||||
isImage: (mimeType: string) => mimeType.startsWith('image/'),
|
||||
THUMBNAIL_SIZE: 320,
|
||||
}));
|
||||
|
||||
// ─── Mock Prisma ─────────────────────────────────────────────────────────
|
||||
const mockMediaAssetCreate = jest.fn() as jest.Mock<any>;
|
||||
const mockMediaAssetFindUnique = jest.fn() as jest.Mock<any>;
|
||||
const mockMediaAssetDelete = jest.fn() as jest.Mock<any>;
|
||||
const mockMediaAssetFindMany = jest.fn() as jest.Mock<any>;
|
||||
const mockMediaAssetCount = jest.fn() as jest.Mock<any>;
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
mediaAsset: {
|
||||
create: mockMediaAssetCreate,
|
||||
findUnique: mockMediaAssetFindUnique,
|
||||
delete: mockMediaAssetDelete,
|
||||
findMany: mockMediaAssetFindMany,
|
||||
count: mockMediaAssetCount,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { uploadMedia, deleteMedia, getMediaById, listMedia } from './media-service';
|
||||
|
||||
describe('media-service', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('uploadMedia', () => {
|
||||
it('uploads image and creates MediaAsset with derivatives', async () => {
|
||||
const pngBuffer = Buffer.from('fake-png');
|
||||
mockStorageSave
|
||||
.mockResolvedValueOnce({ path: 'public/uploads/original.png', url: '/uploads/original.png' })
|
||||
.mockResolvedValueOnce({ path: 'public/uploads/thumb.webp', url: '/uploads/thumb.webp' })
|
||||
.mockResolvedValueOnce({ path: 'public/uploads/original-webp.webp', url: '/uploads/original-webp.webp' })
|
||||
.mockResolvedValueOnce({ path: 'public/uploads/original-avif.avif', url: '/uploads/original-avif.avif' });
|
||||
|
||||
mockGetImageMetadata.mockResolvedValue({ width: 800, height: 600 });
|
||||
mockGenerateDerivatives.mockResolvedValue({
|
||||
thumbnail: { path: 'public/uploads/thumb.webp', url: '/uploads/thumb.webp', width: 320, height: 240, buffer: Buffer.from('thumb') },
|
||||
webp: { path: 'public/uploads/original-webp.webp', url: '/uploads/original-webp.webp', width: 800, height: 600, buffer: Buffer.from('webp') },
|
||||
avif: { path: 'public/uploads/original-avif.avif', url: '/uploads/original-avif.avif', width: 800, height: 600, buffer: Buffer.from('avif') },
|
||||
});
|
||||
|
||||
mockMediaAssetCreate.mockResolvedValue({ id: 'asset-1' });
|
||||
|
||||
const result = await uploadMedia(
|
||||
{ name: 'original.png', buffer: pngBuffer, mimeType: 'image/png', size: 1024 },
|
||||
'editor'
|
||||
);
|
||||
|
||||
expect(mockStorageSave).toHaveBeenCalledTimes(4);
|
||||
expect(mockGenerateDerivatives).toHaveBeenCalledWith(pngBuffer, 'image/png');
|
||||
expect(mockMediaAssetCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
name: 'original.png',
|
||||
path: 'public/uploads/original.png',
|
||||
url: '/uploads/original.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1024,
|
||||
width: 800,
|
||||
height: 600,
|
||||
storageType: 'local',
|
||||
derivatives: JSON.stringify({
|
||||
thumbnail: { path: 'public/uploads/thumb.webp', url: '/uploads/thumb.webp', width: 320, height: 240 },
|
||||
webp: { path: 'public/uploads/original-webp.webp', url: '/uploads/original-webp.webp', width: 800, height: 600 },
|
||||
avif: { path: 'public/uploads/original-avif.avif', url: '/uploads/original-avif.avif', width: 800, height: 600 },
|
||||
}),
|
||||
createdBy: 'editor',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({ id: 'asset-1' });
|
||||
});
|
||||
|
||||
it('uploads non-image file without generating derivatives', async () => {
|
||||
const pdfBuffer = Buffer.from('fake-pdf');
|
||||
mockStorageSave.mockResolvedValueOnce({ path: 'public/uploads/file.pdf', url: '/uploads/file.pdf' });
|
||||
mockMediaAssetCreate.mockResolvedValue({ id: 'asset-2' });
|
||||
|
||||
const result = await uploadMedia(
|
||||
{ name: 'file.pdf', buffer: pdfBuffer, mimeType: 'application/pdf', size: 2048 },
|
||||
'editor'
|
||||
);
|
||||
|
||||
expect(mockStorageSave).toHaveBeenCalledTimes(1);
|
||||
expect(mockGenerateDerivatives).not.toHaveBeenCalled();
|
||||
expect(mockMediaAssetCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
name: 'file.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
size: 2048,
|
||||
width: 0,
|
||||
height: 0,
|
||||
derivatives: '{}',
|
||||
createdBy: 'editor',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({ id: 'asset-2' });
|
||||
});
|
||||
|
||||
it('uses storage provider type for storageType field', async () => {
|
||||
const { getStorageProvider } = jest.requireMock('./storage') as { getStorageProvider: jest.Mock };
|
||||
getStorageProvider.mockReturnValueOnce({
|
||||
type: 's3',
|
||||
save: mockStorageSave.mockResolvedValue({ path: 'key.png', url: 'https://cdn/key.png' }),
|
||||
delete: mockStorageDelete,
|
||||
});
|
||||
|
||||
mockMediaAssetCreate.mockResolvedValue({ id: 'asset-3' });
|
||||
|
||||
await uploadMedia(
|
||||
{ name: 'key.png', buffer: Buffer.from('x'), mimeType: 'image/png', size: 100 },
|
||||
'editor'
|
||||
);
|
||||
|
||||
expect(mockMediaAssetCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ storageType: 's3' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads image with zero dimensions when metadata cannot be parsed', async () => {
|
||||
mockStorageSave.mockResolvedValue({ path: 'public/uploads/broken.png', url: '/uploads/broken.png' });
|
||||
mockGetImageMetadata.mockResolvedValue(null);
|
||||
mockGenerateDerivatives.mockResolvedValue({});
|
||||
mockMediaAssetCreate.mockResolvedValue({ id: 'asset-4' });
|
||||
|
||||
await uploadMedia(
|
||||
{ name: 'broken.png', buffer: Buffer.from('not-really-png'), mimeType: 'image/png', size: 100 },
|
||||
'editor'
|
||||
);
|
||||
|
||||
expect(mockMediaAssetCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
width: 0,
|
||||
height: 0,
|
||||
derivatives: '{}',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMedia', () => {
|
||||
it('deletes original file and all derivatives then removes db record', async () => {
|
||||
mockMediaAssetFindUnique.mockResolvedValue({
|
||||
id: 'asset-1',
|
||||
path: 'public/uploads/original.png',
|
||||
derivatives: JSON.stringify({
|
||||
thumbnail: { path: 'public/uploads/thumb.webp' },
|
||||
webp: { path: 'public/uploads/original-webp.webp' },
|
||||
avif: { path: 'public/uploads/original-avif.avif' },
|
||||
}),
|
||||
});
|
||||
|
||||
await deleteMedia('asset-1');
|
||||
|
||||
expect(mockStorageDelete).toHaveBeenCalledTimes(4);
|
||||
expect(mockStorageDelete).toHaveBeenCalledWith('public/uploads/original.png');
|
||||
expect(mockStorageDelete).toHaveBeenCalledWith('public/uploads/thumb.webp');
|
||||
expect(mockStorageDelete).toHaveBeenCalledWith('public/uploads/original-webp.webp');
|
||||
expect(mockStorageDelete).toHaveBeenCalledWith('public/uploads/original-avif.avif');
|
||||
expect(mockMediaAssetDelete).toHaveBeenCalledWith({ where: { id: 'asset-1' } });
|
||||
});
|
||||
|
||||
it('throws when media asset not found', async () => {
|
||||
mockMediaAssetFindUnique.mockResolvedValue(null);
|
||||
await expect(deleteMedia('missing')).rejects.toThrow('文件不存在');
|
||||
});
|
||||
|
||||
it('only deletes original file when derivatives JSON is invalid', async () => {
|
||||
mockMediaAssetFindUnique.mockResolvedValue({
|
||||
id: 'asset-2',
|
||||
path: 'public/uploads/original.png',
|
||||
derivatives: 'not-json',
|
||||
});
|
||||
|
||||
await deleteMedia('asset-2');
|
||||
|
||||
expect(mockStorageDelete).toHaveBeenCalledTimes(1);
|
||||
expect(mockStorageDelete).toHaveBeenCalledWith('public/uploads/original.png');
|
||||
expect(mockMediaAssetDelete).toHaveBeenCalledWith({ where: { id: 'asset-2' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMediaById', () => {
|
||||
it('returns parsed media asset with derivatives', async () => {
|
||||
mockMediaAssetFindUnique.mockResolvedValue({
|
||||
id: 'asset-1',
|
||||
derivatives: JSON.stringify({ thumbnail: { url: '/t.webp' } }),
|
||||
});
|
||||
|
||||
const result = await getMediaById('asset-1');
|
||||
expect(result).toEqual({
|
||||
id: 'asset-1',
|
||||
derivatives: { thumbnail: { url: '/t.webp' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockMediaAssetFindUnique.mockResolvedValue(null);
|
||||
const result = await getMediaById('missing');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty derivatives when JSON is invalid', async () => {
|
||||
mockMediaAssetFindUnique.mockResolvedValue({
|
||||
id: 'asset-2',
|
||||
derivatives: 'not-json',
|
||||
});
|
||||
|
||||
const result = await getMediaById('asset-2');
|
||||
expect(result).toEqual({
|
||||
id: 'asset-2',
|
||||
derivatives: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listMedia', () => {
|
||||
it('returns paginated list', async () => {
|
||||
mockMediaAssetFindMany.mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]);
|
||||
mockMediaAssetCount.mockResolvedValue(5);
|
||||
|
||||
const result = await listMedia({ page: 2, pageSize: 2 });
|
||||
|
||||
expect(mockMediaAssetFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skip: 2,
|
||||
take: 2,
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({
|
||||
items: [{ id: 'a1' }, { id: 'a2' }],
|
||||
total: 5,
|
||||
page: 2,
|
||||
pageSize: 2,
|
||||
totalPages: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by mimeType prefix', async () => {
|
||||
mockMediaAssetFindMany.mockResolvedValue([]);
|
||||
mockMediaAssetCount.mockResolvedValue(0);
|
||||
|
||||
await listMedia({ mimeType: 'image' });
|
||||
|
||||
expect(mockMediaAssetFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { mimeType: { startsWith: 'image' } },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import path from 'path';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getStorageProvider } from './storage';
|
||||
import { generateDerivatives, getImageMetadata, isImage } from './image-processor';
|
||||
import type { UploadMediaInput, ListMediaOptions, MediaDerivatives, StoredFile, StoredDerivative, GeneratedDerivatives } from './types';
|
||||
|
||||
function generateUniqueFileName(originalName: string): string {
|
||||
const ext = path.extname(originalName) || '';
|
||||
const baseName = path.basename(originalName, ext) || 'file';
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).slice(2, 10);
|
||||
return `${baseName}-${timestamp}-${random}${ext}`;
|
||||
}
|
||||
|
||||
async function saveOriginalFile(
|
||||
provider: ReturnType<typeof getStorageProvider>,
|
||||
input: UploadMediaInput
|
||||
): Promise<StoredFile> {
|
||||
const fileName = generateUniqueFileName(input.name);
|
||||
return provider.save(fileName, input.buffer, input.mimeType);
|
||||
}
|
||||
|
||||
async function saveDerivative(
|
||||
provider: ReturnType<typeof getStorageProvider>,
|
||||
derivative: { path: string; url: string; buffer: Buffer; mimeType?: string }
|
||||
): Promise<StoredFile> {
|
||||
const fileName = path.basename(derivative.path);
|
||||
const mimeType = derivative.mimeType || 'application/octet-stream';
|
||||
return provider.save(fileName, derivative.buffer, mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传媒体文件并创建 MediaAsset 记录
|
||||
* 图片会自动生成缩略图、WebP、AVIF 派生格式并持久化
|
||||
*/
|
||||
export async function uploadMedia(input: UploadMediaInput, createdBy: string) {
|
||||
const provider = getStorageProvider();
|
||||
const original = await saveOriginalFile(provider, input);
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let derivatives: MediaDerivatives = {};
|
||||
|
||||
if (isImage(input.mimeType)) {
|
||||
const metadata = await getImageMetadata(input.buffer);
|
||||
if (metadata) {
|
||||
width = metadata.width;
|
||||
height = metadata.height;
|
||||
}
|
||||
|
||||
const generated: GeneratedDerivatives = await generateDerivatives(input.buffer, input.mimeType);
|
||||
const savedDerivatives: MediaDerivatives = {};
|
||||
|
||||
for (const key of Object.keys(generated) as Array<keyof MediaDerivatives>) {
|
||||
const derivative = generated[key];
|
||||
if (!derivative) continue;
|
||||
|
||||
const mimeType = key === 'thumbnail' || key === 'webp' ? 'image/webp' : 'image/avif';
|
||||
const saved = await saveDerivative(provider, { ...derivative, mimeType });
|
||||
const stored: StoredDerivative = {
|
||||
path: saved.path,
|
||||
url: saved.url,
|
||||
width: derivative.width,
|
||||
height: derivative.height,
|
||||
};
|
||||
savedDerivatives[key] = stored;
|
||||
}
|
||||
|
||||
derivatives = savedDerivatives;
|
||||
}
|
||||
|
||||
return prisma.mediaAsset.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
path: original.path,
|
||||
url: original.url,
|
||||
mimeType: input.mimeType,
|
||||
size: input.size,
|
||||
width,
|
||||
height,
|
||||
alt: input.name,
|
||||
storageType: provider.type,
|
||||
derivatives: JSON.stringify(derivatives),
|
||||
createdBy,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除媒体资源:清理原始文件及所有派生格式,然后删除数据库记录
|
||||
*/
|
||||
export async function deleteMedia(id: string) {
|
||||
const existing = await prisma.mediaAsset.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new Error('文件不存在');
|
||||
}
|
||||
|
||||
const provider = getStorageProvider();
|
||||
const pathsToDelete = [existing.path];
|
||||
|
||||
try {
|
||||
const derivatives = JSON.parse(existing.derivatives) as MediaDerivatives;
|
||||
for (const key of Object.keys(derivatives) as Array<keyof MediaDerivatives>) {
|
||||
const derivative = derivatives[key];
|
||||
if (derivative?.path) {
|
||||
pathsToDelete.push(derivative.path);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// derivatives 字段不是有效 JSON 时仅删除原始文件
|
||||
}
|
||||
|
||||
for (const filePath of pathsToDelete) {
|
||||
await provider.delete(filePath);
|
||||
}
|
||||
|
||||
await prisma.mediaAsset.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查询媒体资源并解析 derivatives JSON
|
||||
*/
|
||||
export async function getMediaById(id: string) {
|
||||
const asset = await prisma.mediaAsset.findUnique({ where: { id } });
|
||||
if (!asset) return null;
|
||||
|
||||
return {
|
||||
...asset,
|
||||
derivatives: parseDerivatives(asset.derivatives),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDerivatives(raw: string): MediaDerivatives {
|
||||
try {
|
||||
return JSON.parse(raw) as MediaDerivatives;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询媒体列表
|
||||
*/
|
||||
export async function listMedia(options: ListMediaOptions = {}) {
|
||||
const page = Math.max(1, options.page || 1);
|
||||
const pageSize = Math.max(1, Math.min(100, options.pageSize || 20));
|
||||
const mimeType = options.mimeType;
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (mimeType) {
|
||||
where.mimeType = { startsWith: mimeType };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.mediaAsset.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.mediaAsset.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
jest.mock('@aws-sdk/client-s3', () => ({
|
||||
S3Client: jest.fn().mockImplementation(() => ({
|
||||
send: (jest.fn() as jest.Mock<any>).mockResolvedValue({}),
|
||||
})),
|
||||
PutObjectCommand: jest.fn().mockImplementation((input: unknown) => input),
|
||||
DeleteObjectCommand: jest.fn().mockImplementation((input: unknown) => input),
|
||||
}));
|
||||
|
||||
import {
|
||||
LocalStorageProvider,
|
||||
getStorageProvider,
|
||||
S3StorageProvider,
|
||||
} from './storage';
|
||||
import type { StorageProvider } from './types';
|
||||
|
||||
// 控制环境变量以测试 provider 选择
|
||||
const originalEnv = process.env;
|
||||
|
||||
describe('LocalStorageProvider', () => {
|
||||
let tempDir: string;
|
||||
let provider: StorageProvider;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'media-test-'));
|
||||
provider = new LocalStorageProvider(tempDir, '/test-uploads');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('saves file to the configured directory and returns path/url', async () => {
|
||||
const buffer = Buffer.from('hello world');
|
||||
const result = await provider.save('test-file.txt', buffer, 'text/plain');
|
||||
|
||||
expect(result.path).toBe(path.join(tempDir, 'test-file.txt'));
|
||||
expect(result.url).toBe('/test-uploads/test-file.txt');
|
||||
|
||||
const saved = await fs.readFile(result.path);
|
||||
expect(saved.toString()).toBe('hello world');
|
||||
});
|
||||
|
||||
it('creates nested directories when saving files', async () => {
|
||||
const nestedProvider = new LocalStorageProvider(
|
||||
path.join(tempDir, 'nested', 'deep'),
|
||||
'/test-uploads'
|
||||
);
|
||||
const buffer = Buffer.from('nested');
|
||||
const result = await nestedProvider.save('deep.txt', buffer, 'text/plain');
|
||||
|
||||
expect(result.path).toContain(path.join('nested', 'deep', 'deep.txt'));
|
||||
const saved = await fs.readFile(result.path);
|
||||
expect(saved.toString()).toBe('nested');
|
||||
});
|
||||
|
||||
it('deletes the physical file', async () => {
|
||||
const buffer = Buffer.from('to delete');
|
||||
const result = await provider.save('delete-me.txt', buffer, 'text/plain');
|
||||
|
||||
await fs.access(result.path);
|
||||
await provider.delete(result.path);
|
||||
await expect(fs.access(result.path)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('does not throw when deleting non-existent file', async () => {
|
||||
await expect(
|
||||
provider.delete(path.join(tempDir, 'not-exist.txt'))
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3StorageProvider', () => {
|
||||
const mockSend = jest.fn<(command: unknown) => Promise<unknown>>();
|
||||
|
||||
beforeEach(() => {
|
||||
mockSend.mockReset();
|
||||
mockSend.mockResolvedValue({});
|
||||
});
|
||||
|
||||
function createMockClient(): { send: typeof mockSend } {
|
||||
return { send: mockSend };
|
||||
}
|
||||
|
||||
it('saves file with PutObjectCommand and public URL prefix', async () => {
|
||||
const client = createMockClient();
|
||||
const provider = new S3StorageProvider(client as any, 'test-bucket', 'https://cdn.example.com');
|
||||
|
||||
const result = await provider.save('folder/image.png', Buffer.from('png'), 'image/png');
|
||||
|
||||
expect(result.path).toBe('folder/image.png');
|
||||
expect(result.url).toBe('https://cdn.example.com/folder/image.png');
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
|
||||
const command = mockSend.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(command).toMatchObject({
|
||||
Bucket: 'test-bucket',
|
||||
Key: 'folder/image.png',
|
||||
Body: expect.any(Buffer),
|
||||
ContentType: 'image/png',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to default S3 URL when public prefix is absent', async () => {
|
||||
const client = createMockClient();
|
||||
const provider = new S3StorageProvider(client as any, 'test-bucket');
|
||||
|
||||
const result = await provider.save('image.png', Buffer.from('png'), 'image/png');
|
||||
|
||||
expect(result.url).toBe('https://test-bucket.s3.amazonaws.com/image.png');
|
||||
});
|
||||
|
||||
it('strips leading slash from key when saving', async () => {
|
||||
const client = createMockClient();
|
||||
const provider = new S3StorageProvider(client as any, 'test-bucket', 'https://cdn.example.com/');
|
||||
|
||||
const result = await provider.save('/image.png', Buffer.from('png'), 'image/png');
|
||||
|
||||
expect(result.url).toBe('https://cdn.example.com/image.png');
|
||||
const command = mockSend.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(command.Key).toBe('image.png');
|
||||
});
|
||||
|
||||
it('deletes file with DeleteObjectCommand and strips leading slash', async () => {
|
||||
const client = createMockClient();
|
||||
const provider = new S3StorageProvider(client as any, 'test-bucket');
|
||||
|
||||
await provider.delete('/folder/image.png');
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
const command = mockSend.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(command).toMatchObject({
|
||||
Bucket: 'test-bucket',
|
||||
Key: 'folder/image.png',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStorageProvider', () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.STORAGE_TYPE;
|
||||
delete process.env.S3_BUCKET;
|
||||
delete process.env.S3_ENDPOINT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('returns local provider by default', () => {
|
||||
const provider = getStorageProvider();
|
||||
expect(provider.type).toBe('local');
|
||||
});
|
||||
|
||||
it('returns local provider when STORAGE_TYPE=local', () => {
|
||||
process.env.STORAGE_TYPE = 'local';
|
||||
const provider = getStorageProvider();
|
||||
expect(provider.type).toBe('local');
|
||||
});
|
||||
|
||||
it('returns s3 provider when STORAGE_TYPE=s3 and required env vars are set', () => {
|
||||
process.env.STORAGE_TYPE = 's3';
|
||||
process.env.S3_BUCKET = 'test-bucket';
|
||||
process.env.S3_ENDPOINT = 'https://s3.test.com';
|
||||
process.env.S3_ACCESS_KEY_ID = 'key';
|
||||
process.env.S3_SECRET_ACCESS_KEY = 'secret';
|
||||
process.env.S3_REGION = 'us-east-1';
|
||||
|
||||
const provider = getStorageProvider();
|
||||
expect(provider.type).toBe('s3');
|
||||
expect(provider).toBeInstanceOf(S3StorageProvider);
|
||||
});
|
||||
|
||||
it('throws when STORAGE_TYPE=s3 but S3_BUCKET is missing', () => {
|
||||
process.env.STORAGE_TYPE = 's3';
|
||||
expect(() => getStorageProvider()).toThrow('S3_BUCKET');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import type { StorageProvider, StoredFile } from './types';
|
||||
|
||||
/**
|
||||
* 本地文件系统存储 Provider
|
||||
* 开发/测试环境使用,文件落盘到 baseDir,通过 publicPath 对外暴露 URL
|
||||
*/
|
||||
export class LocalStorageProvider implements StorageProvider {
|
||||
readonly type = 'local' as const;
|
||||
|
||||
constructor(
|
||||
private readonly baseDir: string,
|
||||
private readonly publicPath: string
|
||||
) {}
|
||||
|
||||
async save(fileName: string, buffer: Buffer, _mimeType: string): Promise<StoredFile> {
|
||||
const filePath = path.join(this.baseDir, fileName);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
url: `${this.publicPath.replace(/\/$/, '')}/${fileName.replace(/^\//, '')}`,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(filePath: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
// 文件可能已被删除,忽略 ENOENT
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* S3/OSS 兼容对象存储 Provider
|
||||
* 生产环境使用,通过环境变量配置 endpoint/credentials
|
||||
*/
|
||||
export class S3StorageProvider implements StorageProvider {
|
||||
readonly type = 's3' as const;
|
||||
|
||||
constructor(
|
||||
private readonly client: S3Client,
|
||||
private readonly bucket: string,
|
||||
private readonly publicUrlPrefix?: string
|
||||
) {}
|
||||
|
||||
async save(fileName: string, buffer: Buffer, mimeType: string): Promise<StoredFile> {
|
||||
const key = fileName.replace(/^\//, '');
|
||||
await this.client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: mimeType,
|
||||
})
|
||||
);
|
||||
|
||||
const url = this.publicUrlPrefix
|
||||
? `${this.publicUrlPrefix.replace(/\/$/, '')}/${key}`
|
||||
: `https://${this.bucket}.s3.amazonaws.com/${key}`;
|
||||
|
||||
return { path: key, url };
|
||||
}
|
||||
|
||||
async delete(filePath: string): Promise<void> {
|
||||
const key = filePath.startsWith('/') ? filePath.slice(1) : filePath;
|
||||
await this.client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createS3Client(): S3Client {
|
||||
const endpoint = process.env.S3_ENDPOINT;
|
||||
const region = process.env.S3_REGION || 'us-east-1';
|
||||
const accessKeyId = process.env.S3_ACCESS_KEY_ID;
|
||||
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY;
|
||||
|
||||
return new S3Client({
|
||||
region,
|
||||
endpoint,
|
||||
credentials:
|
||||
accessKeyId && secretAccessKey
|
||||
? { accessKeyId, secretAccessKey }
|
||||
: undefined,
|
||||
forcePathStyle: Boolean(endpoint),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据环境变量获取当前存储 Provider
|
||||
* - 默认 local,文件落盘 public/uploads/
|
||||
* - STORAGE_TYPE=s3 时要求 S3_BUCKET
|
||||
*/
|
||||
export function getStorageProvider(): StorageProvider {
|
||||
const storageType = process.env.STORAGE_TYPE || 'local';
|
||||
|
||||
if (storageType === 's3') {
|
||||
const bucket = process.env.S3_BUCKET;
|
||||
if (!bucket) {
|
||||
throw new Error('S3_BUCKET environment variable is required when STORAGE_TYPE=s3');
|
||||
}
|
||||
return new S3StorageProvider(
|
||||
createS3Client(),
|
||||
bucket,
|
||||
process.env.S3_PUBLIC_URL_PREFIX
|
||||
);
|
||||
}
|
||||
|
||||
const baseDir = process.env.LOCAL_UPLOAD_DIR || path.join(process.cwd(), 'public', 'uploads');
|
||||
const publicPath = process.env.LOCAL_UPLOAD_PATH || '/uploads';
|
||||
return new LocalStorageProvider(baseDir, publicPath);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 媒体管理模块类型定义
|
||||
*
|
||||
* 信源:基于 CMS PRD 第 4 节媒体管理模块与现有 MediaAsset Prisma 模型设计
|
||||
*/
|
||||
|
||||
export interface StoredFile {
|
||||
/** 存储路径(本地为相对路径,OSS/S3 为 object key) */
|
||||
path: string;
|
||||
/** 可公开访问的 URL */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface StoredDerivative extends StoredFile {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ImageDerivative extends StoredDerivative {
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface MediaDerivatives {
|
||||
thumbnail?: StoredDerivative;
|
||||
webp?: StoredDerivative;
|
||||
avif?: StoredDerivative;
|
||||
}
|
||||
|
||||
/** 图片处理器生成的派生格式,包含未落盘的 buffer */
|
||||
export type GeneratedDerivatives = {
|
||||
[K in keyof MediaDerivatives]: ImageDerivative | undefined;
|
||||
};
|
||||
|
||||
export interface UploadMediaInput {
|
||||
name: string;
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface UploadMediaResult {
|
||||
name: string;
|
||||
original: StoredFile;
|
||||
derivatives: MediaDerivatives;
|
||||
width: number;
|
||||
height: number;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ListMediaOptions {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface StorageProvider {
|
||||
readonly type: 'local' | 's3';
|
||||
save(fileName: string, buffer: Buffer, mimeType: string): Promise<StoredFile>;
|
||||
delete(filePath: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
userRole: { findMany: jest.fn() },
|
||||
permission: { findMany: jest.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
jest.unmock('./permissions');
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { hasPermission, checkUserPermission, requirePermission } from './permissions';
|
||||
import type { Permission, PermissionSuccess, PermissionFailure } from './permissions';
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authenticateRequest: jest.fn(),
|
||||
}));
|
||||
|
||||
import { authenticateRequest } from '@/lib/auth';
|
||||
|
||||
const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction<
|
||||
(request: NextRequest) => { userId: string; username: string; role: string } | null
|
||||
>;
|
||||
|
||||
const mockUserRoleFindMany = prisma.userRole.findMany as jest.MockedFunction<
|
||||
(args: unknown) => Promise<unknown[]>
|
||||
>;
|
||||
const mockPermissionFindMany = prisma.permission.findMany as jest.MockedFunction<
|
||||
(args: unknown) => Promise<unknown[]>
|
||||
>;
|
||||
|
||||
describe('hasPermission', () => {
|
||||
it('returns true when user has super_admin role', () => {
|
||||
const result = hasPermission(['super_admin'], 'news', 'create', []);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when a role has the exact permission', () => {
|
||||
const permissions: Permission[] = [
|
||||
{ roleCode: 'content_editor', modelCode: 'news', action: 'create' },
|
||||
];
|
||||
const result = hasPermission(['content_editor'], 'news', 'create', permissions);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when role has permission for different action', () => {
|
||||
const permissions: Permission[] = [
|
||||
{ roleCode: 'content_editor', modelCode: 'news', action: 'create' },
|
||||
];
|
||||
const result = hasPermission(['content_editor'], 'news', 'publish', permissions);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when role has permission for different model', () => {
|
||||
const permissions: Permission[] = [
|
||||
{ roleCode: 'content_editor', modelCode: 'news', action: 'create' },
|
||||
];
|
||||
const result = hasPermission(['content_editor'], 'product', 'create', permissions);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when user has no roles', () => {
|
||||
const result = hasPermission([], 'news', 'create', []);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkUserPermission', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns true when user role has permission', async () => {
|
||||
mockUserRoleFindMany.mockResolvedValue([{ roleCode: 'content_editor' }]);
|
||||
mockPermissionFindMany.mockResolvedValue([
|
||||
{ roleCode: 'content_editor', modelCode: 'news', action: 'create' },
|
||||
]);
|
||||
|
||||
const result = await checkUserPermission('user-1', 'news', 'create');
|
||||
|
||||
expect(mockUserRoleFindMany).toHaveBeenCalledWith({ where: { userId: 'user-1' } });
|
||||
expect(mockPermissionFindMany).toHaveBeenCalledWith({
|
||||
where: { roleCode: { in: ['content_editor'] } },
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when user role lacks permission', async () => {
|
||||
mockUserRoleFindMany.mockResolvedValue([{ roleCode: 'readonly' }]);
|
||||
mockPermissionFindMany.mockResolvedValue([
|
||||
{ roleCode: 'readonly', modelCode: 'news', action: 'read' },
|
||||
]);
|
||||
|
||||
const result = await checkUserPermission('user-1', 'news', 'create');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requirePermission', () => {
|
||||
const createRequest = () => new NextRequest('http://localhost/api/admin/items');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns user when authenticated and authorized', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
|
||||
mockUserRoleFindMany.mockResolvedValue([{ roleCode: 'content_editor' }]);
|
||||
mockPermissionFindMany.mockResolvedValue([
|
||||
{ roleCode: 'content_editor', modelCode: 'news', action: 'create' },
|
||||
]);
|
||||
|
||||
const request = createRequest();
|
||||
const result = await requirePermission(request, 'news', 'create');
|
||||
|
||||
expect('user' in result).toBe(true);
|
||||
expect((result as PermissionSuccess).user.username).toBe('editor');
|
||||
});
|
||||
|
||||
it('returns unauthorized response when not authenticated', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue(null);
|
||||
|
||||
const request = createRequest();
|
||||
const result = await requirePermission(request, 'news', 'create');
|
||||
|
||||
expect('response' in result).toBe(true);
|
||||
expect((result as PermissionFailure).response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns forbidden response when authenticated but unauthorized', async () => {
|
||||
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
|
||||
mockUserRoleFindMany.mockResolvedValue([{ roleCode: 'content_editor' }]);
|
||||
mockPermissionFindMany.mockResolvedValue([
|
||||
{ roleCode: 'content_editor', modelCode: 'news', action: 'read' },
|
||||
]);
|
||||
|
||||
const request = createRequest();
|
||||
const result = await requirePermission(request, 'news', 'create');
|
||||
|
||||
expect('response' in result).toBe(true);
|
||||
expect((result as PermissionFailure).response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { authenticateRequest, type JwtPayload } from '@/lib/auth';
|
||||
import { unauthorized, forbidden } from '@/lib/api-response';
|
||||
|
||||
export type PermissionAction = 'create' | 'read' | 'update' | 'delete' | 'publish';
|
||||
|
||||
export interface Permission {
|
||||
roleCode: string;
|
||||
modelCode: string;
|
||||
action: PermissionAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一组角色是否对指定模型和操作具备权限
|
||||
* super_admin 默认拥有所有权限
|
||||
*/
|
||||
export function hasPermission(
|
||||
roles: string[],
|
||||
modelCode: string,
|
||||
action: PermissionAction,
|
||||
permissions: Permission[]
|
||||
): boolean {
|
||||
if (roles.includes('super_admin')) return true;
|
||||
return permissions.some(
|
||||
(p) => roles.includes(p.roleCode) && p.modelCode === modelCode && p.action === action
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询数据库判断指定用户是否具备权限
|
||||
*/
|
||||
export async function checkUserPermission(
|
||||
userId: string,
|
||||
modelCode: string,
|
||||
action: PermissionAction
|
||||
): Promise<boolean> {
|
||||
const userRoles = await prisma.userRole.findMany({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
const roleCodes = userRoles.map((ur) => (ur as { roleCode: string }).roleCode);
|
||||
if (roleCodes.includes('super_admin')) return true;
|
||||
if (roleCodes.length === 0) return false;
|
||||
|
||||
const permissionRecords = await prisma.permission.findMany({
|
||||
where: { roleCode: { in: roleCodes } },
|
||||
});
|
||||
|
||||
const permissions = permissionRecords.map((p) => ({
|
||||
roleCode: (p as { roleCode: string }).roleCode,
|
||||
modelCode: (p as { modelCode: string }).modelCode,
|
||||
action: (p as { action: PermissionAction }).action,
|
||||
}));
|
||||
|
||||
return hasPermission(roleCodes, modelCode, action, permissions);
|
||||
}
|
||||
|
||||
export interface PermissionSuccess {
|
||||
user: JwtPayload;
|
||||
}
|
||||
|
||||
export interface PermissionFailure {
|
||||
response: Response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验请求是否已认证并具备指定权限
|
||||
* 通过返回 { user },失败返回 { response }
|
||||
*/
|
||||
export async function requirePermission(
|
||||
request: NextRequest,
|
||||
modelCode: string,
|
||||
action: PermissionAction
|
||||
): Promise<PermissionSuccess | PermissionFailure> {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) {
|
||||
return { response: unauthorized() };
|
||||
}
|
||||
|
||||
const allowed = await checkUserPermission(user.userId, modelCode, action);
|
||||
if (!allowed) {
|
||||
return { response: forbidden() };
|
||||
}
|
||||
|
||||
return { user };
|
||||
}
|
||||
Reference in New Issue
Block a user