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:
张翔
2026-07-25 08:04:01 +08:00
parent e35090b914
commit 10404dbb36
208 changed files with 18107 additions and 8330 deletions
+777 -40
View File
@@ -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,
+137 -5
View File
@@ -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');
});
});
});
+35
View File
@@ -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;
}
// ============ 静态生成辅助 ============
/**
+335
View File
@@ -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();
});
});
+190
View File
@@ -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,
});
}
+1
View File
@@ -86,6 +86,7 @@ export interface ContentItem {
modelCode: string;
title: string;
slug?: string;
locale: string;
status: ContentStatus;
data: Record<string, unknown>;
version: number;
+235
View File
@@ -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 不允许归档'
);
});
});
+148
View File
@@ -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);
}