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:
+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);
|
||||
}
|
||||
Reference in New Issue
Block a user