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:
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 可访问性自动化审计脚本
|
||||
*
|
||||
* 使用 @axe-core/playwright 对关键页面进行 WCAG 2.1 AA 规则扫描。
|
||||
* 脚本会自动启动 preview 服务,扫描完成后关闭服务并输出报告。
|
||||
*/
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
const { AxeBuilder } = require('@axe-core/playwright');
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000';
|
||||
const PAGES = [
|
||||
{ path: '/', name: '首页' },
|
||||
{ path: '/about', name: '关于我们' },
|
||||
{ path: '/products', name: '产品中心' },
|
||||
{ path: '/products/erp', name: '产品详情-ERP' },
|
||||
{ path: '/solutions', name: '解决方案' },
|
||||
{ path: '/solutions/manufacturing', name: '解决方案详情-制造' },
|
||||
{ path: '/services', name: '服务列表' },
|
||||
{ path: '/services/software', name: '服务详情-软件开发' },
|
||||
{ path: '/news', name: '新闻列表' },
|
||||
{ path: '/contact', name: '联系我们' },
|
||||
];
|
||||
|
||||
function startServer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = spawn('npm', ['run', 'preview'], {
|
||||
cwd: path.resolve(__dirname, '..'),
|
||||
stdio: 'pipe',
|
||||
shell: true,
|
||||
});
|
||||
|
||||
server.stdout.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
if (output.includes('Ready') || output.includes('3000')) {
|
||||
resolve(server);
|
||||
}
|
||||
});
|
||||
|
||||
server.stderr.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
if (output.includes('EADDRINUSE') || output.includes('Failed')) {
|
||||
reject(new Error(output));
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => resolve(server), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(url, retries = 30) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'HEAD' });
|
||||
if (res.ok || res.status === 404) return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
throw new Error(`Server at ${url} did not become ready in time`);
|
||||
}
|
||||
|
||||
async function runAudit() {
|
||||
let server;
|
||||
try {
|
||||
console.log('🚀 启动预览服务...');
|
||||
server = await startServer();
|
||||
await waitForServer(BASE_URL);
|
||||
|
||||
console.log('🔍 启动浏览器并运行可访问性审计...\n');
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
const results = [];
|
||||
let totalViolations = 0;
|
||||
|
||||
for (const { path: pagePath, name } of PAGES) {
|
||||
const url = `${BASE_URL}${pagePath}`;
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const analysis = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
|
||||
.analyze();
|
||||
|
||||
const violations = analysis.violations;
|
||||
totalViolations += violations.length;
|
||||
|
||||
results.push({
|
||||
name,
|
||||
url,
|
||||
passed: violations.length === 0,
|
||||
violations: violations.map((v) => ({
|
||||
rule: v.id,
|
||||
impact: v.impact,
|
||||
description: v.description,
|
||||
help: v.help,
|
||||
helpUrl: v.helpUrl,
|
||||
nodes: v.nodes.length,
|
||||
targets: v.nodes.map((n) => ({
|
||||
target: n.target,
|
||||
html: n.html?.slice(0, 200),
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
||||
const status = violations.length === 0 ? '✅' : '❌';
|
||||
console.log(`${status} ${name} (${violations.length} 个问题)`);
|
||||
for (const v of violations) {
|
||||
console.log(` • [${v.impact?.toUpperCase() ?? 'UNKNOWN'}] ${v.id}: ${v.help} (${v.nodes.length} 个节点)`);
|
||||
for (const node of v.nodes.slice(0, 3)) {
|
||||
const target = Array.isArray(node.target) ? node.target.join(' > ') : String(node.target);
|
||||
console.log(` - ${target}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ ${name} 审计失败:`, error.message);
|
||||
results.push({ name, url, passed: false, error: error.message, violations: [] });
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log('\n📊 审计摘要');
|
||||
console.log(` 扫描页面: ${results.length}`);
|
||||
console.log(` 通过页面: ${results.filter((r) => r.passed).length}`);
|
||||
console.log(` 失败页面: ${results.filter((r) => !r.passed).length}`);
|
||||
console.log(` 问题总数: ${totalViolations}`);
|
||||
|
||||
const reportPath = path.resolve(__dirname, '../accessibility-report.json');
|
||||
require('fs').writeFileSync(reportPath, JSON.stringify(results, null, 2));
|
||||
console.log(`\n📝 详细报告已保存: ${reportPath}`);
|
||||
|
||||
process.exit(totalViolations > 0 ? 1 : 0);
|
||||
} catch (error) {
|
||||
console.error('审计执行失败:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (server) {
|
||||
server.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runAudit();
|
||||
@@ -0,0 +1,21 @@
|
||||
const { PrismaClient } = require('../src/generated/prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const items = await prisma.contentItem.findMany({
|
||||
where: { modelCode: 'case-study', status: 'published' },
|
||||
select: { id: true, title: true, slug: true, data: true },
|
||||
});
|
||||
for (const i of items) {
|
||||
const d = JSON.parse(i.data);
|
||||
console.log(d.slug || i.slug, '| industry:', d.industry);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# 封版验收安全扫描手动清单脚本
|
||||
# 运行环境:本地生产预览服务 http://localhost:3000
|
||||
# 前置条件:npm run start 已启动在 3000 端口
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:3000}"
|
||||
REPORT_DIR="${REPORT_DIR:-release-acceptance-reports/$(date +%Y%m%d-%H%M%S)}"
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
echo "======================================"
|
||||
echo "安全扫描手动清单"
|
||||
echo "目标: $BASE_URL"
|
||||
echo "报告目录: $REPORT_DIR"
|
||||
echo "======================================"
|
||||
|
||||
# SEC-01 / SEC-06: 依赖漏洞扫描
|
||||
echo "[SEC-01] 运行 npm audit --production..."
|
||||
npm audit --audit-level moderate --registry=https://registry.npmjs.org/ > "$REPORT_DIR/50-npm-audit-final.log" 2>&1 || true
|
||||
|
||||
# SEC-02: 安全响应头检查
|
||||
echo "[SEC-02] 检查安全响应头..."
|
||||
curl -sI "$BASE_URL/" | grep -iE 'X-Content-Type-Options|X-Frame-Options|X-XSS-Protection|Strict-Transport-Security|Content-Security-Policy' > "$REPORT_DIR/51-security-headers.log" 2>&1 || true
|
||||
if [ ! -s "$REPORT_DIR/51-security-headers.log" ]; then
|
||||
echo "未检测到安全响应头" > "$REPORT_DIR/51-security-headers.log"
|
||||
fi
|
||||
|
||||
# SEC-03: 认证绕过测试
|
||||
echo "[SEC-03] 测试未授权访问 /admin 与 /api/admin/models..."
|
||||
{
|
||||
echo "GET $BASE_URL/admin"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" "$BASE_URL/admin"
|
||||
echo "GET $BASE_URL/api/admin/models"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" "$BASE_URL/api/admin/models"
|
||||
} > "$REPORT_DIR/52-auth-bypass.log" 2>&1
|
||||
|
||||
# SEC-04: XSS 过滤测试
|
||||
echo "[SEC-04] 测试联系表单 XSS 过滤..."
|
||||
curl -s -X POST "$BASE_URL/api/contact" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"XSS Test","email":"xss@test.com","phone":"13800138000","subject":"XSS","message":"<script>alert(1)</script>"}' \
|
||||
> "$REPORT_DIR/53-xss-test.log" 2>&1
|
||||
|
||||
# SEC-05: SQLi 过滤测试
|
||||
echo "[SEC-05] 测试联系表单 SQLi 过滤..."
|
||||
curl -s -X POST "$BASE_URL/api/contact" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"SQLi Test","email":"sqli@test.com","phone":"13800138000","subject":"SQLi","message":"1\x27 OR \x271\x27=\x271"}' \
|
||||
> "$REPORT_DIR/54-sqli-test.log" 2>&1
|
||||
|
||||
# SEC-06: JWT 校验测试
|
||||
echo "[SEC-06] 测试 JWT 校验..."
|
||||
{
|
||||
echo "空 token:"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" "$BASE_URL/api/admin/models"
|
||||
echo "无效 token:"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer invalid-token" "$BASE_URL/api/admin/models"
|
||||
echo "过期 token (expired signature):"
|
||||
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwMjJ9.4AdcjS3X_FSn0FxL5XfKX5qJ6tE4ZfG8h0e8_xW3V7o" "$BASE_URL/api/admin/models"
|
||||
} > "$REPORT_DIR/55-jwt-test.log" 2>&1
|
||||
|
||||
# SEC-07: 敏感信息泄露扫描
|
||||
echo "[SEC-07] 扫描源码中的硬编码敏感信息..."
|
||||
grep -R -n -E "password|secret|token|api[_-]?key" \
|
||||
--include="*.ts" --include="*.tsx" \
|
||||
src/ \
|
||||
| grep -v "\.env" \
|
||||
| grep -v "process\.env" \
|
||||
| grep -v "// " \
|
||||
| head -100 \
|
||||
> "$REPORT_DIR/56-secrets-scan.log" 2>&1 || true
|
||||
|
||||
if [ ! -s "$REPORT_DIR/56-secrets-scan.log" ]; then
|
||||
echo "未发现明显硬编码敏感信息" > "$REPORT_DIR/56-secrets-scan.log"
|
||||
fi
|
||||
|
||||
echo "======================================"
|
||||
echo "安全扫描完成,产物保存在 $REPORT_DIR"
|
||||
echo "======================================"
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* 标题层级检查脚本
|
||||
*
|
||||
* 对关键页面进行标题(h1-h6)层级扫描,确保:
|
||||
* 1. 每个页面有且仅有一个 h1
|
||||
* 2. 标题层级不跳跃(例如 h1 后直接出现 h3)
|
||||
* 3. 标题不为空
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000';
|
||||
|
||||
const PAGES = [
|
||||
{ path: '/', name: '首页' },
|
||||
{ path: '/about', name: '关于我们' },
|
||||
{ path: '/products', name: '产品中心' },
|
||||
{ path: '/products/erp', name: '产品详情-ERP' },
|
||||
{ path: '/solutions', name: '解决方案' },
|
||||
{ path: '/solutions/manufacturing', name: '解决方案详情-制造' },
|
||||
{ path: '/services', name: '服务列表' },
|
||||
{ path: '/services/software', name: '服务详情-软件开发' },
|
||||
{ path: '/news', name: '新闻列表' },
|
||||
{ path: '/contact', name: '联系我们' },
|
||||
];
|
||||
|
||||
interface HeadingIssue {
|
||||
type: 'missing_h1' | 'multiple_h1' | 'empty_heading' | 'skipped_level';
|
||||
message: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface PageResult {
|
||||
name: string;
|
||||
url: string;
|
||||
passed: boolean;
|
||||
headings: Array<{ level: number; text: string }>;
|
||||
issues: HeadingIssue[];
|
||||
}
|
||||
|
||||
function startServer(): Promise<ReturnType<typeof spawn>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = spawn('npm', ['run', 'preview'], {
|
||||
cwd: path.resolve(__dirname, '../..'),
|
||||
stdio: 'pipe',
|
||||
shell: true,
|
||||
});
|
||||
|
||||
server.stdout?.on('data', (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
if (output.includes('Ready') || output.includes('3000')) {
|
||||
resolve(server);
|
||||
}
|
||||
});
|
||||
|
||||
server.stderr?.on('data', (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
if (output.includes('EADDRINUSE') || output.includes('Failed')) {
|
||||
reject(new Error(output));
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => resolve(server), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(url: string, retries = 30): Promise<void> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'HEAD' });
|
||||
if (res.ok || res.status === 404) return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
throw new Error(`Server at ${url} did not become ready in time`);
|
||||
}
|
||||
|
||||
function checkHeadings(headings: Array<{ level: number; text: string }>): HeadingIssue[] {
|
||||
const issues: HeadingIssue[] = [];
|
||||
|
||||
const h1Headings = headings.filter((h) => h.level === 1);
|
||||
if (h1Headings.length === 0) {
|
||||
issues.push({ type: 'missing_h1', message: '页面缺少 h1 标题' });
|
||||
} else if (h1Headings.length > 1) {
|
||||
issues.push({
|
||||
type: 'multiple_h1',
|
||||
message: `页面存在 ${h1Headings.length} 个 h1 标题`,
|
||||
detail: h1Headings.map((h) => h.text).join(' | '),
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < headings.length; i++) {
|
||||
const current = headings[i]!;
|
||||
if (!current.text.trim()) {
|
||||
issues.push({
|
||||
type: 'empty_heading',
|
||||
message: `h${current.level} 标题内容为空`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i === 0) continue;
|
||||
const previous = headings[i - 1]!;
|
||||
if (current.level > previous.level + 1) {
|
||||
issues.push({
|
||||
type: 'skipped_level',
|
||||
message: `标题层级跳跃: h${previous.level} → h${current.level}`,
|
||||
detail: `"${previous.text}" → "${current.text}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function runCheck() {
|
||||
let server;
|
||||
try {
|
||||
console.log('🚀 启动预览服务...');
|
||||
server = await startServer();
|
||||
await waitForServer(BASE_URL);
|
||||
|
||||
console.log('🔍 检查页面标题层级...\n');
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
const results: PageResult[] = [];
|
||||
let totalIssues = 0;
|
||||
|
||||
for (const { path: pagePath, name } of PAGES) {
|
||||
const url = `${BASE_URL}${pagePath}`;
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const headings = await page.evaluate(() => {
|
||||
const elements = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
return elements.map((el) => ({
|
||||
level: parseInt(el.tagName[1]!, 10),
|
||||
text: el.textContent?.trim() ?? '',
|
||||
}));
|
||||
});
|
||||
|
||||
const issues = checkHeadings(headings);
|
||||
totalIssues += issues.length;
|
||||
|
||||
const result: PageResult = {
|
||||
name,
|
||||
url,
|
||||
passed: issues.length === 0,
|
||||
headings,
|
||||
issues,
|
||||
};
|
||||
results.push(result);
|
||||
|
||||
const status = issues.length === 0 ? '✅' : '❌';
|
||||
console.log(`${status} ${name} (${issues.length} 个问题)`);
|
||||
for (const issue of issues) {
|
||||
console.log(` • ${issue.message}${issue.detail ? ` (${issue.detail})` : ''}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`❌ ${name} 检查失败:`, message);
|
||||
results.push({
|
||||
name,
|
||||
url,
|
||||
passed: false,
|
||||
headings: [],
|
||||
issues: [{ type: 'missing_h1', message: `页面加载失败: ${message}` }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log('\n📊 检查摘要');
|
||||
console.log(` 扫描页面: ${results.length}`);
|
||||
console.log(` 通过页面: ${results.filter((r) => r.passed).length}`);
|
||||
console.log(` 失败页面: ${results.filter((r) => !r.passed).length}`);
|
||||
console.log(` 问题总数: ${totalIssues}`);
|
||||
|
||||
const reportPath = path.resolve(__dirname, '../../heading-hierarchy-report.json');
|
||||
require('fs').writeFileSync(reportPath, JSON.stringify(results, null, 2));
|
||||
console.log(`\n📝 详细报告已保存: ${reportPath}`);
|
||||
|
||||
process.exit(totalIssues > 0 ? 1 : 0);
|
||||
} catch (error) {
|
||||
console.error('检查执行失败:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (server) {
|
||||
server.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runCheck();
|
||||
|
||||
Reference in New Issue
Block a user