chore: 添加设计分析参考和辅助脚本

- IHG设计分析HTML参考页面
- 布局检查、DOM诊断、验收测试等辅助脚本
This commit is contained in:
张翔
2026-06-07 16:22:11 +08:00
parent b1f3a395ca
commit 929f3ed250
15 changed files with 3011 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
const { chromium } = require("playwright");
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
try {
await page.goto("http://localhost:3001/products/novavis", { waitUntil: "domcontentloaded", timeout: 15000 });
await page.waitForTimeout(4000);
const result = await page.evaluate(() => {
const header = document.querySelector("header .container-wide");
const headerRect = header?.getBoundingClientRect();
const headerContentLeft = headerRect ? headerRect.left : null;
const sections = document.querySelectorAll("section");
const sectionData = [];
sections.forEach((section, i) => {
const rect = section.getBoundingClientRect();
if (rect.height < 30) return;
const container = section.querySelector(".container-wide, .container-full");
const containerRect = container?.getBoundingClientRect();
const firstTextBlock = section.querySelector("h1, h2, h3, p");
const textRect = firstTextBlock?.getBoundingClientRect();
sectionData.push({
index: i,
sectionLeft: Math.round(rect.left),
containerLeft: containerRect ? Math.round(containerRect.left) : null,
firstTextLeft: textRect ? Math.round(textRect.left) : null,
height: Math.round(rect.height),
text: firstTextBlock?.textContent?.substring(0, 40).trim(),
});
});
return { headerContentLeft, sections: sectionData };
});
console.log("=== NovaVis 产品页内容对齐检测 ===\n");
console.log(`Header container left: ${result.headerContentLeft}px\n`);
let allAligned = true;
for (const s of result.sections) {
const containerOk = s.containerLeft === result.headerContentLeft;
const textOk = s.firstTextLeft !== null && Math.abs(s.firstTextLeft - result.headerContentLeft) < 5;
if (!containerOk && s.containerLeft !== null) {
allAligned = false;
console.log(` ❌ Section[${s.index}]: containerLeft=${s.containerLeft} (期望 ${result.headerContentLeft}) | ${s.text?.substring(0, 30)}`);
} else if (s.containerLeft !== null) {
console.log(` ✅ Section[${s.index}]: containerLeft=${s.containerLeft} | ${s.text?.substring(0, 30)}`);
}
}
if (allAligned) {
console.log("\n🎉 所有区块容器与 Header 对齐!");
}
} catch(e) {
console.error("ERROR:", e.message);
}
await browser.close();
})();
+395
View File
@@ -0,0 +1,395 @@
#!/usr/bin/env python3
"""Novalon Website Dogfood Test v3 - Final"""
import os
import re
import sys
from playwright.sync_api import sync_playwright
BASE_URL = "http://localhost:3001"
SCREENSHOT_DIR = "/tmp/novalon-dogfood"
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
results = {"passed": 0, "failed": 0, "warnings": [], "errors": []}
def log(test_name: str, passed: bool, detail: str = ""):
status = "✅ PASS" if passed else "❌ FAIL"
if passed:
results["passed"] += 1
else:
results["failed"] += 1
results["errors"].append(f"{test_name}: {detail}")
print(f" [{status}] {test_name} {f'{detail}' if detail else ''}")
def wait_for_dynamic_content(page, timeout_ms=3000):
"""Wait for dynamically-imported sections to hydrate"""
page.wait_for_timeout(timeout_ms)
def test_homepage_full_render(page):
"""Dogfood-1: 首页全量渲染"""
print("\n" + "=" * 60)
print("📋 Dogfood-1: 首页全量渲染测试")
print("=" * 60)
page.goto(BASE_URL)
page.wait_for_load_state("networkidle")
wait_for_dynamic_content(page)
page.screenshot(path=f"{SCREENSHOT_DIR}/01-homepage-full.png", full_page=True)
log("首页完整截图保存", True)
sections = [
("Hero Section", "#home"),
("Product Matrix", "#products"),
("Challenge Section", "#challenges"),
("Social Proof", "#social-proof"),
("Why Us (NEW)", "#why-us"),
("CTA Section", "#cta"),
]
for name, selector in sections:
el = page.locator(selector)
visible = el.count() > 0 and el.first.is_visible()
log(f"Section 可见性: {name}", visible, f"selector={selector}")
body_text = page.locator("body").inner_text()
for keyword in ["产品矩阵", "为什么选择我们"]:
found = keyword in body_text
log(f"关键词存在: '{keyword}'", found)
def test_navigation_system(page):
"""Dogfood-2: 导航系统"""
print("\n" + "=" * 60)
print("📋 Dogfood-2: 导航系统测试")
print("=" * 60)
page.goto(BASE_URL)
page.wait_for_load_state("networkidle")
wait_for_dynamic_content(page)
nav = page.locator('nav[aria-label="主导航"]')
log("桌面导航可见", nav.count() > 0 and nav.is_visible())
btns = nav.locator("button")
links = nav.locator("a")
total_nav = btns.count() + links.count()
log(f"导航项总数 >= 5 (btn={btns.count()} + link={links.count()})", total_nav >= 5, f"实际={total_nav}")
logo = page.locator('a[aria-label="返回首页"] img')
log("Logo 存在且可见", logo.count() > 0 and logo.first.is_visible())
dropdowns = nav.locator('[aria-haspopup="true"]')
dd_count = dropdowns.count()
log(f"MegaDropdown 数量 >= 1", dd_count >= 1, f"实际={dd_count}")
if dd_count > 0:
dropdowns.first.hover()
page.wait_for_timeout(1000)
page.screenshot(path=f"{SCREENSHOT_DIR}/02-dropdown-open.png")
dd_visible = False
for sel in [
'[data-state="open"]', '[data-expanded="true"]',
'div[class*="dropdown"][class*="absolute"]',
'div[class*="mega"]',
]:
try:
el = page.locator(sel)
if el.count() > 0 and el.first.is_visible():
dd_visible = True
break
except Exception:
continue
if not dd_visible:
body_before = page.evaluate("document.body.innerText.length")
dd_text = page.locator('header').get_by_text(re.compile("ERP|CRM|CMS|企业套装"))
dd_visible = dd_text.count() > 3
log("MegaDropdown 展开后有可见内容", dd_visible)
contact_link = page.locator('header').get_by_text(re.compile("联系"))
log("头部存在'联系'相关链接/按钮", contact_link.count() > 0, f"实际={contact_link.count()}")
def test_product_cards(page):
"""Dogfood-3: 产品卡片状态验证"""
print("\n" + "=" * 60)
print("📋 Dogfood-3: 产品卡片状态验证")
print("=" * 60)
page.goto(BASE_URL + "/#products")
page.wait_for_load_state("networkidle")
wait_for_dynamic_content(page)
cards = page.locator("#products").locator('a[href*="/products/"]')
card_count = cards.count()
log(f"产品卡片数量 = 6", card_count == 6, f"实际={card_count}")
page.screenshot(path=f"{SCREENSHOT_DIR}/03-product-cards.png")
status_badges = page.locator("#products").get_by_text(re.compile("研发中|内测|已发布"))
badge_count = status_badges.count()
log(f"状态标签数量 >= 6", badge_count >= 6, f"实际={badge_count}")
dev_indicators = page.locator("#products").get_by_text("正在积极开发")
dev_count = dev_indicators.count()
log(f"'正在积极开发'提示条数量 = 6", dev_count == 6, f"实际={dev_count}")
section_status = page.locator("#products").get_by_text(re.compile("全部研发中"))
log("Section '全部研发中' 标签可见", section_status.count() > 0)
if card_count > 0:
cards.first.hover()
page.wait_for_timeout(300)
log("产品卡片 hover 无崩溃", True)
def wait_for_text(page, text, timeout_ms=5000):
"""Wait for specific text to appear in body (handles dynamic hydration)"""
try:
page.wait_for_function(
f"document.body.innerText.includes({text!r})",
timeout=timeout_ms,
)
return True
except Exception:
return False
def test_cta_and_whyus(page):
"""Dogfood-4: CTA + WhyUs 新增Section"""
print("\n" + "=" * 60)
print("📋 Dogfood-4: CTA + WhyUs 新功能验证")
print("=" * 60)
page.goto(BASE_URL)
page.wait_for_load_state("networkidle")
whyus = page.locator("#why-us")
log("WhyUs Section 存在", whyus.count() > 0)
has_whyus_text = wait_for_text(page, "为什么选择我们", 5000)
log("WhyUs 动态内容已水合", has_whyus_text)
page.evaluate("document.getElementById('why-us')?.scrollIntoView({ behavior: 'instant' })")
page.wait_for_timeout(800)
page.screenshot(path=f"{SCREENSHOT_DIR}/04-whyus-section.png")
pillar_titles = ["12年+", "大型 IT 企业背景", "复合型技术团队",
"全栈技术能力", "结果导向交付"]
for title in pillar_titles:
found = whyus.get_by_text(title).count() > 0
log(f" 支柱标题: '{title}'", found)
team_text = whyus.get_by_text(re.compile("了解核心|核心团队|/team"))
log("'了解核心团队' 链接文本存在", team_text.count() > 0)
cta = page.locator("#cta")
log("CTA Section 存在", cta.count() > 0)
has_cta_text = wait_for_text(page, "一起聊聊", 5000)
log("CTA 动态内容已水合", has_cta_text)
page.evaluate("document.getElementById('cta')?.scrollIntoView({ behavior: 'instant' })")
page.wait_for_timeout(800)
page.screenshot(path=f"{SCREENSHOT_DIR}/04a-cta-section.png")
cta_title = cta.get_by_text(re.compile("一起聊聊.*需求"))
log("CTA 标题文案正确", cta_title.count() > 0)
cta_primary = cta.get_by_text(re.compile("预约.*咨询"))
if cta_primary.count() > 0:
log("CTA 主按钮含'预约.*咨询'文本", True)
else:
body_cta = page.locator("body").get_by_text(re.compile("预约.*咨询"))
log("CTA 主按钮含'预约.*咨询'文本 (body级)", body_cta.count() > 0)
trust_badges = ["免费咨询", "30 分钟内响应", "无销售压力"]
for badge in trust_badges:
found = cta.get_by_text(badge).count() > 0
log(f" 信任徽章: '{badge}'", found)
def test_responsive_layout(page):
"""Dogfood-5: 响应式布局"""
print("\n" + "=" * 60)
print("📋 Dogfood-5: 响应式布局测试")
print("=" * 60)
viewports = [
("Desktop 1280px", 1280, 900),
("Tablet 768px", 768, 1024),
("Mobile 375px", 375, 812),
]
for name, w, h in viewports:
page.set_viewport_size({"width": w, "height": h})
page.goto(BASE_URL)
page.wait_for_load_state("networkidle")
page.wait_for_timeout(800)
safe_name = name.replace(" ", "-").replace("/", "_")
page.screenshot(path=f"{SCREENSHOT_DIR}/05-responsive-{safe_name}.png", full_page=True)
log(f"{name} 截图成功", True)
if w < 768:
mobile_btn = page.locator('button[aria-label*="菜单"], button[aria-label*="menu"]')
is_mobile = mobile_btn.count() > 0
log(f" {name}: 移动端菜单按钮存在", is_mobile)
if is_mobile:
mobile_btn.first.click()
page.wait_for_timeout(500)
page.screenshot(path=f"{SCREENSHOT_DIR}/05-mobile-open-{w}.png")
log(f" {name}: 移动端菜单展开截图", True)
else:
nav_area = page.locator('nav[aria-label*="导航"]')
visible = nav_area.count() > 0 and nav_area.first.is_visible()
log(f" {name}: 桌面导航可见", visible)
page.set_viewport_size({"width": 1280, "height": 900})
def test_theme_toggle(page):
"""Dogfood-6: 主题切换"""
print("\n" + "=" * 60)
print("📋 Dogfood-6: 主题切换 + 暗色模式")
print("=" * 60)
page.set_viewport_size({"width": 1280, "height": 900})
page.goto(BASE_URL)
page.wait_for_load_state("networkidle")
wait_for_dynamic_content(page)
toggle = None
for sel in ['button[aria-label*="切换"]', 'button[aria-label*="theme"]',
'[data-testid="theme-toggle"]', 'button[class*="theme"]']:
t = page.locator(sel)
if t.count() > 0:
toggle = t.first
break
log("主题切换按钮存在", toggle is not None)
if toggle:
page.screenshot(path=f"{SCREENSHOT_DIR}/06-theme-light.png", full_page=True)
toggle.click()
page.wait_for_timeout(500)
dark_option = page.get_by_text(re.compile("深色|Dark"))
if dark_option.count() > 0:
dark_option.first.click()
page.wait_for_timeout(600)
else:
toggle.click()
page.screenshot(path=f"{SCREENSHOT_DIR}/06-theme-dark.png", full_page=True)
html_data_theme = page.locator("html").get_attribute("data-theme") or ""
log("Dark 模式已激活 (data-theme)", html_data_theme == "dark", f"data-theme={html_data_theme}")
def test_page_navigation_and_console(page):
"""Dogfood-7: 页面导航 + 控制台错误扫描"""
print("\n" + "=" * 60)
print("📋 Dogfood-7: 页面导航 + 控制台错误扫描")
print("=" * 60)
console_msgs = []
def on_console(msg):
if msg.type in ["error", "warning"]:
console_msgs.append({"type": msg.type, "text": msg.text})
page.on("console", on_console)
pages_to_test = [("/", "首页"), ("/team", "团队页面"),
("/contact", "联系页面"), ("/about", "关于页面")]
for path, name in pages_to_test:
try:
resp = page.goto(BASE_URL + path, timeout=15000)
page.wait_for_load_state("networkidle")
page.wait_for_timeout(600)
status = resp.status if resp else "N/A"
safe_name = name.replace(" ", "-")
page.screenshot(path=f"{SCREENSHOT_DIR}/07-nav-{safe_name}.png", full_page=True)
ok = status == 200 and page.locator("body").inner_text() != ""
log(f"页面: {name} ({path}) — HTTP {status}", ok)
except Exception as e:
log(f"页面: {name} ({path})", False, str(e)[:100])
errors = [m for m in console_msgs if m["type"] == "error"]
real_errors = [e for e in errors if not any(
p in e["text"] for p in [
"Expected server HTML to contain a matching",
"Hydration failed",
"There was an error while hydrating",
"lucide-react",
]
)]
log(f"控制台真实 Error 数量 = 0", len(real_errors) == 0, f"实际={len(real_errors)}")
for e in real_errors[:5]:
results["errors"].append(f"CONSOLE_ERROR: {e['text'][:200]}")
warnings = [m for m in console_msgs if m["type"] == "warning"]
ignore = ["Each child in a list should have a unique", "Prop `className` did not match",
"Download the React DevTools", "HMR",
"Expected server HTML to contain a matching", "Hydration failed",
"There was an error while hydrating", "lucide-react"]
fw = [w for w in warnings if not any(p in w["text"] for p in ignore)]
log(f"控制台 Warning(过滤后)<= 5", len(fw) <= 5, f"实际={len(fw)}")
for w in fw[:5]:
results["warnings"].append(w["text"][:200])
def main():
print("🐕 Novalon Website Dogfood Test Suite v3")
print(f" Target: {BASE_URL}")
print(f" Screenshots: {SCREENSHOT_DIR}/")
print("=" * 60)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 900}, locale="zh-CN")
page = context.new_page()
try:
test_homepage_full_render(page)
test_navigation_system(page)
test_product_cards(page)
test_cta_and_whyus(page)
test_responsive_layout(page)
test_theme_toggle(page)
test_page_navigation_and_console(page)
except Exception as e:
print(f"\n💥 测试套件异常中断: {e}")
page.screenshot(path=f"{SCREENSHOT_DIR}/error-crash.png")
results["errors"].append(f"SUITE_CRASH: {e}")
finally:
browser.close()
print("\n" + "=" * 60)
print("📊 DOGFOOD TEST SUMMARY")
print("=" * 60)
total = results["passed"] + results["failed"]
rate = (results["passed"] / total * 100) if total > 0 else 0
print(f" 总计: {total} | ✅ 通过: {results['passed']} | ❌ 失败: {results['failed']}")
print(f" 通过率: {rate:.1f}%")
if results["errors"]:
print(f"\n ❌ 失败项 ({len(results['errors'])}):")
for err in results["errors"]:
print(f"{err}")
if results["warnings"]:
print(f"\n ⚠️ 警告 ({len(results['warnings'])}):")
for w in results["warnings"]:
print(f"{w[:120]}")
print(f"\n 📸 截图目录: {SCREENSHOT_DIR}/")
print("=" * 60)
sys.exit(0 if results["failed"] == 0 else 1)
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Deep DOM Diagnostic v2"""
from playwright.sync_api import sync_playwright
BASE_URL = "http://localhost:3001"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 900})
console_logs = []
page.on("console", lambda msg: console_logs.append(f"{msg.type}: {msg.text[:150]}"))
# Navigate directly to CTA area
page.goto(BASE_URL + "/#cta")
page.wait_for_load_state("networkidle")
page.wait_for_timeout(2000) # Extra wait for dynamic imports
print("=== CTA SECTION DEEP PROBE ===")
cta = page.locator("#cta")
print(f"#cta exists: {cta.count() > 0}")
if cta.count() > 0:
html = cta.first.inner_html()
print(f"#cta innerHTML length: {len(html)}")
print(f"#cta innerHTML (first 500 chars): {html[:500]}")
# Check for anchor tags
anchors = cta.locator("a")
print(f"\n#cta <a> tags: {anchors.count()}")
for i in range(anchors.count()):
a = anchors.nth(i)
txt = a.inner_text().strip()
href = a.get_attribute("href") or ""
cls = a.get_attribute("class") or ""
print(f" a[{i}]: text='{txt}' href={href} class={cls[:80]}")
# Check all clickable elements
clickables = cta.locator("a, button, [role='button'], [role='link']")
print(f"\n#cta all clickables: {clickables.count()}")
print("\n=== WHY-US DEEP PROBE ===")
whyus = page.locator("#why-us")
print(f"#why-us exists: {whyus.count() > 0}")
if whyus.count() > 0:
anchors = whyus.locator("a")
print(f"#why-us <a> tags: {anchors.count()}")
for i in range(anchors.count()):
a = anchors.nth(i)
txt = a.inner_text().strip()
href = a.get_attribute("href") or ""
print(f" a[{i}]: text='{txt}' href={href}")
print("\n=== HEADER CONSULT DEEP PROBE ===")
header = page.locator("header")
header_anchors = header.locator("a")
print(f"header <a> tags: {header_anchors.count()}")
for i in range(header_anchors.count()):
a = header_anchors.nth(i)
txt = a.inner_text().strip()[:30]
href = a.get_attribute("href") or ""
aria = a.get_attribute("aria-label") or ""
if '咨询' in txt or '咨询' in aria or 'contact' in href:
print(f" CONSULT a[{i}]: text='{txt}' href={href} aria={aria}")
header_buttons = header.locator("button")
print(f"header <button> tags: {header_buttons.count()}")
for i in range(header_buttons.count()):
b = header_buttons.nth(i)
txt = b.inner_text().strip()[:30]
print(f" btn[{i}]: text='{txt}'")
print("\n=== BODY TEXT CHECK ===")
body_text = page.locator("body").inner_text()
for kw in ["预约免费咨询", "了解核心团队", "咨询"]:
idx = body_text.find(kw)
print(f" '{kw}': found at index {idx}" if idx >= 0 else f" '{kw}': NOT FOUND")
print("\n=== CONSOLE ERRORS ===")
errors = [l for l in console_logs if l.startswith("error:")]
for e in errors[:10]:
print(f" {e}")
browser.close()
+485
View File
@@ -0,0 +1,485 @@
const { chromium } = require("playwright");
const BASE = "http://localhost:3001";
const TIMEOUT = 20000;
const RESULTS = { pass: 0, fail: 0, warn: 0, details: [] };
function log(category, status, message) {
const icon = status === "pass" ? "✅" : status === "fail" ? "❌" : "⚠️";
RESULTS[status === "pass" ? "pass" : status === "fail" ? "fail" : "warn"]++;
RESULTS.details.push({ category, status, message });
console.log(` ${icon} [${category}] ${message}`);
}
async function waitForPage(page, url) {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: TIMEOUT });
await page.waitForTimeout(4000);
}
function createErrorCollector() {
const errors = [];
const listener = (msg) => {
if (msg.type() === "error") {
const text = msg.text();
if (!text.includes("hydration") && !text.includes("Fast Refresh") && !text.includes("React DevTools")) {
errors.push(text.substring(0, 120));
}
}
};
return { listener, getErrors: () => [...errors] };
}
(async () => {
const browser = await chromium.launch({ headless: true });
const desktop = { width: 1440, height: 900 };
const mobile = { width: 375, height: 812 };
// ========== 1. 首页测试 ==========
console.log("\n═══════════════════════════════════════");
console.log(" 1. 首页全面测试");
console.log("═══════════════════════════════════════");
{
const page = await browser.newPage({ viewport: desktop });
const errorCollector = createErrorCollector();
page.on("console", errorCollector.listener);
await waitForPage(page, BASE);
// 1a. 页面加载
const title = await page.title();
if (title && title.length > 0) {
log("首页", "pass", `页面标题: "${title}"`);
} else {
log("首页", "fail", "页面标题为空");
}
// 1b. Header 存在
const header = await page.$("header");
log("首页", header ? "pass" : "fail", "Header 元素存在性");
// 1c. Footer 存在
const footer = await page.$("footer");
log("首页", footer ? "pass" : "fail", "Footer 元素存在性");
// 1d. 布局对齐检测
const alignment = await page.evaluate(() => {
const header = document.querySelector("header .container-wide");
const footer = document.querySelector("footer .container-wide, footer .container-full");
const hRect = header?.getBoundingClientRect();
const fRect = footer?.getBoundingClientRect();
const allContainers = document.querySelectorAll(".container-wide, .container-full");
const positions = [];
allContainers.forEach((el) => {
const r = el.getBoundingClientRect();
if (r.height > 20) positions.push(Math.round(r.left));
});
return {
headerLeft: hRect ? Math.round(hRect.left) : null,
footerLeft: fRect ? Math.round(fRect.left) : null,
allLefts: positions,
uniqueLefts: [...new Set(positions)],
};
});
if (alignment.uniqueLefts.length === 1 && alignment.uniqueLefts[0] === alignment.headerLeft) {
log("首页-对齐", "pass", `所有 ${alignment.allLefts.length} 个容器 left=${alignment.headerLeft}px 对齐`);
} else {
log("首页-对齐", "fail", `容器左边距不一致: [${alignment.uniqueLefts.join(", ")}]px,期望 ${alignment.headerLeft}px`);
}
// 1e. Hero 区域
const heroH1 = await page.$("h1");
const heroText = heroH1 ? await heroH1.textContent() : "";
log("首页-Hero", heroH1 ? "pass" : "fail", `主标题: "${heroText?.substring(0, 40)}"`);
// 1f. 产品卡片
const productCards = await page.$$("[class*='product-card'], [class*='ProductCard'], a[href*='/products/']");
log("首页-产品", productCards.length > 0 ? "pass" : "warn", `产品卡片/链接数量: ${productCards.length}`);
// 1g. CTA 按钮
const ctaButtons = await page.$$("a[href='/contact'], a[href*='contact'], button:has-text('预约'), button:has-text('体验')");
log("首页-CTA", ctaButtons.length > 0 ? "pass" : "warn", `CTA 按钮数量: ${ctaButtons.length}`);
// 1h. 控制台错误
await page.waitForTimeout(2000);
if (errorCollector.getErrors().length === 0) {
log("首页-控制台", "pass", "无真实控制台错误");
} else {
log("首页-控制台", "warn", `${errorCollector.getErrors().length} 个控制台错误: ${errorCollector.getErrors()[0]?.substring(0, 60)}`);
}
await page.close();
}
// ========== 2. 产品详情页测试 ==========
console.log("\n═══════════════════════════════════════");
console.log(" 2. 产品详情页测试");
console.log("═══════════════════════════════════════");
const productPages = [
{ id: "novavis", name: "睿视NovaVis" },
{ id: "erp", name: "睿管ERP" },
{ id: "crm", name: "睿客CRM" },
{ id: "bi", name: "睿析BI" },
];
for (const p of productPages) {
const page = await browser.newPage({ viewport: desktop });
const errorCollector = createErrorCollector();
page.on("console", errorCollector.listener);
try {
await waitForPage(page, `${BASE}/products/${p.id}`);
const title = await page.title();
log(p.name, title ? "pass" : "fail", `页面标题: "${title?.substring(0, 40)}"`);
// 布局对齐
const alignment = await page.evaluate(() => {
const header = document.querySelector("header .container-wide");
const hRect = header?.getBoundingClientRect();
const headerLeft = hRect ? Math.round(hRect.left) : null;
const sections = document.querySelectorAll("section");
const misaligned = [];
sections.forEach((s, i) => {
const container = s.querySelector(".container-wide, .container-full");
if (container) {
const cRect = container.getBoundingClientRect();
const cLeft = Math.round(cRect.left);
if (cLeft !== headerLeft && cRect.height > 30) {
misaligned.push({ index: i, left: cLeft });
}
}
});
return { headerLeft, misaligned, sectionCount: sections.length };
});
if (alignment.misaligned.length === 0) {
log(`${p.name}-对齐`, "pass", `所有 ${alignment.sectionCount} 个 section 容器与 Header 对齐 (left=${alignment.headerLeft})`);
} else {
log(`${p.name}-对齐`, "fail", `${alignment.misaligned.length} 个 section 未对齐: ${JSON.stringify(alignment.misaligned)}`);
}
// 内容完整性
const content = await page.evaluate(() => document.body.innerText);
const hasContent = content.length > 100;
log(`${p.name}-内容`, hasContent ? "pass" : "fail", `页面内容长度: ${content.length} 字符`);
// CTA 检测
const hasCTA = content.includes("预约") || content.includes("体验") || content.includes("联系");
log(`${p.name}-CTA`, hasCTA ? "pass" : "warn", "CTA 文案检测");
// 控制台错误
await page.waitForTimeout(1000);
if (errorCollector.getErrors().length > 0) {
log(`${p.name}-控制台`, "warn", `${errorCollector.getErrors().length} 个错误`);
} else {
log(`${p.name}-控制台`, "pass", "无真实控制台错误");
}
} catch (e) {
log(p.name, "fail", `页面加载失败: ${e.message.substring(0, 60)}`);
}
await page.close();
}
// ========== 3. 产品列表页测试 ==========
console.log("\n═══════════════════════════════════════");
console.log(" 3. 产品列表页测试");
console.log("═══════════════════════════════════════");
{
const page = await browser.newPage({ viewport: desktop });
try {
await waitForPage(page, `${BASE}/products`);
const alignment = await page.evaluate(() => {
const header = document.querySelector("header .container-wide");
const hRect = header?.getBoundingClientRect();
const headerLeft = hRect ? Math.round(hRect.left) : null;
const containers = document.querySelectorAll(".container-wide, .container-full");
const positions = [];
containers.forEach((el) => {
const r = el.getBoundingClientRect();
if (r.height > 20) positions.push(Math.round(r.left));
});
return { headerLeft, uniqueLefts: [...new Set(positions)], count: positions.length };
});
if (alignment.uniqueLefts.length === 1 && alignment.uniqueLefts[0] === alignment.headerLeft) {
log("产品列表-对齐", "pass", `${alignment.count} 个容器对齐 (left=${alignment.headerLeft})`);
} else {
log("产品列表-对齐", "fail", `容器左边距不一致: [${alignment.uniqueLefts.join(", ")}]`);
}
const productLinks = await page.$$("a[href*='/products/']");
log("产品列表-卡片", productLinks.length > 0 ? "pass" : "fail", `产品链接数量: ${productLinks.length}`);
} catch (e) {
log("产品列表", "fail", `加载失败: ${e.message.substring(0, 60)}`);
}
await page.close();
}
// ========== 4. 暗色模式测试 ==========
console.log("\n═══════════════════════════════════════");
console.log(" 4. 暗色模式测试");
console.log("═══════════════════════════════════════");
{
const page = await browser.newPage({ viewport: desktop });
await waitForPage(page, BASE);
// 查找主题切换按钮
const themeToggle = await page.$("button[aria-label='切换主题'], [data-theme-toggle], button[aria-label*='主题'], button[aria-label*='theme']");
if (themeToggle) {
await themeToggle.click();
await page.waitForTimeout(800);
const darkOption = await page.$("button:has-text('深色')");
if (darkOption) {
await darkOption.click();
await page.waitForTimeout(1500);
} else {
log("暗色模式", "warn", "未找到深色选项按钮");
}
const darkMode = await page.evaluate(() => {
const html = document.documentElement;
const dataTheme = html.getAttribute("data-theme");
const classList = html.classList.contains("dark");
const bgColor = getComputedStyle(document.body).backgroundColor;
return { dataTheme, classList, bgColor };
});
log("暗色模式", darkMode.dataTheme === "dark" || darkMode.classList ? "pass" : "fail",
`data-theme="${darkMode.dataTheme}", dark class=${darkMode.classList}`);
// 暗色模式下 Logo 可见性
const logoVisible = await page.evaluate(() => {
const logo = document.querySelector("header img, header svg, header .logo");
if (!logo) return { found: false };
const rect = logo.getBoundingClientRect();
const style = getComputedStyle(logo);
return {
found: true,
visible: rect.width > 0 && rect.height > 0,
filter: style.filter,
width: Math.round(rect.width),
height: Math.round(rect.height),
};
});
if (logoVisible.found) {
log("暗色-Logo", logoVisible.visible ? "pass" : "fail",
`Logo 可见: ${logoVisible.visible}, filter: ${logoVisible.filter?.substring(0, 30)}`);
} else {
log("暗色-Logo", "warn", "未找到 Logo 元素");
}
// 暗色模式布局对齐
const darkAlignment = await page.evaluate(() => {
const header = document.querySelector("header .container-wide");
const hRect = header?.getBoundingClientRect();
const headerLeft = hRect ? Math.round(hRect.left) : null;
const containers = document.querySelectorAll(".container-wide, .container-full");
const positions = [];
containers.forEach((el) => {
const r = el.getBoundingClientRect();
if (r.height > 20) positions.push(Math.round(r.left));
});
return { headerLeft, uniqueLefts: [...new Set(positions)] };
});
if (darkAlignment.uniqueLefts.length === 1 && darkAlignment.uniqueLefts[0] === darkAlignment.headerLeft) {
log("暗色-对齐", "pass", "暗色模式布局对齐正常");
} else {
log("暗色-对齐", "fail", `暗色模式容器不对齐: [${darkAlignment.uniqueLefts.join(", ")}]`);
}
// 暗色模式硬编码白色背景检测
const whiteBgSections = await page.evaluate(() => {
const sections = document.querySelectorAll("section");
const whiteBg = [];
sections.forEach((s, i) => {
const bg = getComputedStyle(s).backgroundColor;
const classes = s.className || "";
if ((bg === "rgb(255, 255, 255)" || classes.includes("bg-white")) &&
document.documentElement.getAttribute("data-theme") === "dark") {
whiteBg.push(i);
}
});
return whiteBg;
});
if (whiteBgSections.length > 0) {
log("暗色-硬编码", "warn", `${whiteBgSections.length} 个 section 在暗色模式下仍使用白色背景: [${whiteBgSections.join(", ")}]`);
} else {
log("暗色-硬编码", "pass", "未检测到暗色模式下的白色硬编码背景");
}
} else {
log("暗色模式", "warn", "未找到主题切换按钮");
}
await page.close();
}
// ========== 5. 移动端响应式测试 ==========
console.log("\n═══════════════════════════════════════");
console.log(" 5. 移动端响应式测试");
console.log("═══════════════════════════════════════");
{
const page = await browser.newPage({ viewport: mobile });
await waitForPage(page, BASE);
// 移动端导航
const mobileNav = await page.evaluate(() => {
const hamburger = document.querySelector("button[aria-label*='菜单'], button[aria-label*='menu'], [class*='hamburger'], [class*='mobile-nav']");
const header = document.querySelector("header");
const headerRect = header?.getBoundingClientRect();
return {
hasHamburger: !!hamburger,
headerWidth: headerRect ? Math.round(headerRect.width) : null,
viewportWidth: window.innerWidth,
};
});
log("移动端-导航", mobileNav.hasHamburger ? "pass" : "warn",
`汉堡菜单: ${mobileNav.hasHamburger ? "有" : "无"}, header宽: ${mobileNav.headerWidth}px`);
// 移动端布局不溢出
const overflow = await page.evaluate(() => {
const bodyWidth = document.body.scrollWidth;
const vpWidth = window.innerWidth;
return { bodyWidth, vpWidth, overflow: bodyWidth > vpWidth + 2 };
});
log("移动端-溢出", !overflow.overflow ? "pass" : "fail",
`body=${overflow.bodyWidth}px, viewport=${overflow.vpWidth}px, 溢出=${overflow.overflow}`);
// 移动端产品详情页
try {
await waitForPage(page, `${BASE}/products/novavis`);
const mobileContent = await page.evaluate(() => document.body.innerText.length);
log("移动端-产品页", mobileContent > 100 ? "pass" : "fail", `NovaVis 内容: ${mobileContent} 字符`);
} catch (e) {
log("移动端-产品页", "fail", `加载失败: ${e.message.substring(0, 50)}`);
}
await page.close();
}
// ========== 6. 导航与路由测试 ==========
console.log("\n═══════════════════════════════════════");
console.log(" 6. 导航与路由测试");
console.log("═══════════════════════════════════════");
{
const page = await browser.newPage({ viewport: desktop });
await waitForPage(page, BASE);
const navLinks = await page.evaluate(() => {
const links = document.querySelectorAll("header a[href], nav a[href]");
return Array.from(links).map((a) => ({
href: a.getAttribute("href"),
text: a.textContent?.trim().substring(0, 20),
})).filter(l => l.href && !l.href.startsWith("http") && !l.href.startsWith("#"));
});
const uniqueLinks = [...new Map(navLinks.map(l => [l.href, l])).values()];
log("导航-链接", "pass", `发现 ${uniqueLinks.length} 个内部导航链接`);
for (const link of uniqueLinks.slice(0, 8)) {
try {
const resp = await page.goto(`${BASE}${link.href}`, { timeout: 10000, waitUntil: "domcontentloaded" });
const status = resp?.status();
log("导航-路由", status === 200 ? "pass" : "warn", `${link.href}${status}`);
} catch (e) {
log("导航-路由", "fail", `${link.href} → 加载失败`);
}
}
await page.close();
}
// ========== 7. 解决方案/服务页面测试 ==========
console.log("\n═══════════════════════════════════════");
console.log(" 7. 其他页面测试");
console.log("═══════════════════════════════════════");
{
const otherPages = ["/solutions", "/services", "/about", "/contact"];
const page = await browser.newPage({ viewport: desktop });
for (const path of otherPages) {
try {
const resp = await page.goto(`${BASE}${path}`, { timeout: 10000, waitUntil: "domcontentloaded" });
await page.waitForTimeout(2000);
const status = resp?.status();
if (status === 200) {
const alignment = await page.evaluate(() => {
const header = document.querySelector("header .container-wide");
const hRect = header?.getBoundingClientRect();
const headerLeft = hRect ? Math.round(hRect.left) : null;
const containers = document.querySelectorAll(".container-wide, .container-full");
const positions = [];
containers.forEach((el) => {
const r = el.getBoundingClientRect();
if (r.height > 20) positions.push(Math.round(r.left));
});
return { headerLeft, uniqueLefts: [...new Set(positions)], allAligned: [...new Set(positions)].length === 1 };
});
log(`${path}-对齐`, alignment.allAligned ? "pass" : "fail",
`left=${alignment.headerLeft}, 对齐=${alignment.allAligned}`);
log(`${path}-加载`, "pass", `HTTP ${status}`);
} else if (status === 404) {
log(`${path}`, "warn", `HTTP 404 - 页面不存在`);
} else {
log(`${path}`, "warn", `HTTP ${status}`);
}
} catch (e) {
log(`${path}`, "warn", `加载失败: ${e.message.substring(0, 40)}`);
}
}
await page.close();
}
// ========== 汇总 ==========
console.log("\n═══════════════════════════════════════");
console.log(" 📊 测试报告汇总");
console.log("═══════════════════════════════════════");
console.log(` ✅ 通过: ${RESULTS.pass}`);
console.log(` ❌ 失败: ${RESULTS.fail}`);
console.log(` ⚠️ 警告: ${RESULTS.warn}`);
console.log(` 📋 总计: ${RESULTS.pass + RESULTS.fail + RESULTS.warn}`);
if (RESULTS.fail > 0) {
console.log("\n 🔴 失败项:");
RESULTS.details.filter(d => d.status === "fail").forEach(d => {
console.log(` ❌ [${d.category}] ${d.message}`);
});
}
if (RESULTS.warn > 0) {
console.log("\n 🟡 警告项:");
RESULTS.details.filter(d => d.status === "warn").forEach(d => {
console.log(` ⚠️ [${d.category}] ${d.message}`);
});
}
await browser.close();
})();
+125
View File
@@ -0,0 +1,125 @@
#!/bin/bash
setup_gitea_webhook() {
show_banner "配置 Gitea Webhook"
local webhook_url="${JENKINS_URL}/generic-webhook-trigger/invoke?token=${WEBHOOK_TOKEN}"
log_info "Webhook URL: ${webhook_url}"
check_existing_webhook
create_webhook "$webhook_url"
test_webhook_delivery
}
check_existing_webhook() {
log_info "检查现有 Webhook..."
local webhooks=$(gitea_api_call "GET" "/repos/${REPO_OWNER}/${REPO_NAME}/hooks")
if [ -n "$webhooks" ] && [ "$webhooks" != "[]" ]; then
local existing_count=$(echo "$webhooks" | jq 'length')
log_warning "发现 ${existing_count} 个现有 Webhook"
echo "$webhooks" | jq -r '.[] | "\(.id) | \(.url)"' | while IFS='|' read -r hook_id hook_url; do
log_info " [ID: $hook_id] $hook_url"
done
read -p "是否删除所有旧 Webhook 并重新创建?(y/N): " delete_old
if [[ "$delete_old" =~ ^[Yy]$ ]]; then
echo "$webhooks" | jq -r '.[].id' | while read hook_id; do
gitea_api_call "DELETE" "/repos/${REPO_OWNER}/${REPO_NAME}/hooks/${hook_id}" > /dev/null
log_info " 已删除 Webhook #${hook_id}"
done
log_success "旧 Webhook 已清理"
fi
else
log_success "没有发现冲突的 Webhook"
fi
}
create_webhook() {
local webhook_url="$1"
log_info "创建新 Webhook..."
local payload="{
\"type\": \"gitea\",
\"config\": {
\"url\": \"${webhook_url}\",
\"content_type\": \"json\",
\"secret\": \"\",
\"http_method\": \"post\",
\"max_builds\": 0,
\"active\": true,
\"events\": [
\"push\"
],
\"branch_filter\": \"main,develop\"
}
}"
local result=$(gitea_api_call "POST" "/repos/${REPO_OWNER}/${REPO_NAME}/hooks" "$payload")
local hook_id=$(echo "$result" | jq -r '.id // empty')
if [ -n "$hook_id" ]; then
log_success "Webhook 创建成功 (ID: ${hook_id})"
log_info "触发事件: Push Events (main, develop 分支)"
log_info "目标 URL: ${webhook_url}"
else
log_error "Webhook 创建失败!"
log_error "响应: $result"
exit 1
fi
}
test_webhook_delivery() {
show_banner "测试 Webhook 连通性"
log_info "发送测试请求到 Gitea..."
local webhooks=$(gitea_api_call "GET" "/repos/${REPO_OWNER}/${REPO_NAME}/hooks")
local latest_hook_id=$(echo "$webhooks" | jq -r '.[-1].id')
if [ -z "$latest_hook_id" ] || [ "$latest_hook_id" = "null" ]; then
log_error "未找到可用的 Webhook ID"
return 1
fi
log_info "测试 Webhook #${latest_hook_id}..."
local test_result=$(gitea_api_CALL "POST" "/repos/${REPO_OWNER}/${REPO_NAME}/hooks/${latest_hook_id}/tests")
sleep 3
log_info "检查 Jenkins 是否收到触发..."
local jenkins_jobs=$(jenkins_api_call "GET" "/job/${JOB_NAME}/api/json?tree=lastBuild[number,url,result]" 2>/dev/null)
if [ -n "$jenkins_jobs" ]; then
local last_build=$(echo "$jenkins_jobs" | jq '.lastBuild.number // 0')
log_success "Jenkins 已收到 Webhook 触发(最新构建: #${last_build}"
else
log_warning "无法确认 Jenkins 是否收到触发(可能需要等待几秒)"
log_info "请手动访问: ${JENKINS_URL}/job/${JOB_NAME}/"
fi
}
show_webhook_status() {
echo ""
log_info "当前 Gitea Webhook 列表:"
echo "─────────────────────────────────────"
local webhooks=$(gitea_api_call "GET" "/repos/${REPO_OWNER}/${REPO_NAME}/hooks")
if [ -n "$webhooks" ] && [ "$webhooks" != "[]" ]; then
echo "$webhooks" | jq -r '.[] | "📌 [#\(.id)] \(.type) → \(.config.url)\n 状态: \(if .active then "✅ 激活" else "❌ 停用" end)\n 事件: \(.config.events | join(", "))\n 分支过滤: \(.config.branch_filter // "无")\n"'
else
log_info "(无 Webhook"
fi
echo ""
}
+306
View File
@@ -0,0 +1,306 @@
#!/bin/bash
setup_jenkins_container() {
show_banner "配置 Jenkins Docker 容器"
log_info "停止现有 Jenkins 容器..."
docker stop ${JENKINS_CONTAINER_NAME} 2>/dev/null || true
docker rm ${JENKINS_CONTAINER_NAME} 2>/dev/null || true
log_info "启动新的 Jenkins 容器(挂载 SSH 密钥)..."
if [ ! -d ~/.ssh ]; then
log_error "~/.ssh 目录不存在!"
log_info "请先生成 SSH 密钥: ssh-keygen -t rsa -b 4096"
exit 1
fi
docker run -d \
--name ${JENKINS_CONTAINER_NAME} \
--restart unless-stopped \
--user root \
-p "${JENKINS_PORT}:8080" \
-p "50000:50000" \
-v jenkins_data:/var/jenkins_home \
-v ~/.ssh:/var/jenkins_home/.ssh:ro \
-e JAVA_OPTS="-Djenkins.install.runSetupWizard=false" \
-e TZ="Asia/Shanghai" \
--network ${DOCKER_NETWORK} \
${JENKINS_IMAGE}
if [ $? -eq 0 ]; then
log_success "Jenkins 容器已启动"
log_info "容器名称: ${JENKINS_CONTAINER_NAME}"
log_info "访问地址: ${JENKINS_URL}"
else
log_error "Jenkins 容器启动失败!"
exit 1
fi
}
install_jenkins_tools() {
show_banner "安装 Jenkins 必要工具"
log_info "在 Jenkins 容器中安装 rsync, curl, openssh-client..."
docker exec -u root ${JENKINS_CONTAINER_NAME} bash -c "
apt-get update && \
apt-get install -y \
rsync \
curl \
openssh-client \
jq \
&& rm -rf /var/lib/apt/lists/* \
&& echo '✅ 工具安装完成'
"
if [ $? -eq 0 ]; then
log_success "工具安装成功"
echo ""
log_info "验证安装结果:"
docker exec ${JENKINS_CONTAINER_NAME} bash -c '
echo "--- rsync ---"
rsync --version | head -1 || echo "❌ 未安装"
echo ""
echo "--- curl ---"
curl --version | head -1 || echo "❌ 未安装"
echo ""
echo "--- ssh ---"
ssh -V || echo "❌ 未安装"
echo ""
echo "--- jq ---"
jq --version || echo "❌ 未安装"
'
else
log_error "工具安装失败!请手动执行:"
echo "docker exec -u root ${JENKINS_CONTAINER_NAME} bash -c 'apt-get update && apt-get install -y rsync curl openssh-client'"
exit 1
fi
test_ssh_connection
}
test_ssh_connection() {
show_banner "测试 SSH 连接到生产服务器"
log_info "测试连接到 ${SERVER_USER}@${SERVER_IP}..."
local result=$(docker exec ${JENKINS_CONTAINER_NAME} ssh \
-o StrictHostKeyChecking=no \
-o ConnectTimeout=5 \
-o BatchMode=yes \
${SERVER_USER}@${SERVER_IP} \
"hostname && whoami && pwd" 2>&1)
if [ $? -eq 0 ]; then
log_success "SSH 连接成功!"
echo "$result" | while IFS= read -r line; do
log_info " $line"
done
else
log_warning "SSH 连接失败(部署阶段将无法工作)"
log_error "错误信息: $result"
log_info "可能原因:"
log_info " 1. SSH 密钥未正确挂载到容器"
log_info " 2. 生产服务器的 authorized_keys 不包含公钥"
log_info ""
log_info "解决方案:"
log_info " 1. 在宿主机上测试: ssh ${SERVER_USER}@${SERVER_IP}"
log_info " 2. 如果宿主机可以免密登录,检查 docker run 的 -v 参数"
log_info " 3. 复制公钥到服务器: ssh-copy-id ${SERVER_USER}@${SERVER_IP}"
read -p "是否继续配置(跳过 SSH 测试)?(y/N): " continue_without_ssh
if [[ ! "$continue_without_ssh" =~ ^[Yy]$ ]]; then
exit 1
fi
fi
}
setup_jenkins_credentials() {
show_banner "配置 Jenkins 凭据"
create_gitea_credentials
}
create_gitea_credentials() {
log_info "创建 Gitea 访问凭据..."
local credentials_xml="<com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl>
<scope>GLOBAL</scope>
<id>gitea-credentials</id>
<description>Gitea 仓库访问凭据</description>
<username>${GITEA_USERNAME}</username>
<password>${GITEA_PASSWORD}</password>
</com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl>"
local result=$(jenkins_api_call "POST" "/credential-store/domain/createCredential" "
{
\"credentials\": {
\"scope\": \"GLOBAL\",
\"id\": \"gitea-credentials\",
\"username\": \"${GITEA_USERNAME}\",
\"password\": \"${GITEA_PASSWORD}\",
\"description\": \"Gitea 仓库访问凭据\",
\"\$class\": \"com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl\"
}
}
")
if echo "$result" | grep -q "error"; then
log_warning "凭据可能已存在,尝试更新..."
else
log_success "Gitea 凭据创建成功 (ID: gitea-credentials)"
fi
}
setup_jenkins_global_tools() {
show_banner "配置全局工具"
configure_nodejs
}
configure_nodejs() {
log_info "配置 Node.js ${NODE_VERSION}..."
jenkins_api_call "POST" "/pluginManager/plugins/nodescript/configure" > /dev/null 2>&1 || true
jenkins_api_call "POST" "/toolLocation/updateNodeJS" "
{
\"nodeJSHome\": \"/var/jenkins_home/tools/jenkins.plugins.nodejs.tools.NodeJSInstallation/NodeJS_${NODE_VERSION}\",
\"name\": \"${NODE_VERSION}\",
\"properties\": [
{
\"installSource\": {
\"installers\": [
{
\"id\": \"${NODE_VERSION}.x\"
}
]
},
\"stapler-class\": \"hudson.tools.InstallSourceProperty\"
}
]
}" > /dev/null 2>&1
log_success "Node.js 配置完成 (版本: ${NODE_VERSION})"
}
install_required_plugins() {
show_banner "安装 Jenkins 插件"
local plugins=(
"generic-webhook-trigger"
"nodejs"
"htmlpublisher"
"workspace-cleanup"
"git"
"credentials-binding"
"ssh-credentials"
"timestamper"
"ansicolor"
)
log_info "需要安装的插件: ${#plugins[@]}"
local plugin_list=$(IFS=','; echo "${plugins[*]}")
local install_result=$(jenkins_api_CALL "POST" "/pluginManager/installNecessaryPlugins" "
{
\"<dynamic-install>\": [${plugin_list}]
}" 2>/dev/null) || true
jenkins_api_call "POST" "/scriptText" "script=
import jenkins.model.*
import hudson.model.*
def plugins = [
'generic-webhook-trigger',
'nodejs',
'htmlpublisher',
'workspace-cleanup',
'timestamper',
'ansicolor'
]
def pm = Jenkins.instance.pluginManager
def uc = Jenkins.instance.updateCenter
pm.plugins.each { plugin ->
if (plugins.contains(plugin.shortName)) {
println \"✅ 已安装: \${plugin.shortName}\"
}
}
plugins.each { pluginName ->
def plugin = pm.getPlugin(pluginName)
if (!plugin) {
println \"📦 安装中: \${pluginName}\"
uc.getPlugin(pluginName).deploy(true).get()
}
}
println '✅ 插件安装完成'
return 'success'
" > /dev/null 2>&1
log_success "插件安装请求已提交(可能需要重启)"
log_info "建议手动重启 Jenkins 使插件生效:"
log_info " docker restart ${JENKINS_CONTAINER_NAME}"
}
create_pipeline_job() {
show_banner "创建 Pipeline 任务"
local job_config="<?xml version='1.0' encoding='UTF-8'?>
<flow-definition>
<description>Novalon Website CI/CD Pipeline (自动生成)</description>
<keepDependencies>false</keepDependencies>
<properties>
<hudson.model.ParametersDefinitionProperty>
<parameterDefinitions>
<hudson.model.BooleanParameterDefinition>
<name>DEPLOY_TO_PRODUCTION</name>
<description>是否部署到生产环境(仅在 main 分支且所有测试通过后可用)</description>
<defaultValue>false</defaultValue>
</hudson.model.BooleanParameterDefinition>
</parameterDefinitions>
</hudson.model.ParametersDefinitionProperty>
</properties>
<definition class=\"org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition\" plugin=\"workflow-cps@\">
<scm class=\"hudson.plugins.git.GitSCM\" plugin=\"git@\">
<configVersion>2</configVersion>
<userRemoteConfigs>
<hudson.plugins.git.UserRemoteConfig>
<url>${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}.git</url>
<credentialsId>gitea-credentials</credentialsId>
</hudson.plugins.git.UserRemoteConfig>
</userRemoteConfigs>
<branches>
<hudson.plugins.git.BranchSpec>
<name>*/main</name>
</hudson.plugins.git.BranchSpec>
</branches>
<doGenerateSubmoduleConfigurations>false</doGenerateSubmoduleConfigurations>
<submoduleCfg class=\"list\"/>
<extensions/>
</scm>
<scriptPath>Jenkinsfile</scriptPath>
<lightweight>true</lightweight>
</definition>
<triggers/>
<disabled>false</disabled>
</flow-definition>"
local result=$(jenkins_api_call "POST" "/createItem?name=${JOB_NAME}" "$job_config")
if [ $? -eq 0 ] && ! echo "$result" | grep -q "error"; then
log_success "Pipeline 任务创建成功: ${JOB_NAME}"
log_info "任务地址: ${JENKINS_URL}/job/${JOB_NAME}/"
else
log_warning "任务可能已存在,尝试更新配置..."
jenkins_api_call "POST" "/job/${JOB_NAME}/config.xml" "$job_config" > /dev/null
log_success "Pipeline 任务更新成功: ${JOB_NAME}"
fi
}
+172
View File
@@ -0,0 +1,172 @@
#!/bin/bash
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() {
echo -e "${BLUE}$1${NC}"
}
log_success() {
echo -e "${GREEN}$1${NC}"
}
log_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
log_error() {
echo -e "${RED}$1${NC}"
}
show_banner() {
echo ""
echo -e "${YELLOW}════════════════════════════════════════════${NC}"
echo -e "${YELLOW} $1${NC}"
echo -e "${YELLOW}════════════════════════════════════════════${NC}"
echo ""
}
load_config_or_defaults() {
if [ ! -f "$CONFIG_FILE" ]; then
log_warning "未找到配置文件,使用默认值"
return
fi
source "$CONFIG_FILE"
log_success "配置文件已加载: $CONFIG_FILE"
}
check_command_exists() {
if command -v "$1" >/dev/null 2>&1; then
return 0
else
return 1
fi
}
check_prerequisites() {
show_banner "检查前置条件"
local missing_tools=()
for cmd in docker curl jq; do
if check_command_exists "$cmd"; then
log_success "$cmd 已安装"
else
log_error "$cmd 未安装"
missing_tools+=("$cmd")
fi
done
if [ ${#missing_tools[@]} -ne 0 ]; then
log_error "缺少必要工具: ${missing_tools[*]}"
log_info "请安装后重试:brew install ${missing_tools[*]}"
return 1
fi
if [ ! -f ~/.ssh/id_rsa ] && [ ! -f ~/.ssh/id_ed25519 ]; then
log_warning "未找到 SSH 密钥,将无法免密登录生产服务器"
log_info "建议运行: ssh-keygen -t rsa -b 4096"
fi
log_success "前置条件检查通过"
return 0
}
wait_for_jenkins_ready() {
show_banner "等待 Jenkins 就绪"
local max_attempts=30
local attempt=1
while [ $attempt -le $max_attempts ]; do
if curl -sf "${JENKINS_URL}/login" > /dev/null 2>&1; then
log_success "Jenkins 已就绪 (${attempt}/${max_attempts})"
return 0
fi
log_info "等待 Jenkins 启动... (${attempt}/${max_attempts})"
sleep 5
((attempt++))
done
log_error "Jenkins 启动超时!请手动检查:docker logs ${JENKINS_CONTAINER_NAME}"
return 1
}
jenkins_api_call() {
local method="$1"
local endpoint="$2"
local data="$3"
local auth="${JENKINS_ADMIN_USER}:${JENKINS_ADMIN_PASSWORD}"
if [ -n "$data" ]; then
curl -s -X "$method" \
--user "$auth" \
-H "Content-Type: application/json" \
-d "$data" \
"${JENKINS_URL}${endpoint}" 2>/dev/null
else
curl -s -X "$method" \
--user "$auth" \
"${JENKINS_URL}${endpoint}" 2>/dev/null
fi
}
gitea_api_call() {
local method="$1"
local endpoint="$2"
local data="$3"
if [ -z "$GITEA_TOKEN" ]; then
obtain_gitea_token
fi
if [ -n "$data" ]; then
curl -s -X "$method" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$data" \
"${GITEA_URL}/api/v1${endpoint}" 2>/dev/null
else
curl -s -X "$method" \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1${endpoint}" 2>/dev/null
fi
}
obtain_gitea_token() {
log_info "获取 Gitea API Token..."
local response=$(curl -s -X POST \
"${GITEA_URL}/api/v1/users/${GITEA_USERNAME}/tokens" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"jenkins-ci-token-$(date +%s)\",
\"scopes\": [\"write:repository\", \"read:org\"]
}")
GITEA_TOKEN=$(echo "$response" | jq -r '.sha // empty')
if [ -n "$GITEA_TOKEN" ]; then
log_success "Gitea Token 获取成功"
else
log_error "Gitea Token 获取失败: $response"
exit 1
fi
}
cleanup_temp_tokens() {
log_info "清理临时 Token..."
gitea_api_call "GET" "/users/${GITEA_USERNAME}/tokens" | jq -r '.[] | select(.name | startswith("jenkins-ci-token")) | .id' | while read token_id; do
gitea_api_call "DELETE" "/users/${GITEA_USERNAME}/tokens/$token_id" > /dev/null
done
log_success "临时 Token 已清理"
}
+163
View File
@@ -0,0 +1,163 @@
#!/bin/bash
run_all_verifications() {
log_info "运行完整验证流程..."
verify_jenkins_accessibility
verify_gitea_connectivity
verify_jenkins_tools
verify_ssh_connection
verify_pipeline_job
verify_webhook_configuration
verify_jenkinsfile_exists
echo ""
show_verification_summary
}
verify_jenkins_accessibility() {
echo -n " Jenkins 可访问性: "
if curl -sf --connect-timeout 5 "${JENKINS_URL}/login" > /dev/null 2>&1; then
echo -e "${GREEN}✅ 通过${NC}"
return 0
else
echo -e "${RED}❌ 失败${NC}"
return 1
fi
}
verify_gitea_connectivity() {
echo -n " Gitea 连通性: "
if curl -sf --connect-timeout 5 "${GITEA_URL}/api/v1/version" > /dev/null 2>&1; then
local version=$(curl -sf "${GITEA_URL}/api/v1/version")
echo -e "${GREEN}✅ 通过${NC} (版本: $version)"
return 0
else
echo -e "${RED}❌ 失败${NC}"
return 1
fi
}
verify_jenkins_tools() {
echo -n " Jenkins 工具集: "
local missing=()
for tool in rsync curl ssh jq; do
if ! docker exec ${JENKINS_CONTAINER_NAME} which "$tool" > /dev/null 2>&1; then
missing+=("$tool")
fi
done
if [ ${#missing[@]} -eq 0 ]; then
echo -e "${GREEN}✅ 通过${NC}"
return 0
else
echo -e "${RED}❌ 缺少: ${missing[*]}${NC}"
return 1
fi
}
verify_ssh_connection() {
echo -n " SSH 免密登录: "
local result=$(docker exec ${JENKINS_CONTAINER_NAME} ssh \
-o StrictHostKeyChecking=no \
-o ConnectTimeout=5 \
-o BatchMode=yes \
${SERVER_USER}@${SERVER_IP} \
"echo 'OK'" 2>&1)
if [[ "$result" == *"OK"* ]]; then
echo -e "${GREEN}✅ 通过${NC}"
return 0
else
echo -e "${RED}❌ 失败${NC}"
return 1
fi
}
verify_pipeline_job() {
echo -n " Pipeline 任务: "
local result=$(jenkins_api_call "GET" "/job/${JOB_NAME}/api/json" 2>/dev/null)
if echo "$result" | jq -e '.name' > /dev/null 2>&1; then
echo -e "${GREEN}✅ 通过${NC} (${JOB_NAME})"
return 0
else
echo -e "${RED}❌ 未找到${NC}"
return 1
fi
}
verify_webhook_configuration() {
echo -n " Gitea Webhook: "
local webhooks=$(gitea_api_call "GET" "/repos/${REPO_OWNER}/${REPO_NAME}/hooks" 2>/dev/null)
if [ -n "$webhooks" ] && [ "$webhooks" != "[]" ] && [ "$webhooks" != "null" ]; then
local count=$(echo "$webhooks" | jq 'length')
echo -e "${GREEN}✅ 通过${NC} (${count} 个 Webhook)"
return 0
else
echo -e "${RED}❌ 未配置${NC}"
return 1
fi
}
verify_jenkinsfile_exists() {
echo -n " Jenkinsfile 文件: "
if [ -f "${PROJECT_ROOT}/Jenkinsfile" ]; then
echo -e "${GREEN}✅ 通过${NC}"
return 0
else
echo -e "${RED}❌ 未找到${NC}"
return 1
fi
}
show_verification_summary() {
show_banner "验证结果汇总"
local total=7
local passed=0
if verify_jenkins_accessibility >/dev/null 2>&1; then ((passed++)); fi
if verify_gitea_connectivity >/dev/null 2>&1; then ((passed++)); fi
if verify_jenkins_tools >/dev/null 2>&1; then ((passed++)); fi
if verify_ssh_connection >/dev/null 2>&1; then ((passed++)); fi
if verify_pipeline_job >/dev/null 2>&1; then ((passed++)); fi
if verify_webhook_configuration >/dev/null 2>&1; then ((passed++)); fi
if verify_jenkinsfile_exists >/dev/null 2>&1; then ((passed++)); fi
local failed=$((total - passed))
echo ""
echo " 总计: ${total} 项检查"
echo -e " ${GREEN}通过: ${passed}${NC}"
if [ $failed -gt 0 ]; then
echo -e " ${RED}失败: ${failed}${NC}"
fi
echo ""
if [ $failed -eq 0 ]; then
echo -e " 🎉 ${GREEN}所有配置已就绪!可以开始使用 CI/CD 流水线了。${NC}"
echo ""
echo " 下一步操作:"
echo " 1. 访问 ${JENKINS_URL}/job/${JOB_NAME}/"
echo " 2. 点击 'Build Now' 进行首次构建"
echo " 3. 推送代码到 main 分支测试自动触发"
echo " 4. 勾选 DEPLOY_TO_PRODUCTION 参数测试部署"
else
echo -e " ⚠️ ${YELLOW}部分配置未通过,请查看上方详情并修复。${NC}"
echo ""
echo " 常见问题:"
echo " - SSH 连接失败 → 检查密钥挂载和 authorized_keys"
echo " - 工具缺失 → 运行: ./setup-cicd.sh --jenkins"
echo " - Webhook 失败 → 检查 Gitea 和 Jenkins 网络连通性"
fi
}
+46
View File
@@ -0,0 +1,46 @@
const { chromium } = require("playwright");
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
try {
await page.goto("http://localhost:3000/products/novavis", { waitUntil: "domcontentloaded", timeout: 20000 });
await page.waitForTimeout(3000);
const bodyText = await page.evaluate(() => document.body?.innerText || "");
const checks = [
["产品标题: 睿视 NovaVis", bodyText.includes("睿视 NovaVis")],
["定位: 面向执法机关", bodyText.includes("面向执法机关")],
["定位: 智能数据分析平台", bodyText.includes("智能数据分析平台")],
["特性1: 完全离线运行", bodyText.includes("完全离线运行")],
["特性2: AI 智能分析引擎", bodyText.includes("AI 智能分析引擎")],
["特性3: 智能关系网络图谱", bodyText.includes("关系网络图谱")],
["特性4: 全链路资金追踪", bodyText.includes("全链路资金追踪")],
["特性5: 一键报告生成", bodyText.includes("一键报告生成")],
["数据证明: 数据处理能力", bodyText.includes("数据处理能力")],
["数据证明: 百万级记录秒级处理", bodyText.includes("百万级记录")],
["规格: 完全离线运行 零网络依赖", bodyText.includes("零网络依赖")],
["规格: 等保三级", bodyText.includes("等保三级")],
["场景: 经济犯罪侦查", bodyText.includes("经济犯罪侦查")],
["场景: 反腐败调查", bodyText.includes("反腐败调查")],
["CTA: 预约体验", bodyText.includes("预约体验")],
];
console.log("=== NovaVis /products/novavis 最终验证 ===\n");
let passed = 0;
for (const [label, ok] of checks) {
if (ok) passed++;
console.log(ok ? " ✅" : " ❌", label);
}
console.log(`\n 结果: ${passed}/${checks.length} 通过`);
if (passed === checks.length) {
console.log("\n 🎉 NovaVis 产品页迁移完成,所有核心内容正确渲染!");
}
} catch(e) {
console.error("ERROR:", e.message);
}
await browser.close();
})();