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
+260
View File
@@ -0,0 +1,260 @@
import { test, expect, APIRequestContext } from '@playwright/test';
/**
* CMS 内容发布工作流 E2E 测试
*
* 覆盖完整流程:管理员登录 → 创建草稿 → 提交审核 → 审核通过 → 前台可见
* 以及多角色权限分离:管理员配置权限 → 编辑创建/提交 → 审核员发布 → 前台可见
*/
const ADMIN_CREDENTIALS = { username: 'admin', password: 'admin123' };
const EDITOR_CREDENTIALS = { username: 'e2e_editor', password: 'editor123' };
const REVIEWER_CREDENTIALS = { username: 'e2e_reviewer', password: 'reviewer123' };
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000';
const REVALIDATE_SECRET = process.env.CMS_REVALIDATE_SECRET || 'e2e-test-secret';
test.setTimeout(120000);
async function login(
request: APIRequestContext,
credentials: { username: string; password: string }
): Promise<string> {
const loginResponse = await request.post(`${BASE_URL}/api/auth/login`, {
data: credentials,
headers: { 'Content-Type': 'application/json' },
});
expect(loginResponse.ok()).toBeTruthy();
const loginBody = await loginResponse.json();
expect(loginBody.accessToken).toBeTruthy();
return loginBody.accessToken as string;
}
async function loginAdmin(request: APIRequestContext): Promise<string> {
return login(request, ADMIN_CREDENTIALS);
}
async function getNewsModelId(request: APIRequestContext, token: string): Promise<string> {
const response = await request.get(`${BASE_URL}/api/admin/models`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(response.ok()).toBeTruthy();
const models = await response.json();
const newsModel = models.find((m: { code: string }) => m.code === 'news');
expect(newsModel).toBeTruthy();
return newsModel.id;
}
async function createNewsDraft(
request: APIRequestContext,
token: string,
suffix: string,
modelId?: string
): Promise<{ id: string; slug: string; title: string }> {
const title = `E2E 测试新闻 ${suffix}`;
const slug = `e2e-test-news-${suffix}`;
const resolvedModelId = modelId || (await getNewsModelId(request, token));
const response = await request.post(`${BASE_URL}/api/admin/items`, {
data: {
modelId: resolvedModelId,
modelCode: 'news',
title,
slug,
status: 'draft',
data: {
id: slug,
excerpt: `这是 E2E 测试摘要 ${suffix}`,
date: new Date().toISOString().split('T')[0],
category: '公司新闻',
image: '/images/placeholder-news.jpg',
content: `<p>E2E 测试正文内容 ${suffix}</p>`,
featured: false,
},
},
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.status).toBe('draft');
return { id: body.id, slug, title };
}
async function submitForReview(
request: APIRequestContext,
token: string,
itemId: string
): Promise<void> {
const response = await request.post(`${BASE_URL}/api/admin/items/${itemId}/workflow`, {
data: { action: 'submit' },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.status).toBe('review');
}
async function approveItem(
request: APIRequestContext,
token: string,
itemId: string
): Promise<void> {
const response = await request.post(`${BASE_URL}/api/admin/items/${itemId}/workflow`, {
data: { action: 'approve' },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.status).toBe('published');
}
async function revalidateNews(request: APIRequestContext, slug: string): Promise<void> {
const response = await request.post(`${BASE_URL}/api/cms/revalidate`, {
data: {
event: 'content.published',
modelCode: 'news',
slug,
secret: REVALIDATE_SECRET,
},
headers: { 'Content-Type': 'application/json' },
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.code).toBe(0);
expect(body.data.revalidatedPaths).toContain('/news');
expect(body.data.revalidatedPaths).toContain(`/news/${slug}`);
}
async function ensureRolePermissions(
request: APIRequestContext,
adminToken: string,
roleCode: string,
permissions: Array<{ modelCode: string; action: string }>
): Promise<void> {
const response = await request.put(`${BASE_URL}/api/admin/roles`, {
data: { roleCode, permissions },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${adminToken}`,
},
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.roleCode).toBe(roleCode);
expect(body.permissions).toHaveLength(permissions.length);
}
async function verifyNewsVisibleOnFrontend(
page: import('@playwright/test').Page,
slug: string,
title: string,
suffix: string
): Promise<void> {
// 直接访问详情页验证 ISR 刷新后的内容可见(比列表页更稳定,避免整页缓存波动)
await page.goto(`/news/${slug}`, { waitUntil: 'domcontentloaded', timeout: 30000 });
await expect
.poll(
async () => {
const h1Text = (await page.locator('h1').first().textContent().catch(() => '')) || '';
return h1Text.includes(title);
},
{ timeout: 20000, intervals: [1000, 2000, 2000] }
)
.toBe(true);
await expect(page.locator('body')).toContainText(`E2E 测试正文内容 ${suffix}`);
}
test.describe('CMS 内容发布工作流', () => {
test('admin 可完成 创建草稿 → 提交审核 → 发布 → 前台可见', async ({ request, page }) => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const token = await loginAdmin(request);
const { id, slug, title } = await createNewsDraft(request, token, suffix);
await submitForReview(request, token, id);
await approveItem(request, token, id);
await revalidateNews(request, slug);
await verifyNewsVisibleOnFrontend(page, slug, title, suffix);
});
test('非法状态流转会被拦截', async ({ request }) => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const token = await loginAdmin(request);
const { id } = await createNewsDraft(request, token, suffix);
const response = await request.post(`${BASE_URL}/api/admin/items/${id}/workflow`, {
data: { action: 'approve' },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.error).toContain('当前状态 draft 不允许审核通过');
});
test('多角色权限分离:管理员配置权限 → 编辑创建/提交 → 审核员发布 → 前台可见', async ({
request,
page,
}) => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// 1. 管理员登录并获取模型 ID,然后配置角色权限
const adminToken = await loginAdmin(request);
const newsModelId = await getNewsModelId(request, adminToken);
await ensureRolePermissions(request, adminToken, 'content_editor', [
{ modelCode: 'news', action: 'create' },
{ modelCode: 'news', action: 'read' },
{ modelCode: 'news', action: 'update' },
]);
await ensureRolePermissions(request, adminToken, 'reviewer', [
{ modelCode: 'news', action: 'read' },
{ modelCode: 'news', action: 'publish' },
]);
// 2. 编辑创建草稿并提交审核(模型 ID 由管理员提供,编辑无需 content-model 读取权限)
const editorToken = await login(request, EDITOR_CREDENTIALS);
const { id, slug, title } = await createNewsDraft(request, editorToken, suffix, newsModelId);
await submitForReview(request, editorToken, id);
// 3. 编辑尝试直接审核通过,应被 403 拒绝
const editorApproveResponse = await request.post(
`${BASE_URL}/api/admin/items/${id}/workflow`,
{
data: { action: 'approve' },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${editorToken}`,
},
}
);
expect(editorApproveResponse.status()).toBe(403);
// 4. 审核员审核通过
const reviewerToken = await login(request, REVIEWER_CREDENTIALS);
await approveItem(request, reviewerToken, id);
// 5. 刷新 ISR 缓存并验证前台可见
await revalidateNews(request, slug);
await verifyNewsVisibleOnFrontend(page, slug, title, suffix);
});
});
+4 -7
View File
@@ -118,9 +118,6 @@ test.describe('P1: 品牌视觉审计 - 全站文本扫描', () => {
// 检查是否包含禁止的文本
if (FORBIDDEN_TEXT.test(pageText)) {
// 如果匹配到,需要进一步确认是否在允许的上下文中
const matches = pageText.match(FORBIDDEN_TEXT) || [];
// 获取所有包含 novalon 的元素进行详细检查
const elementsWithForbiddenText = await page.evaluate(() => {
const results: Array<{
@@ -329,7 +326,7 @@ test.describe('P1: 品牌视觉审计 - 关键位置验证', () => {
break;
}
}
} catch (e) {
} catch {
// 忽略单个选择器的错误,继续尝试下一个
continue;
}
@@ -667,7 +664,7 @@ test.describe('P1: 品牌视觉审计 - 响应式布局验证', () => {
if (await logo.count() > 0) {
try {
await expect(logo).toBeVisible({ timeout: 2000 });
} catch (e) {
} catch {
// 某些小屏幕下 Logo 可能不可见,这是可接受的
console.log(`Warning: Logo not visible in ${name} viewport`);
}
@@ -691,7 +688,7 @@ test.describe('P1: 品牌视觉审计 - 特殊场景覆盖', () => {
test('404 错误页面品牌显示正常', async ({ page }) => {
// 访问一个不存在的页面
const response = await safeGoto(page, '/nonexistent-page-12345')
await safeGoto(page, '/nonexistent-page-12345')
.then(() => true)
.catch(() => false);
@@ -772,7 +769,7 @@ test.describe('P1: 品牌视觉审计 - 性能与截图基线', () => {
const logo = page.locator('header img, header svg, [data-testid="logo"]').first();
try {
await logo.waitFor({ state: 'visible', timeout: 10000 });
} catch (e) {
} catch {
console.log('Logo not visible within 10s - may use different structure');
// Logo 可能使用了非标准结构,继续测试
}
+1 -1
View File
@@ -690,7 +690,7 @@ test.describe('SEO - 搜索引擎优化基础', () => {
expect(content!.length).toBeGreaterThan(50);
});
test('各页面有唯一标题', async ({ page, context }) => {
test('各页面有唯一标题', async ({ context }) => {
const urls = ['/', '/about', '/contact', '/products', '/services', '/solutions'];
const titles: string[] = [];
-1
View File
@@ -182,7 +182,6 @@ test.describe('特定浏览器 - 行为差异检查', () => {
const imageCount = await images.count();
let loadedCount = 0;
let errorCount = 0;
for (let i = 0; i < Math.min(imageCount, 10); i++) {
const img = images.nth(i);
+1 -4
View File
@@ -111,7 +111,6 @@ test.describe('性能 - 资源优化', () => {
const imageAnalysis = await page.evaluate(() => {
const images = Array.from(document.querySelectorAll('img'));
let totalSize = 0;
let optimizedCount = 0;
let largeImages = 0;
@@ -244,7 +243,6 @@ test.describe('可访问性 - WCAG 2.1 AA 合规', () => {
inputs.forEach(input => {
const id = input.id;
const ariaLabel = input.getAttribute('aria-label');
const ariaLabelledBy = input.getAttribute('aria-labelledby');
// 检查是否有显式 label
let hasLabel = false;
@@ -308,7 +306,6 @@ test.describe('可访问性 - WCAG 2.1 AA 合规', () => {
elementsWithAria.forEach(el => {
const role = el.getAttribute('role');
const ariaLabel = el.getAttribute('aria-label');
// 如果有 role,应该有相应的语义
if (role) {
@@ -503,7 +500,7 @@ test.describe('SEO - 元数据完整性', () => {
try {
JSON.parse(content!); // 应该是有效的JSON
} catch (e) {
} catch {
throw new Error(`Invalid JSON-LD at index ${i}`);
}
}
+8
View File
@@ -1,5 +1,8 @@
import { defineConfig, devices } from '@playwright/test';
// 为 CMS 工作流 E2E 测试提供默认 revalidate secret
process.env.CMS_REVALIDATE_SECRET = process.env.CMS_REVALIDATE_SECRET || 'e2e-test-secret';
export default defineConfig({
testDir: './',
fullyParallel: true,
@@ -14,6 +17,7 @@ export default defineConfig({
snapshotPathTemplate: '{snapshotDir}/{projectName}/{testFilePath}/{arg}-{projectName}{ext}',
use: {
baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000',
storageState: './storageState.json',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
ignoreHTTPSErrors: true,
@@ -96,5 +100,9 @@ export default defineConfig({
url: 'http://localhost:3000',
reuseExistingServer: true,
timeout: 120000,
cwd: '..',
env: {
CMS_REVALIDATE_SECRET: process.env.CMS_REVALIDATE_SECRET || 'e2e-test-secret',
},
},
});
+14
View File
@@ -0,0 +1,14 @@
{
"cookies": [],
"origins": [
{
"origin": "http://localhost:3000",
"localStorage": [
{
"name": "novalon-cookie-preferences",
"value": "{\"necessary\":true,\"analytics\":false,\"marketing\":false,\"functionality\":true,\"timestamp\":1784284000000}"
}
]
}
]
}