chore: 添加设计分析参考和辅助脚本
- IHG设计分析HTML参考页面 - 布局检查、DOM诊断、验收测试等辅助脚本
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
{"type":"server-started","port":53000,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:53000","screen_dir":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/39838-1777791603"}
|
||||||
|
{"type":"screen-added","file":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/39838-1777791603/animation-style.html"}
|
||||||
|
{"type":"screen-added","file":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/39838-1777791603/hero-animation-demos.html"}
|
||||||
|
{"type":"screen-updated","file":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/39838-1777791603/hero-animation-demos.html"}
|
||||||
|
{"type":"screen-updated","file":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/39838-1777791603/hero-animation-demos.html"}
|
||||||
|
{"type":"screen-updated","file":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/39838-1777791603/hero-animation-demos.html"}
|
||||||
|
{"type":"screen-updated","file":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/39838-1777791603/hero-animation-demos.html"}
|
||||||
|
{"type":"screen-updated","file":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/39838-1777791603/hero-animation-demos.html"}
|
||||||
|
{"type":"screen-updated","file":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/39838-1777791603/hero-animation-demos.html"}
|
||||||
|
{"type":"screen-added","file":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/39838-1777791603/hero-ink-demos.html"}
|
||||||
|
{"type":"screen-added","file":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/39838-1777791603/hero-refined-demos.html"}
|
||||||
|
{"type":"server-stopped","reason":"idle timeout"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
39911
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{"type":"server-started","port":49579,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:49579","screen_dir":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/71919-1777542709"}
|
||||||
|
{"type":"screen-added","file":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/71919-1777542709/atlassian-comparison.html"}
|
||||||
|
{"source":"user-event","type":"click","text":"5\n \n 设计系统驱动一致性\n Atlassian Design System 统一了 Token、组件、模式三层。从字体(Atlassian Sans)、图标(1.5px stroke)、颜色(中性偏暖)到组件(按钮/标签/卡片),全产品线一致。Novalon 目前有设计变量但缺乏系统化组件库。","choice":"design-system","id":null,"timestamp":1777542883049}
|
||||||
|
{"source":"user-event","type":"click","text":"1\n \n 产品矩阵导航\n 顶部导航下拉展示所有产品,每个产品有独立落地页。用户可按\"产品\"或\"场景\"两条路径发现内容。Novalon 当前将产品、服务、解决方案平铺在单页中,缺乏独立深度。","choice":"product-matrix","id":null,"timestamp":1777542885404}
|
||||||
|
{"source":"user-event","type":"click","text":"1\n \n 产品矩阵导航\n 顶部导航下拉展示所有产品,每个产品有独立落地页。用户可按\"产品\"或\"场景\"两条路径发现内容。Novalon 当前将产品、服务、解决方案平铺在单页中,缺乏独立深度。","choice":"product-matrix","id":null,"timestamp":1777542923363}
|
||||||
|
{"source":"user-event","type":"click","text":"5\n \n 设计系统驱动一致性\n Atlassian Design System 统一了 Token、组件、模式三层。从字体(Atlassian Sans)、图标(1.5px stroke)、颜色(中性偏暖)到组件(按钮/标签/卡片),全产品线一致。Novalon 目前有设计变量但缺乏系统化组件库。","choice":"design-system","id":null,"timestamp":1777542928409}
|
||||||
|
{"source":"user-event","type":"click","text":"1\n \n 产品矩阵导航\n 顶部导航下拉展示所有产品,每个产品有独立落地页。用户可按\"产品\"或\"场景\"两条路径发现内容。Novalon 当前将产品、服务、解决方案平铺在单页中,缺乏独立深度。","choice":"product-matrix","id":null,"timestamp":1777542929579}
|
||||||
|
{"source":"user-event","type":"click","text":"3\n \n 场景化入口\n \"Get started with a template\" — Scrum / Bug Tracking / DevOps 等场景模板,让用户按自身需求快速找到切入点。Novalon 的解决方案页面目前是静态描述,缺乏场景引导。","choice":"scenario-entry","id":null,"timestamp":1777542951112}
|
||||||
|
{"source":"user-event","type":"click","text":"1\n \n 产品矩阵导航\n 顶部导航下拉展示所有产品,每个产品有独立落地页。用户可按\"产品\"或\"场景\"两条路径发现内容。Novalon 当前将产品、服务、解决方案平铺在单页中,缺乏独立深度。","choice":"product-matrix","id":null,"timestamp":1777542952419}
|
||||||
|
{"source":"user-event","type":"click","text":"2\n \n 社会证明前置\n Hero 之后紧跟\"300K+ 公司 / 80% Fortune 500\"数据条。Novalon 的数据统计在 Hero 底部,且缺乏大客户 Logo 墙和具体量化成果展示。","choice":"social-proof","id":null,"timestamp":1777543514788}
|
||||||
|
{"source":"user-event","type":"click","text":"1\n \n 产品矩阵导航\n 顶部导航下拉展示所有产品,每个产品有独立落地页。用户可按\"产品\"或\"场景\"两条路径发现内容。Novalon 当前将产品、服务、解决方案平铺在单页中,缺乏独立深度。","choice":"product-matrix","id":null,"timestamp":1777543918517}
|
||||||
|
{"type":"screen-added","file":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/71919-1777542709/brand-direction-comparison.html"}
|
||||||
|
{"source":"user-event","type":"click","text":"数字化转型\n 睿新致遠\n 智连未来,成长伙伴\n 预约演示\n \n \n \n \n B — 收敛但保留印记\n 白底为主 + 极简装饰线条 + 书法标题保留 + 朱砂红品牌色。克制专业,品牌锚点仍在。","choice":"b","id":null,"timestamp":1777544379412}
|
||||||
|
{"type":"screen-added","file":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/71919-1777542709/delivery-pace-comparison.html"}
|
||||||
|
{"type":"screen-added","file":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/71919-1777542709/homepage-layout-options.html"}
|
||||||
|
{"type":"screen-added","file":"/Users/zhangxiang/Codes/Novalon/novalon-website/.superpowers/brainstorm/71919-1777542709/design-spec-full.html"}
|
||||||
|
{"type":"server-stopped","reason":"idle timeout"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
71992
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>IHG设计模式分析 - Novalon产品架构</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Noto Sans SC', sans-serif;
|
||||||
|
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 100%);
|
||||||
|
color: #e8e8e8;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
.container { max-width: 1400px; margin: 0 auto; }
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, #fff 0%, #c41e3a 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.subtitle { font-size: 1.1rem; color: #a0a0a0; }
|
||||||
|
|
||||||
|
.comparison-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 40px;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 30px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-logo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ihg-logo { background: linear-gradient(135deg, #1d4ed8 0%, #3b82f6 100%); }
|
||||||
|
.novalon-logo { background: linear-gradient(135deg, #c41e3a 0%, #e11d48 100%); }
|
||||||
|
|
||||||
|
.panel-title { font-size: 1.5rem; font-weight: 600; }
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #a0a0a0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover { color: #fff; }
|
||||||
|
.tab.active {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-card:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-info h3 { font-size: 1.1rem; margin-bottom: 6px; }
|
||||||
|
.brand-info p { font-size: 0.9rem; color: #a0a0a0; line-height: 1.6; }
|
||||||
|
|
||||||
|
.principles {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.principles h2 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.principle-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.principle {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.principle-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.principle h3 { font-size: 1.1rem; margin-bottom: 10px; }
|
||||||
|
.principle p { font-size: 0.9rem; color: #a0a0a0; line-height: 1.6; }
|
||||||
|
|
||||||
|
.architecture {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.architecture h2 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hierarchy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #c41e3a;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-cards {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-card {
|
||||||
|
padding: 16px 32px;
|
||||||
|
background: linear-gradient(135deg, rgba(196, 30, 58, 0.2) 0%, rgba(196, 30, 58, 0.1) 100%);
|
||||||
|
border: 1px solid rgba(196, 30, 58, 0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector {
|
||||||
|
width: 2px;
|
||||||
|
height: 30px;
|
||||||
|
background: linear-gradient(180deg, rgba(196, 30, 58, 0.5) 0%, rgba(196, 30, 58, 0.1) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-section {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 30px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-question {
|
||||||
|
padding: 16px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-question:hover { background: rgba(255, 255, 255, 0.06); }
|
||||||
|
|
||||||
|
.faq-answer {
|
||||||
|
padding: 0
|
||||||
@@ -0,0 +1,890 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Novalon × IHG 设计模式融合</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700&family=Noto+Sans+SC:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-ink: #1C1C1C;
|
||||||
|
--color-ink-light: #595959;
|
||||||
|
--color-ink-muted: #A3A3A3;
|
||||||
|
--color-cinnabar: #C41E3A;
|
||||||
|
--color-paper: #FFFFFF;
|
||||||
|
--color-paper-warm: #FAFAFA;
|
||||||
|
--color-ink-dark: #0A0A0A;
|
||||||
|
|
||||||
|
--spacing-xs: 0.5rem;
|
||||||
|
--spacing-sm: 1rem;
|
||||||
|
--spacing-md: 1.5rem;
|
||||||
|
--spacing-lg: 2rem;
|
||||||
|
--spacing-xl: 3rem;
|
||||||
|
--spacing-2xl: 4rem;
|
||||||
|
|
||||||
|
--radius-lg: 1rem;
|
||||||
|
--radius-xl: 1.5rem;
|
||||||
|
|
||||||
|
--shadow-soft: 0 1px 3px rgba(0,0,0,0.04);
|
||||||
|
--shadow-hover: 0 16px 32px rgba(0,0,0,0.08);
|
||||||
|
|
||||||
|
--transition-default: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--color-paper);
|
||||||
|
color: var(--color-ink);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header - Group Brand Navigation */
|
||||||
|
.header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--spacing-md) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
font-family: 'Noto Serif SC', serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-logo-mark {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: linear-gradient(135deg, var(--color-ink) 0%, var(--color-ink-dark) 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--color-ink-light);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: var(--transition-default);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -8px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--color-cinnabar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero Section */
|
||||||
|
.hero {
|
||||||
|
padding: var(--spacing-2xl) 0 var(--spacing-xl);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-label {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-cinnabar);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-family: 'Noto Serif SC', serif;
|
||||||
|
font-size: clamp(2.5rem, 5vw, 3.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--color-ink-light);
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breadcrumb */
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-lg) 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a {
|
||||||
|
color: var(--color-ink-light);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: var(--transition-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a:hover {
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category Tabs */
|
||||||
|
.category-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tab {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-ink-light);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: var(--transition-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tab:hover {
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tab.active {
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tab.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--color-ink);
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category Description */
|
||||||
|
.category-description {
|
||||||
|
background: var(--color-paper-warm);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-description h2 {
|
||||||
|
font-family: 'Noto Serif SC', serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-description p {
|
||||||
|
color: var(--color-ink-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product Grid */
|
||||||
|
.products-section {
|
||||||
|
padding: var(--spacing-xl) 0 var(--spacing-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product Card */
|
||||||
|
.product-card {
|
||||||
|
background: var(--color-paper);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
transition: var(--transition-default);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card-image {
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card-image::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, var(--color-ink) 0%, var(--color-ink-dark) 100%);
|
||||||
|
opacity: 0.03;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card-icon {
|
||||||
|
font-family: 'Noto Serif SC', serif;
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card-content {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card-category {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-cinnabar);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card h3 {
|
||||||
|
font-family: 'Noto Serif SC', serif;
|
||||||
|
font-size: 1.375rem;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card p {
|
||||||
|
color: var(--color-ink-light);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding-top: var(--spacing-md);
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-tag {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border-radius: 100px;
|
||||||
|
color: var(--color-ink-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product Detail Page (Inline Preview) */
|
||||||
|
.product-detail-preview {
|
||||||
|
display: none;
|
||||||
|
background: var(--color-paper-warm);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--spacing-2xl);
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-preview.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-title h1 {
|
||||||
|
font-family: 'Noto Serif SC', serif;
|
||||||
|
font-size: 2.25rem;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-title .product-detail-category {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-cinnabar);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5fr 1fr;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-main h2 {
|
||||||
|
font-family: 'Noto Serif SC', serif;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: var(--spacing-lg) 0 var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-main p {
|
||||||
|
color: var(--color-ink-light);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) 0;
|
||||||
|
color: var(--color-ink-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li::before {
|
||||||
|
content: '';
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--color-cinnabar);
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-top: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 100px;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-card {
|
||||||
|
background: var(--color-paper);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background: var(--color-ink);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-default);
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn:hover {
|
||||||
|
background: var(--color-ink-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-ink);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-default);
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn:hover {
|
||||||
|
border-color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer Brands */
|
||||||
|
.footer-section {
|
||||||
|
background: var(--color-ink-dark);
|
||||||
|
color: white;
|
||||||
|
padding: var(--spacing-2xl) 0;
|
||||||
|
margin-top: var(--spacing-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brands {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brand-link {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: var(--transition-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brand-link:hover {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Demo Controls */
|
||||||
|
.demo-controls {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--spacing-lg);
|
||||||
|
right: var(--spacing-lg);
|
||||||
|
background: var(--color-paper);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-controls h4 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-toggle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-btn {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-md);
|
||||||
|
background: var(--color-paper-warm);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-btn.active {
|
||||||
|
background: var(--color-ink);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Close button for detail */
|
||||||
|
.close-detail {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
transition: var(--transition-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-detail:hover {
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.product-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-controls {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="header-content">
|
||||||
|
<a href="#" class="group-logo">
|
||||||
|
<div class="group-logo-mark">诺</div>
|
||||||
|
<span>Novalon</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="#" class="nav-link active">产品品牌</a>
|
||||||
|
<a href="#" class="nav-link">解决方案</a>
|
||||||
|
<a href="#" class="nav-link">服务</a>
|
||||||
|
<a href="#" class="nav-link">关于</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="#">首页</a>
|
||||||
|
<span>/</span>
|
||||||
|
<span>产品品牌</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Hero -->
|
||||||
|
<section class="hero">
|
||||||
|
<span class="hero-label">Product Portfolio</span>
|
||||||
|
<h1>Novalon 产品品牌矩阵</h1>
|
||||||
|
<p>从企业级数字化到智能化转型,我们的产品矩阵覆盖不同规模、不同阶段的企业需求,为您提供量身定制的解决方案</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Category Tabs -->
|
||||||
|
<div class="category-tabs" id="categoryTabs">
|
||||||
|
<button class="category-tab active" data-category="enterprise">企业旗舰系列</button>
|
||||||
|
<button class="category-tab" data-category="growth">成长加速系列</button>
|
||||||
|
<button class="category-tab" data-category="specialized">专业工具系列</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category Description -->
|
||||||
|
<div class="category-description" id="categoryDescription">
|
||||||
|
<h2>企业旗舰系列</h2>
|
||||||
|
<p>为大型企业和集团客户打造的全栈数字化解决方案,以深度业务理解和行业最佳实践为基础,助力企业实现卓越运营</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Detail Preview -->
|
||||||
|
<div class="product-detail-preview" id="productDetail">
|
||||||
|
<div class="product-detail-header">
|
||||||
|
<div class="product-detail-title">
|
||||||
|
<span class="product-detail-category" id="detailCategory">企业旗舰系列</span>
|
||||||
|
<h1 id="detailTitle">产品名称</h1>
|
||||||
|
</div>
|
||||||
|
<button class="close-detail" onclick="closeProductDetail()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="product-detail-content">
|
||||||
|
<div class="product-detail-main">
|
||||||
|
<p id="detailDescription">产品描述</p>
|
||||||
|
<h2>核心功能</h2>
|
||||||
|
<ul class="feature-list" id="detailFeatures">
|
||||||
|
<li>功能特性一</li>
|
||||||
|
<li>功能特性二</li>
|
||||||
|
<li>功能特性三</li>
|
||||||
|
</ul>
|
||||||
|
<h2>产品优势</h2>
|
||||||
|
<p id="detailBenefits">产品优势描述</p>
|
||||||
|
</div>
|
||||||
|
<div class="product-detail-sidebar">
|
||||||
|
<div class="sidebar-card">
|
||||||
|
<a href="#" class="primary-btn">预约演示</a>
|
||||||
|
<button class="secondary-btn">获取白皮书</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Grid -->
|
||||||
|
<section class="products-section">
|
||||||
|
<div class="product-grid" id="productGrid">
|
||||||
|
<!-- Products will be inserted here -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Brands -->
|
||||||
|
<footer class="footer-section">
|
||||||
|
<div class="container">
|
||||||
|
<h3 style="font-family: 'Noto Serif SC', serif; font-size: 1.25rem; margin-bottom: var(--spacing-sm);">Novalon 品牌家族</h3>
|
||||||
|
<p style="color: rgba(255,255,255,0.6); font-size: 0.9375rem;">探索我们所有的产品品牌,找到最适合您的解决方案</p>
|
||||||
|
<div class="footer-brands" id="footerBrands">
|
||||||
|
<!-- Footer brand links will be inserted here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Demo Controls -->
|
||||||
|
<div class="demo-controls">
|
||||||
|
<h4>设计模式展示</h4>
|
||||||
|
<div class="demo-toggle">
|
||||||
|
<button class="demo-btn active" onclick="toggleView('grid')">品牌概览</button>
|
||||||
|
<button class="demo-btn" onclick="toggleView('detail')">单品详情</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Product Data
|
||||||
|
const products = {
|
||||||
|
enterprise: [
|
||||||
|
{
|
||||||
|
id: 'stockpilot',
|
||||||
|
title: 'StockPilot',
|
||||||
|
category: '企业旗舰系列',
|
||||||
|
description: '智能供应链决策平台,融合AI预测与优化算法,为企业提供端到端的供应链数字化解决方案',
|
||||||
|
tags: ['AI驱动', '供应链', 'SaaS'],
|
||||||
|
features: ['智能需求预测', '库存优化引擎', '供应链可视性', '协同决策平台'],
|
||||||
|
benefits: '通过先进的机器学习算法精准预测市场需求,减少库存积压同时提升服务水平,帮助企业在不确定性中找到最优解'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'novaflow',
|
||||||
|
title: 'NovaFlow',
|
||||||
|
category: '企业旗舰系列',
|
||||||
|
description: '企业级业务流程管理平台,通过低代码方式快速构建和部署复杂业务流程',
|
||||||
|
tags: ['BPM', '低代码', '流程自动化'],
|
||||||
|
features: ['可视化流程设计器', '智能流程推荐', '多系统集成', '性能监控'],
|
||||||
|
benefits: '让业务人员也能参与流程设计,大幅缩短数字化转型周期,同时保持企业级的稳定性和可扩展性'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'insighthub',
|
||||||
|
title: 'InsightHub',
|
||||||
|
category: '企业旗舰系列',
|
||||||
|
description: '一站式数据分析与商业智能平台,将分散的数据转化为可执行的洞察',
|
||||||
|
tags: ['BI', '数据分析', '可视化'],
|
||||||
|
features: ['多源数据连接', '自助分析', 'AI增强分析', '实时仪表板'],
|
||||||
|
benefits: '打破数据孤岛,让每个业务决策者都能轻松获取洞察,从经验驱动转向数据驱动'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
growth: [
|
||||||
|
{
|
||||||
|
id: 'startos',
|
||||||
|
title: 'StartOS',
|
||||||
|
category: '成长加速系列',
|
||||||
|
description: '面向成长型企业的一站式运营管理系统,涵盖CRM、项目、财务等核心模块',
|
||||||
|
tags: ['中小企业', '一体化', 'SaaS'],
|
||||||
|
features: ['客户管理', '项目协作', '财务对账', '数据分析'],
|
||||||
|
benefits: '开箱即用的完整解决方案,让成长型企业无需复杂IT投入即可获得世界一流的管理工具'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'connex',
|
||||||
|
title: 'Connex',
|
||||||
|
category: '成长加速系列',
|
||||||
|
description: '智能客户连接平台,整合全渠道触点,打造无缝客户体验',
|
||||||
|
tags: ['客户体验', '全渠道', '营销自动化'],
|
||||||
|
features: ['全渠道接入', '客户画像', '智能路由', '满意度分析'],
|
||||||
|
benefits: '在正确的时间通过正确的渠道与客户沟通,提升转化率和客户忠诚度'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
specialized: [
|
||||||
|
{
|
||||||
|
id: 'secureguard',
|
||||||
|
title: 'SecureGuard',
|
||||||
|
category: '专业工具系列',
|
||||||
|
description: '企业级安全合规管理套件,帮助企业满足各种监管要求',
|
||||||
|
tags: ['安全', '合规', '审计'],
|
||||||
|
features: ['风险评估', '合规管理', '安全监控', '审计追踪'],
|
||||||
|
benefits: '化繁为简,让复杂的合规管理变成可落地的日常工作'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'devcloud',
|
||||||
|
title: 'DevCloud',
|
||||||
|
category: '专业工具系列',
|
||||||
|
description: '云原生开发与运维平台,赋能研发团队高效交付软件',
|
||||||
|
tags: ['DevOps', '云原生', 'CI/CD'],
|
||||||
|
features: ['代码管理', '自动化构建', '容器部署', '监控告警'],
|
||||||
|
benefits: '从代码到生产的全链路自动化,让软件开发像流水线一样高效可靠'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentCategory = 'enterprise';
|
||||||
|
let activeView = 'grid';
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
function init() {
|
||||||
|
renderProducts();
|
||||||
|
renderFooterBrands();
|
||||||
|
setupTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Products
|
||||||
|
function renderProducts() {
|
||||||
|
const grid = document.getElementById('productGrid');
|
||||||
|
const categoryProducts = products[currentCategory];
|
||||||
|
|
||||||
|
grid.innerHTML = categoryProducts.map(product => `
|
||||||
|
<a href="#" class="product-card" onclick="showProductDetail('${product.id}', '${currentCategory}'); return false;">
|
||||||
|
<div class="product-card-image">
|
||||||
|
<span class="product-card-icon">${product.title.charAt(0)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="product-card-content">
|
||||||
|
<div class="product-card-category">${product.category}</div>
|
||||||
|
<h3>${product.title}</h3>
|
||||||
|
<p>${product.description}</p>
|
||||||
|
<div class="product-card-meta">
|
||||||
|
${product.tags.map(tag => `<span class="product-tag">${tag}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Footer Brands
|
||||||
|
function renderFooterBrands() {
|
||||||
|
const footer = document.getElementById('footerBrands');
|
||||||
|
const allProducts = [...products.enterprise, ...products.growth, ...products.specialized];
|
||||||
|
|
||||||
|
footer.innerHTML = allProducts.map(product => `
|
||||||
|
<a href="#" class="footer-brand-link" onclick="quickJumpTo('${product.id}'); return false;">${product.title}</a>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup Tabs
|
||||||
|
function setupTabs() {
|
||||||
|
const tabs = document.querySelectorAll('.category-tab');
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
tabs.forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
|
||||||
|
currentCategory = tab.dataset.category;
|
||||||
|
updateCategoryDescription();
|
||||||
|
renderProducts();
|
||||||
|
closeProductDetail();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Category Description
|
||||||
|
function updateCategoryDescription() {
|
||||||
|
const desc = document.getElementById('categoryDescription');
|
||||||
|
const descriptions = {
|
||||||
|
enterprise: {
|
||||||
|
title: '企业旗舰系列',
|
||||||
|
text: '为大型企业和集团客户打造的全栈数字化解决方案,以深度业务理解和行业最佳实践为基础,助力企业实现卓越运营'
|
||||||
|
},
|
||||||
|
growth: {
|
||||||
|
title: '成长加速系列',
|
||||||
|
text: '面向快速成长企业的轻量化解决方案,在保持简洁易用的同时提供强大的功能,助力企业规模化发展'
|
||||||
|
},
|
||||||
|
specialized: {
|
||||||
|
title: '专业工具系列',
|
||||||
|
text: '针对特定业务领域的专业工具,深入业务痛点,提供业界领先的专业解决方案'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
desc.innerHTML = `
|
||||||
|
<h2>${descriptions[currentCategory].title}</h2>
|
||||||
|
<p>${descriptions[currentCategory].text}</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show Product Detail
|
||||||
|
function showProductDetail(productId, category) {
|
||||||
|
const allProducts = [...products.enterprise, ...products.growth, ...products.specialized];
|
||||||
|
const product = allProducts.find(p => p.id === productId);
|
||||||
|
|
||||||
|
if (!product) return;
|
||||||
|
|
||||||
|
document.getElementById('detailCategory').textContent = product.category;
|
||||||
|
document.getElementById('detailTitle').textContent = product.title;
|
||||||
|
document.getElementById('detailDescription').textContent = product.description;
|
||||||
|
document.getElementById('detailFeatures').innerHTML = product.features.map(f => `<li>${f}</li>`).join('');
|
||||||
|
document.getElementById('detailBenefits').textContent = product.benefits;
|
||||||
|
|
||||||
|
document.getElementById('productDetail').classList.add('active');
|
||||||
|
document.getElementById('productDetail').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close Product Detail
|
||||||
|
function closeProductDetail() {
|
||||||
|
document.getElementById('productDetail').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick Jump
|
||||||
|
function quickJumpTo(productId) {
|
||||||
|
const allProducts = [...products.enterprise, ...products.growth, ...products.specialized];
|
||||||
|
const product = allProducts.find(p => p.id === productId);
|
||||||
|
|
||||||
|
if (product) {
|
||||||
|
// Find and switch to the correct category
|
||||||
|
let targetCategory = 'enterprise';
|
||||||
|
if (products.growth.find(p => p.id === productId)) targetCategory = 'growth';
|
||||||
|
if (products.specialized.find(p => p.id === productId)) targetCategory = 'specialized';
|
||||||
|
|
||||||
|
// Switch tabs
|
||||||
|
const tabs = document.querySelectorAll('.category-tab');
|
||||||
|
tabs.forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelector(`[data-category="${targetCategory}"]`).classList.add('active');
|
||||||
|
currentCategory = targetCategory;
|
||||||
|
updateCategoryDescription();
|
||||||
|
renderProducts();
|
||||||
|
|
||||||
|
// Show detail after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
showProductDetail(productId, targetCategory);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle View (Demo Control)
|
||||||
|
function toggleView(view) {
|
||||||
|
const buttons = document.querySelectorAll('.demo-btn');
|
||||||
|
buttons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
event.target.classList.add('active');
|
||||||
|
|
||||||
|
if (view === 'grid') {
|
||||||
|
closeProductDetail();
|
||||||
|
document.getElementById('productGrid').style.display = 'grid';
|
||||||
|
} else {
|
||||||
|
showProductDetail('stockpilot', 'enterprise');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on load
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
Executable
+125
@@ -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 ""
|
||||||
|
}
|
||||||
Executable
+306
@@ -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
|
||||||
|
}
|
||||||
Executable
+172
@@ -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 已清理"
|
||||||
|
}
|
||||||
Executable
+163
@@ -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
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user