文件内容
scripts/grid_builder.py
#!/usr/bin/env python3
"""
grid_builder.py — Grid-based HTML模块化引擎 v1.0.0
两层级模块体系:
- Base (base): CSS原语 (字体/颜色/渐变/圆角/图片裁切/间距)
- Composite (composite): 复合模块 (图文组合/多行文字/二维码卡片等)
Grid spec 定义布局 → cells 分配模块 → 输出带 CSS Grid 的自包含 HTML
用法:
python grid_builder.py --spec <grid_spec.json> --output <out.html>
python grid_builder.py --list-modules
python grid_builder.py --list-templates
python grid_builder.py --demo --template harmony-app --output demo.html
"""
import argparse
import json
import re
import sys
import traceback
from copy import deepcopy
from pathlib import Path
SKILL_DIR = Path(__file__).parent.parent
SCRIPTS_DIR = SKILL_DIR / "scripts"
BUILTIN_TEMPLATES_DIR = SCRIPTS_DIR / "templates" # 内置文件型模板(跟随技能)
OUTPUT_DIR = DATA_DIR / "output"
USER_TEMPLATES_DIR = DATA_DIR / "user-templates"
# ══════════════════════════════════════════════════════
# 中文错误处理工具
# ══════════════════════════════════════════════════════
def show_error(err_type, message, fix_hint=""):
"""输出中文错误提示 + 修复建议。不抛异常,不输出英文 Traceback。"""
icon_map = {
"参数错误": "❌",
"文件错误": "📁",
"模块错误": "🧩",
"模板错误": "📋",
"路径错误": "🔗",
"JSON错误": "📄",
"内部错误": "⚙️",
}
icon = icon_map.get(err_type, "❌")
# 兼容 GBK 终端(Windows 下无法显示 emoji 时降级为纯文字)
lines = [
f"\n[{err_type}] {message}",
]
if fix_hint:
lines.append(f" [修复建议] {fix_hint}")
lines.append(" [提示] 如仍有问题,可查看 FAQ (references/faq.md) 或使用 --help 查看参数说明")
msg = "\n".join(lines)
try:
print(f"\n{icon} [{err_type}] {message}")
if fix_hint:
print(f" 💡 修复建议: {fix_hint}")
print(" ℹ️ 如仍有问题,可查看 FAQ (references/faq.md) 或使用 --help 查看参数说明")
except UnicodeEncodeError:
# Fallback for GBK terminals (Windows)
print(msg.encode("ascii", errors="replace").decode("ascii"))
def safe_read_json(path):
"""安全读取 JSON 文件,失败输出中文错误"""
p = Path(path)
if not p.exists():
show_error("文件错误", f"找不到文件: {p}", f"请检查路径是否正确。可使用绝对路径,如: {Path.cwd() / p}")
return None
try:
with open(p, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
show_error("JSON错误", f"文件 {p} 格式错误: {e}", "请确认 JSON 格式正确,可使用 jsonlint.com 校验")
return None
except Exception as e:
show_error("文件错误", f"读取文件 {p} 失败: {e}", "检查文件权限和编码(应为 UTF-8)")
return None
def safe_write_text(path, text, desc="文件"):
"""安全写入文本文件"""
p = Path(path)
try:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(text, encoding="utf-8")
except Exception as e:
show_error("文件错误", f"写入 {desc} 失败: {p}", f"检查目录权限或路径是否有效: {e}")
return False
return True
# ══════════════════════════════════════════════════════
# 第一层: Base Modules (CSS 原语)
# ══════════════════════════════════════════════════════
BASE_MODULES = {
# ── 字体大小 ──
"font-size-xxl": {"type": "css", "css": {"font-size": "36px", "font-weight": "700", "line-height": "1.2"}, "desc": "超大标题 36px Bold"},
"font-size-xl": {"type": "css", "css": {"font-size": "28px", "font-weight": "600", "line-height": "1.2"}, "desc": "大标题 28px Semibold"},
"font-size-lg": {"type": "css", "css": {"font-size": "18px", "font-weight": "600", "line-height": "1.3"}, "desc": "中标题 18px Semibold"},
"font-size-md": {"type": "css", "css": {"font-size": "15px", "font-weight": "400", "line-height": "1.5"}, "desc": "正文 15px Regular"},
"font-size-sm": {"type": "css", "css": {"font-size": "13px", "font-weight": "500", "line-height": "1.4"}, "desc": "小字 13px Medium"},
"font-size-xs": {"type": "css", "css": {"font-size": "11px", "font-weight": "500", "line-height": "1.3"}, "desc": "极小 11px Medium"},
"font-size-xxs": {"type": "css", "css": {"font-size": "9px", "font-weight": "400", "line-height": "1.3"}, "desc": "注释 9px Regular"},
# ── 字体颜色 ──
"color-dark": {"type": "css", "css": {"color": "#1a2a3a"}, "desc": "深色文字"},
"color-mid": {"type": "css", "css": {"color": "#4f606f"}, "desc": "中灰色文字"},
"color-light": {"type": "css", "css": {"color": "#8b9aab"}, "desc": "浅灰色文字"},
"color-white": {"type": "css", "css": {"color": "#ffffff"}, "desc": "白色文字"},
"color-primary": {"type": "css", "css": {"color": "#2c7da0"}, "desc": "主色调文字"},
"color-gradient-text": {"type": "css", "css": {
"background": "linear-gradient(145deg, #2d3f55 0%, #13212e 100%)",
"-webkit-background-clip": "text",
"background-clip": "text",
"color": "transparent",
}, "desc": "渐变文字"},
# ── 背景 ──
"bg-white": {"type": "css", "css": {"background": "#ffffff"}, "desc": "白色背景"},
"bg-transparent": {"type": "css", "css": {"background": "transparent"}, "desc": "透明背景"},
"bg-light-blue": {"type": "css", "css": {"background": "rgba(240, 246, 255, 0.6)"}, "desc": "浅蓝半透明背景"},
"bg-light-gray": {"type": "css", "css": {"background": "#f8f9fa"}, "desc": "浅灰色背景"},
"bg-glass": {"type": "css", "css": {"background": "rgba(255,255,255,0.82)", "backdrop-filter": "blur(25px)", "-webkit-backdrop-filter": "blur(25px)"}, "desc": "毛玻璃效果"},
"bg-dark": {"type": "css", "css": {"background": "#1a2a3a"}, "desc": "深色背景"},
"bg-gradient-purple": {"type": "css", "css": {"background": "linear-gradient(135deg, #6C63FF 0%, #FF6584 100%)"}, "desc": "粉紫渐变"},
"bg-gradient-blue": {"type": "css", "css": {"background": "linear-gradient(135deg, #00B894 0%, #00CEC9 100%)"}, "desc": "蓝绿渐变"},
"bg-gradient-dark": {"type": "css", "css": {"background": "linear-gradient(135deg, #2D3436 0%, #636E72 100%)"}, "desc": "深灰渐变"},
"bg-gradient-gold": {"type": "css", "css": {"background": "linear-gradient(135deg, #c0392b 0%, #e74c3c 100%)"}, "desc": "红金渐变"},
# ── 圆角 ──
"radius-sm": {"type": "css", "css": {"border-radius": "8px"}, "desc": "小圆角 8px"},
"radius-md": {"type": "css", "css": {"border-radius": "16px"}, "desc": "中圆角 16px"},
"radius-lg": {"type": "css", "css": {"border-radius": "24px"}, "desc": "大圆角 24px"},
"radius-xl": {"type": "css", "css": {"border-radius": "36px"}, "desc": "超大圆角 36px"},
"radius-full": {"type": "css", "css": {"border-radius": "50%"}, "desc": "圆形 50%"},
"radius-pill": {"type": "css", "css": {"border-radius": "40px"}, "desc": "胶囊圆角 40px"},
# ── 边距 ──
"pad-xs": {"type": "css", "css": {"padding": "8px"}, "desc": "小间距 8px"},
"pad-sm": {"type": "css", "css": {"padding": "12px 16px"}, "desc": "中间距 12x16"},
"pad-md": {"type": "css", "css": {"padding": "16px 20px"}, "desc": "中上间距 16x20"},
"pad-lg": {"type": "css", "css": {"padding": "24px 20px 28px"}, "desc": "大间距 24x20x28"},
"pad-xl": {"type": "css", "css": {"padding": "40px 24px"}, "desc": "超大间距 40x24"},
# ── 阴影 ──
"shadow-sm": {"type": "css", "css": {"box-shadow": "0 2px 8px rgba(0,0,0,0.06)"}, "desc": "小阴影"},
"shadow-md": {"type": "css", "css": {"box-shadow": "0 10px 20px -8px rgba(0,20,30,0.15)"}, "desc": "中阴影"},
"shadow-lg": {"type": "css", "css": {"box-shadow": "0 20px 35px -12px rgba(0,0,0,0.12)"}, "desc": "大阴影"},
"shadow-glass": {"type": "css", "css": {"box-shadow": "0 20px 35px -8px rgba(0,0,0,0.15), 0 4px 12px rgba(0,0,0,0.05), inset 0 1px 1px rgba(255,255,255,0.7)"}, "desc": "毛玻璃阴影"},
# ── 边框 ──
"border-glass": {"type": "css", "css": {"border": "1px solid rgba(255,255,255,0.4)"}, "desc": "毛玻璃边框"},
"border-light": {"type": "css", "css": {"border": "1px solid rgba(210,225,245,0.8)"}, "desc": "浅色边框"},
"border-bottom": {"type": "css", "css": {"border-bottom": "1px solid #d0deed"}, "desc": "底部边框线"},
"divider-gradient": {"type": "css", "css": {"height": "1px", "background": "linear-gradient(90deg, transparent, #b3c6d9, transparent)"}, "desc": "渐变分割线"},
"divider-solid": {"type": "css", "css": {"height": "1px", "background": "#e0e8f0"}, "desc": "实色分割线"},
# ── 图片样式 ──
"img-circle": {"type": "css", "css": {"border-radius": "50%", "object-fit": "cover"}, "desc": "圆形图片裁剪"},
"img-cover": {"type": "css", "css": {"width": "100%", "height": "200px", "object-fit": "cover", "border-radius": "8px"}, "desc": "封面填充"},
"img-contain":{"type": "css", "css": {"width": "100%", "height": "auto", "border-radius": "8px"}, "desc": "含展示(不变形)"},
"img-logo": {"type": "css", "css": {"width": "80px", "height": "auto", "position": "absolute", "top": "12px", "left": "12px"}, "desc": "Logo定位"},
# ── flex / 布局原语 ──
"flex-center": {"type": "css", "css": {"display": "flex", "align-items": "center", "justify-content": "center"}, "desc": "Flex居中"},
"flex-between": {"type": "css", "css": {"display": "flex", "align-items": "center", "justify-content": "space-between"}, "desc": "Flex两端对齐"},
"flex-col": {"type": "css", "css": {"display": "flex", "flex-direction": "column"}, "desc": "Flex纵向排列"},
"text-center": {"type": "css", "css": {"text-align": "center"}, "desc": "文字居中"},
"text-left": {"type": "css", "css": {"text-align": "left"}, "desc": "文字左对齐"},
"gap-xs": {"type": "css", "css": {"gap": "8px"}, "desc": "小间隙 8px"},
"gap-sm": {"type": "css", "css": {"gap": "12px"}, "desc": "中间隙 12px"},
"gap-md": {"type": "css", "css": {"gap": "16px"}, "desc": "大间隙 16px"},
"gap-lg": {"type": "css", "css": {"gap": "24px"}, "desc": "超大间隙 24px"},
# ── 透明度 ──
"opacity-100": {"type": "css", "css": {"opacity": "1"}, "desc": "不透明"},
"opacity-90": {"type": "css", "css": {"opacity": "0.9"}, "desc": "90%透明"},
"opacity-70": {"type": "css", "css": {"opacity": "0.7"}, "desc": "70%透明"},
"opacity-50": {"type": "css", "css": {"opacity": "0.5"}, "desc": "50%透明"},
# ── 动画 ──
"anim-fade": {"type": "css", "css": {"animation": "gridFadeIn 0.6s ease-out"}, "desc": "淡入动画"},
"anim-slide": {"type": "css", "css": {"animation": "gridSlideIn 0.5s ease-out"}, "desc": "滑入动画"},
"hover-scale":{"type": "css", "css": {"transition": "transform 0.3s"}, "desc": "悬停放大"},
}
# ══════════════════════════════════════════════════════
# 第二层: Composite Modules (复合模块)
# 每个复合模块引用 base modules + 定义 HTML 模板
# ══════════════════════════════════════════════════════
COMPOSITE_MODULES = {
# ── 头部 ──
"header-entity": {
"desc": "应用头部(圆形图标 + 名称 + 标签)",
"base": ["flex-center", "gap-xs", "pad-xs"],
"template": (
'<div class="grid-header-entity">'
'<div class="ghe-icon">'
'<img class="ghe-icon-img editable-img" data-field="app-icon" src="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2748%27 height=%2748%27%3E%3Crect fill=%27%236C63FF%27 width=%2748%27 height=%2748%27 rx=%2716%27/%3E%3Ctext x=%2724%27 y=%2728%27 text-anchor=%27middle%27 fill=%27white%27 font-size=%2720%27 font-weight=%27bold%27%3EA%3C/text%3E%3C/svg%3E" alt="应用图标">'
'</div>'
'<div class="ghe-text">'
'<div class="ghe-name" data-field="entity-name">应用名称</div>'
'<div class="ghe-badge" data-field="entity-badge">标签描述</div>'
'</div>'
'</div>'
),
"css": """
.grid-header-entity { display:flex; align-items:center; gap:8px; justify-content:center; }
.ghe-icon { width:48px; height:48px; border-radius:16px; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.ghe-icon-img { width:100%; height:100%; border-radius:16px; object-fit:cover; cursor:pointer; }
.ghe-text { display:flex; flex-direction:column; }
.ghe-name { font-size:18px; font-weight:600; color:#1a2a3a; line-height:1.2; }
.ghe-badge { font-size:11px; color:#5e707f; background:rgba(210,225,240,0.6); padding:2px 8px; border-radius:30px; margin-top:2px; border:0.3px solid rgba(0,90,150,0.1); text-align:center; }
"""
},
"header-dual": {
"desc": "双实体头部(左应用 + 右元服务)",
"base": ["flex-between", "gap-sm", "pad-xs"],
"template": (
'<div class="grid-dual-header">'
'<div class="gdh-entity gdh-left">'
'<div class="gdh-icon"><img class="gdh-icon-img editable-img" data-field="app-icon" src="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2748%27 height=%2748%27%3E%3Crect fill=%27%236C63FF%27 width=%2748%27 height=%2748%27 rx=%2716%27/%3E%3Ctext x=%2724%27 y=%2728%27 text-anchor=%27middle%27 fill=%27white%27 font-size=%2720%27 font-weight=%27bold%27%3EA%3C/text%3E%3C/svg%3E" alt="应用图标"></div>'
'<div class="gdh-text">'
'<div class="gdh-name" data-field="app-name">应用名称</div>'
'<div class="gdh-badge" data-field="app-badge">应用市场</div>'
'</div>'
'</div>'
'<div class="gdh-entity gdh-right">'
'<div class="gdh-text" style="text-align:right;align-items:flex-end;">'
'<div class="gdh-name" data-field="service-name">元服务</div>'
'<div class="gdh-badge" data-field="service-badge">即用·免安装</div>'
'</div>'
'<div class="gdh-icon"><img class="gdh-icon-img editable-img" data-field="service-icon" src="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2748%27 height=%2748%27%3E%3Crect fill=%27%23FF6584%27 width=%2748%27 height=%2748%27 rx=%2716%27/%3E%3Ctext x=%2724%27 y=%2728%27 text-anchor=%27middle%27 fill=%27white%27 font-size=%2720%27 font-weight=%27bold%27%3ES%3C/text%3E%3C/svg%3E" alt="元服务图标"></div>'
'</div>'
'</div>'
),
"css": """
.grid-dual-header { display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:20px; }
.gdh-entity { display:flex; align-items:center; gap:8px; flex:1; }
.gdh-left { justify-content:flex-start; }
.gdh-right { justify-content:flex-end; }
.gdh-icon { width:48px; height:48px; border-radius:16px; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.gdh-icon-img { width:100%; height:100%; border-radius:16px; object-fit:cover; cursor:pointer; }
.gdh-text { display:flex; flex-direction:column; min-width:0; }
.gdh-right .gdh-text { align-items:flex-end; text-align:right; }
.gdh-name { font-size:18px; font-weight:600; color:#1a2a3a; line-height:1.2; white-space:nowrap; }
.gdh-badge { font-size:11px; color:#5e707f; background:rgba(210,225,240,0.6); padding:2px 8px; border-radius:30px; margin-top:2px; border:0.3px solid rgba(0,90,150,0.1); }
"""
},
# ── 主标题区 ──
"main-title": {
"desc": "主标题(渐变文字 + 副标题 + 底部边线)",
"base": ["text-center", "gap-xs", "pad-sm"],
"template": (
'<div class="grid-main-title">'
'<div class="gmt-title" data-field="main-title">主标题文字</div>'
'<div class="gmt-sub" data-field="main-sub">副标题描述</div>'
'</div>'
),
"css": """
.grid-main-title { text-align:center; margin:6px 0 14px 0; }
.gmt-title { font-size:28px; font-weight:600; background:linear-gradient(145deg,#2d3f55 0%,#13212e 100%); -webkit-background-clip:text; background-clip:text; color:transparent; letter-spacing:0.5px; line-height:1.2; }
.gmt-sub { font-size:15px; font-weight:380; color:#4f606f; border-bottom:1px solid #d0deed; padding-bottom:12px; margin:0 10px 8px 10px; }
"""
},
# ── 二维码卡片 ──
"qr-card": {
"desc": "单张二维码卡片",
"base": [],
"template": (
'<div class="grid-qr-section">'
'<div class="gqs-item">'
'<div class="gqs-qr-wrapper">'
'<img class="gqs-qr-img editable-img" src="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27110%27 height=%27110%27%3E%3Crect fill=%27%23f2f5fa%27 width=%27110%27 height=%27110%27/%3E%3Cpath d=%27M30 30L80 30L80 80L30 80Z%27 fill=%27none%27 stroke=%27%233f5e7a%27 stroke-width=%274%27 stroke-dasharray=%278 6%27/%3E%3Ctext x=%2720%27 y=%2760%27 font-size=%2712%27 fill=%27%233f5e7a%27%3E二维码%3C/text%3E%3C/svg%3E" data-field="qr-image" alt="QR码占位">'
'</div>'
'<span class="gqs-label" data-field="qr-label">扫码体验</span>'
'<span class="gqs-hint" data-field="qr-hint">平台说明</span>'
'</div>'
'</div>'
),
"css": """
.grid-qr-section { display:flex; gap:16px; margin:10px 0 16px 0; justify-content:center; }
.gqs-item { background:white; border-radius:24px; padding:16px 12px 12px; box-shadow:0 10px 20px -8px rgba(0,20,30,0.15); border:1px solid rgba(210,225,245,0.8); display:flex; flex-direction:column; align-items:center; max-width:160px; }
.gqs-qr-wrapper { width:110px; height:110px; background:#fff; border-radius:16px; padding:6px; box-shadow:0 2px 8px rgba(0,0,0,0.03); display:flex; align-items:center; justify-content:center; }
.gqs-qr-img { width:100%; height:100%; object-fit:contain; display:block; border-radius:12px; }
.gqs-label { margin-top:8px; font-size:12px; font-weight:500; color:#2e4a62; background:#ecf3fa; padding:3px 12px; border-radius:40px; border:0.3px solid #bdd0e6; white-space:nowrap; }
.gqs-hint { font-size:9px; color:#7e92a5; margin-top:2px; }
"""
},
"qr-dual": {
"desc": "双二维码(应用 + 元服务并排)",
"base": [],
"template": (
'<div class="grid-qr-dual">'
'<div class="gqd-item">'
'<div class="gqd-qr-wrapper">'
'<img class="gqd-qr-img editable-img" src="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27110%27 height=%27110%27%3E%3Crect fill=%27%23f2f5fa%27 width=%27110%27 height=%27110%27/%3E%3Cpath d=%27M30 30L80 30L80 80L30 80Z%27 fill=%27none%27 stroke=%27%233f5e7a%27 stroke-width=%274%27 stroke-dasharray=%278 6%27/%3E%3Ctext x=%2720%27 y=%2760%27 font-size=%2712%27 fill=%27%233f5e7a%27%3E应用码%3C/text%3E%3C/svg%3E" data-field="qr-image-left" alt="QR码左">'
'</div>'
'<span class="gqd-label" data-field="qr-label-left">安装应用</span>'
'<span class="gqd-hint" data-field="qr-hint-left">华为应用市场</span>'
'</div>'
'<div class="gqd-item">'
'<div class="gqd-qr-wrapper">'
'<img class="gqd-qr-img editable-img" src="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27110%27 height=%27110%27%3E%3Crect fill=%27%23f2f5fa%27 width=%27110%27 height=%27110%27/%3E%3Cpath d=%27M30 30L80 30L80 80L30 80Z%27 fill=%27none%27 stroke=%27%233f5e7a%27 stroke-width=%274%27 stroke-dasharray=%278 6%27/%3E%3Ctext x=%2720%27 y=%2760%27 font-size=%2712%27 fill=%27%233f5e7a%27%3E元服务码%3C/text%3E%3C/svg%3E" data-field="qr-image-right" alt="QR码右">'
'</div>'
'<span class="gqd-label" data-field="qr-label-right">体验元服务</span>'
'<span class="gqd-hint" data-field="qr-hint-right">即扫即用</span>'
'</div>'
'</div>'
),
"css": """
.grid-qr-dual { display:flex; gap:16px; margin:10px 0 16px 0; justify-content:center; }
.gqd-item { background:white; border-radius:24px; padding:16px 12px 12px; box-shadow:0 10px 20px -8px rgba(0,20,30,0.15); border:1px solid rgba(210,225,245,0.8); flex:1; display:flex; flex-direction:column; align-items:center; max-width:160px; }
.gqd-qr-wrapper { width:110px; height:110px; background:#fff; border-radius:16px; padding:6px; box-shadow:0 2px 8px rgba(0,0,0,0.03); display:flex; align-items:center; justify-content:center; }
.gqd-qr-img { width:100%; height:100%; object-fit:contain; display:block; border-radius:12px; }
.gqd-label { margin-top:8px; font-size:12px; font-weight:500; color:#2e4a62; background:#ecf3fa; padding:3px 12px; border-radius:40px; border:0.3px solid #bdd0e6; white-space:nowrap; }
.gqd-hint { font-size:9px; color:#7e92a5; margin-top:2px; }
"""
},
# ── 特性面板 ──
"feature-panel": {
"desc": "特性面板(多行图标+文字描述)",
"base": [],
"template": (
'<div class="grid-feature-panel">'
'<div class="gfp-row" data-row="0">'
'<span class="gfp-icon" data-field="feature-icon-0">✨⭐</span>'
'<span class="gfp-text" data-field="feature-text-0">特性描述一</span>'
'</div>'
'<div class="gfp-row" data-row="1">'
'<span class="gfp-icon" data-field="feature-icon-1">🎯📊</span>'
'<span class="gfp-text" data-field="feature-text-1">特性描述二</span>'
'</div>'
'</div>'
),
"css": """
.grid-feature-panel { background:rgba(240,246,255,0.6); border-radius:40px; padding:12px 16px; backdrop-filter:blur(8px); border:0.6px solid rgba(190,210,235,0.9); display:flex; flex-direction:column; gap:12px; }
.gfp-row { display:flex; align-items:center; gap:12px; color:#1e3a5f; justify-content:center; }
.gfp-icon { font-size:18px; color:#3a5f7a; min-width:60px; text-align:center; }
.gfp-text { font-size:13px; font-weight:500; }
"""
},
# ── 通信面板 (设备标签 + 箭头) ──
"comms-panel": {
"desc": "双端通信/对比面板(标签 + 连接线 + 协议标注)",
"base": [],
"template": (
'<div class="grid-comms-panel">'
'<div class="gcp-row" data-row="0">'
'<span class="gcp-device"><span class="gcp-dev-icon">📱</span> <span data-field="device-a">端A</span></span>'
'<span class="gcp-arrow">⟷ <span class="gcp-protocol" data-field="protocol-a">协议/连接</span> ⟷</span>'
'<span class="gcp-device"><span class="gcp-dev-icon">📱</span> <span data-field="device-b">端B</span></span>'
'</div>'
'<div class="gcp-row" data-row="1">'
'<span class="gcp-device"><span class="gcp-dev-icon">⭐</span> <span data-field="device-c">端C</span></span>'
'<span class="gcp-arrow">⟶ <span class="gcp-protocol" data-field="protocol-b">协议/连接</span> ⟶</span>'
'<span class="gcp-device"><span class="gcp-dev-icon">📱</span> <span data-field="device-d">端D</span></span>'
'</div>'
'</div>'
),
"css": """
.grid-comms-panel { background:rgba(240,246,255,0.6); border-radius:40px; padding:12px 16px; backdrop-filter:blur(8px); border:0.6px solid rgba(190,210,235,0.9); display:flex; flex-direction:column; gap:10px; }
.gcp-row { display:flex; align-items:center; justify-content:space-around; flex-wrap:wrap; gap:8px; }
.gcp-device { display:flex; align-items:center; gap:4px; background:white; padding:4px 12px; border-radius:30px; font-size:13px; color:#1e3a5f; box-shadow:0 2px 4px rgba(0,0,0,0.02); border:0.5px solid #ccddef; }
.gcp-dev-icon { font-size:16px; }
.gcp-arrow { display:flex; align-items:center; gap:4px; color:#6e8bb0; font-size:14px; }
.gcp-protocol { background:#d9e9ff; border-radius:30px; padding:2px 8px; font-size:10px; font-weight:600; color:#1e5c8b; border:0.5px solid #a0c0e0; }
"""
},
# ── 脚注 ──
"footer-caption": {
"desc": "底部说明行(分隔线 + 标签组)",
"base": [],
"template": (
'<div class="grid-footer">'
'<div class="gf-divider"></div>'
'<div class="gf-caption">'
'<span data-field="footer-tag-1">标签一</span>'
'<span data-field="footer-tag-2">标签二</span>'
'<span data-field="footer-tag-3">标签三</span>'
'</div>'
'</div>'
),
"css": """
.grid-footer { margin-top:4px; }
.gf-divider { margin:14px 0 6px 0; height:1px; background:linear-gradient(90deg,transparent,#b3c6d9,transparent); }
.gf-caption { text-align:center; font-size:10px; color:#8b9aab; display:flex; justify-content:center; gap:10px; }
.gf-caption span { background:rgba(210,225,240,0.5); padding:2px 10px; border-radius:30px; }
"""
},
"small-note": {
"desc": "极小注释文字行",
"base": [],
"template": (
'<div class="grid-small-note" data-field="note-text">注释说明文字</div>'
),
"css": """
.grid-small-note { font-size:9px; color:#7f93a8; text-align:center; margin-top:4px; }
"""
},
# ── 纯文本块 ──
"text-block": {
"desc": "纯文本块(标题 + 正文)",
"base": [],
"template": (
'<div class="grid-text-block">'
'<h3 class="gtb-title" data-field="tb-title">标题</h3>'
'<p class="gtb-body" data-field="tb-body">正文内容,可以包含多行文字描述。</p>'
'<p class="gtb-body" data-field="tb-body-2">第二段正文。</p>'
'</div>'
),
"css": """
.grid-text-block { padding:16px; }
.gtb-title { font-size:18px; font-weight:600; color:#1a2a3a; margin:0 0 8px 0; }
.gtb-body { font-size:14px; line-height:1.6; color:#4f606f; margin:0 0 8px 0; }
"""
},
# ── 图文组合(左文右图) ──
"text-img-right": {
"desc": "左文右图组合",
"base": [],
"template": (
'<div class="grid-text-img">'
'<div class="gti-text">'
'<h3 class="gti-title" data-field="ti-title">标题</h3>'
'<p class="gti-desc" data-field="ti-desc">描述文字</p>'
'</div>'
'<div class="gti-image">'
'<img class="gti-img editable-img" src="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27300%27 height=%27200%27%3E%3Crect fill=%27%23e0e8f0%27 width=%27300%27 height=%27200%27 rx=%2712%27/%3E%3Ctext x=%27150%27 y=%27110%27 text-anchor=%27middle%27 fill=%27%238b9aab%27 font-size=%2716%27%3E点击/拖入图片%3C/text%3E%3C/svg%3E" data-field="ti-image" alt="图文区图片" style="width:100%;border-radius:12px;">'
'</div>'
'</div>'
),
"css": """
.grid-text-img { display:grid; grid-template-columns:1fr 1fr; gap:20px; align-items:center; padding:16px; }
.gti-text { display:flex; flex-direction:column; gap:8px; }
.gti-title { font-size:18px; font-weight:600; color:#1a2a3a; margin:0; }
.gti-desc { font-size:14px; color:#4f606f; margin:0; line-height:1.6; }
.gti-image { display:flex; align-items:center; justify-content:center; }
.gti-img { width:100%; border-radius:12px; cursor:pointer; }
"""
},
# ── 参数面板(表单区) ──
"param-panel": {
"desc": "参数设置面板(带标题的信息区)",
"base": [],
"template": (
'<div class="grid-param-panel">'
'<div class="gpp-title" data-field="param-title">📆 参数控制</div>'
'<div class="gpp-body">'
'<div class="gpp-row"><label>参数1:</label><span class="gpp-value" data-field="param-1">值1</span></div>'
'<div class="gpp-row"><label>参数2:</label><span class="gpp-value" data-field="param-2">值2</span></div>'
'</div>'
'</div>'
),
"css": """
.grid-param-panel { background:#f8fafd; padding:20px 24px; border-radius:24px; border:1px solid #dce5ef; }
.gpp-title { font-weight:700; font-size:1.05rem; color:#1f5068; border-bottom:2px solid #cbdde9; padding-bottom:6px; margin-bottom:12px; }
.gpp-body { display:flex; flex-direction:column; gap:10px; }
.gpp-row { display:flex; align-items:center; gap:16px; }
.gpp-row label { font-weight:600; min-width:80px; }
.gpp-value { color:#4a627a; }
"""
},
# ── 数据表格 ──
"data-table": {
"desc": "数据表格(含表头 + 可编辑行)",
"base": [],
"template": (
'<div class="grid-data-table">'
'<table class="gdt-table">'
'<thead><tr><th data-field="th-1">列1</th><th data-field="th-2">列2</th><th>操作</th></tr></thead>'
'<tbody>'
'<tr><td data-field="td-row0-col0">值1</td><td data-field="td-row0-col1">值2</td><td><button class="gdt-del">🗑️</button></td></tr>'
'</tbody>'
'</table>'
'<button class="gdt-add">+ 添加</button>'
'</div>'
),
"css": """
.grid-data-table { width:100%; }
.gdt-table { width:100%; border-collapse:collapse; background:white; border-radius:16px; overflow:hidden; font-size:0.85rem; }
.gdt-table th { background:#eef3fc; font-weight:600; padding:8px 6px; border-bottom:1px solid #e2e8f0; text-align:left; }
.gdt-table td { padding:8px 6px; border-bottom:1px solid #e2e8f0; }
.gdt-del { background:none; border:none; cursor:pointer; font-size:1.1rem; color:#b91c1c; padding:0 5px; }
.gdt-add { margin-top:12px; background:#eef2ff; border:1px dashed #5f8ab6; padding:6px 14px; border-radius:40px; font-size:0.8rem; cursor:pointer; }
"""
},
# ── 统计卡片 ──
"stat-card": {
"desc": "数据统计卡片(大数字 + 图例)",
"base": [],
"template": (
'<div class="grid-stat-card">'
'<div class="gsc-main">'
'<div class="gsc-label" data-field="stat-label">总工日</div>'
'<div class="gsc-value" data-field="stat-value">365</div>'
'</div>'
'<div class="gsc-legend">'
'<span class="badge badge-work">🟢 工作日</span>'
'<span class="badge badge-holiday">🟠 节假日</span>'
'</div>'
'</div>'
),
"css": """
.grid-stat-card { background:#eef3fa; border-radius:20px; padding:14px 22px; display:flex; justify-content:space-between; align-items:baseline; flex-wrap:wrap; gap:12px; }
.gsc-main { display:flex; flex-direction:column; gap:4px; }
.gsc-label { font-size:14px; color:#4a627a; }
.gsc-value { font-size:2rem; font-weight:800; color:#1f6392; }
.gsc-legend { display:flex; gap:18px; font-size:0.75rem; flex-wrap:wrap; }
.badge { display:inline-block; font-size:0.7rem; border-radius:30px; padding:2px 8px; }
.badge-work { background:#e1f7dc; color:#2c6e2c; }
.badge-holiday { background:#ffdec2; color:#bc5100; }
.badge-rest { background:#ffe6e5; color:#b13e3e; }
.badge-comp { background:#d9effa; color:#00668c; }
"""
},
}
# ══════════════════════════════════════════════════════
# 样式预设 (Style Presets)
# ══════════════════════════════════════════════════════
STYLE_PRESETS = {
"business": {
"name": "商务风格",
"card_style": {
"max_width": "1200px", "width": "100%",
"bg": "#f0f4f8", "border_radius": "8px",
"shadow": "0 4px 12px rgba(0,0,0,0.08)",
"padding": "24px",
"border": "1px solid #d0d8e0",
},
"font": "'Microsoft YaHei', sans-serif",
"primary": "#1a2a4a", "secondary": "#4a5568",
},
"academic": {
"name": "科研风格",
"card_style": {
"max_width": "960px", "width": "100%",
"bg": "#ffffff", "border_radius": "4px",
"shadow": "0 2px 8px rgba(0,0,0,0.06)",
"padding": "28px",
"border": "1px solid #cccccc",
},
"font": "SimSun, serif",
"primary": "#333333", "secondary": "#666666",
},
"festive": {
"name": "喜庆风格(红金配色)",
"card_style": {
"max_width": "600px", "width": "100%",
"bg": "linear-gradient(135deg, #c0392b 0%, #e74c3c 100%)",
"border_radius": "16px",
"shadow": "0 8px 24px rgba(192,57,43,0.3)",
"padding": "32px",
"border": "none",
},
"font": "SimSun, serif",
"primary": "#FFD700", "secondary": "#FFA500",
},
"mourning": {
"name": "丧事风格(素雅黑白灰)",
"card_style": {
"max_width": "600px", "width": "100%",
"bg": "#f5f5f5", "border_radius": "8px",
"shadow": "0 2px 8px rgba(0,0,0,0.04)",
"padding": "28px",
"border": "1px solid #dddddd",
},
"font": "SimSun, serif",
"primary": "#333333", "secondary": "#666666",
},
"tech": {
"name": "技术风格(代码样式)",
"card_style": {
"max_width": "1200px", "width": "100%",
"bg": "#f8f9fa", "border_radius": "4px",
"shadow": "0 2px 8px rgba(0,0,0,0.06)",
"padding": "24px",
"border": "1px solid #d0d8e0",
},
"font": "Consolas, 'Courier New', monospace",
"primary": "#2D3436", "secondary": "#636E72",
},
}
# ══════════════════════════════════════════════════════
# Grid Spec 默认包装器
# ══════════════════════════════════════════════════════
DEFAULT_CARD_STYLE = {
"max_width": "400px",
"width": "100%",
"bg": "rgba(255,255,255,0.82)",
"backdrop": "blur(25px)",
"webkit_backdrop": "blur(25px)",
"border_radius": "36px",
"shadow": "0 20px 35px -8px rgba(0,0,0,0.15), 0 4px 12px rgba(0,0,0,0.05), inset 0 1px 1px rgba(255,255,255,0.7)",
"padding": "24px 20px 28px 20px",
"border": "1px solid rgba(255,255,255,0.4)",
}
def css_dict_to_str(css_dict):
"""Convert dict of CSS properties to inline style string"""
return "; ".join(f"{k}: {v}" for k, v in css_dict.items())
def merge_css_dicts(*dicts):
"""Merge multiple CSS dicts; later dicts override earlier ones"""
result = {}
for d in dicts:
if d:
result.update(d)
return result
def resolve_base_modules(base_names):
"""Resolve base module names to combined CSS dict"""
css = {}
for name in base_names:
if name in BASE_MODULES:
mod = BASE_MODULES[name]
if mod["type"] == "css":
css.update(mod["css"])
else:
print(f" [WARN] Unknown base module: {name}")
return css
def composite_inline_css(composite_name, cell_config=None):
"""Get inline CSS from composite module's base modules and cell overrides"""
mod = COMPOSITE_MODULES.get(composite_name, {})
base_names = mod.get("base", [])
cell_base = (cell_config or {}).get("base", [])
css = resolve_base_modules(base_names + cell_base)
# Cell-level style overrides
cell_style = (cell_config or {}).get("style", {})
css.update(cell_style)
return css
# ══════════════════════════════════════════════════════
# 内置模板定义
# ══════════════════════════════════════════════════════
BUILTIN_TEMPLATES = {
"harmony-app": {
"name": "App推广卡片(毛玻璃风格)",
"desc": "通用APP推广模板:头部图标 → 主标题 → 二维码 → 特性 → 脚注",
"card_style": DEFAULT_CARD_STYLE,
"grid": {"rows": 6, "cols": 1, "gap": "0",
"cells": [
{"id": "header", "row": 0, "col": 0, "module": "composite:header-entity"},
{"id": "title", "row": 1, "col": 0, "module": "composite:main-title"},
{"id": "qr", "row": 2, "col": 0, "module": "composite:qr-card"},
{"id": "features","row": 3, "col": 0, "module": "composite:feature-panel"},
{"id": "footer", "row": 4, "col": 0, "module": "composite:footer-caption"},
{"id": "note", "row": 5, "col": 0, "module": "composite:small-note"},
],
},
},
"harmony-dual": {
"name": "双端推广卡片(应用+元服务/双实体)",
"desc": "通用双实体推广模板:左应用右服务 → 主标题 → 双二维码 → 通信/对比面板 → 脚注",
"card_style": DEFAULT_CARD_STYLE,
"grid": {"rows": 6, "cols": 1, "gap": "0",
"cells": [
{"id": "dual-header", "row": 0, "col": 0, "module": "composite:header-dual"},
{"id": "title", "row": 1, "col": 0, "module": "composite:main-title"},
{"id": "qr-dual", "row": 2, "col": 0, "module": "composite:qr-dual"},
{"id": "comms", "row": 3, "col": 0, "module": "composite:comms-panel"},
{"id": "footer", "row": 4, "col": 0, "module": "composite:footer-caption"},
{"id": "note", "row": 5, "col": 0, "module": "composite:small-note"},
],
},
},
"calendar-dashboard": {
"name": "动态周历·假日区间管理仪表板(完整交互版)",
"desc": "完全交互式:年份控制、周末规则、假日区间CRUD、补班管理、周历视图、总工日统计",
"source": "智能周历系统(用户模板泛化)",
"file": "scripts/templates/calendar-dashboard-interactive.json",
"card_style": {
"max_width": "1600px",
"width": "100%",
"bg": "#eef2f7",
"border_radius": "28px",
"shadow": "0 20px 35px -12px rgba(0,0,0,0.12)",
"padding": "24px 28px 36px",
},
"grid": {"rows": 5, "cols": 3, "gap": "16px",
"cells": [{"id":"header","row":0,"col":0,"colspan":3,"html":"<div>📅 动态周历</div>"}]*3 + [{"id":"stat","row":2,"col":0,"colspan":3,"html":"<div>🏆 总工日</div>"}],
},
},
"promo": {
"name": "活动宣传面板",
"desc": "渐变头部 + 卡片网格(原promo模板)",
"card_style": {
"max_width": "1200px", "width": "100%",
"bg": "#ffffff", "border_radius": "12px",
"shadow": "0 4px 20px rgba(0,0,0,0.08)",
"padding": "20px",
},
"grid": {"rows": 3, "cols": 3, "gap": "20px",
"cells": [
{"id": "header", "row": 0, "col": 0, "colspan": 3,
"module": "composite:text-block",
"style": {"text-align":"center","background":"linear-gradient(135deg,#6C63FF 0%,#3F51B5 100%)","color":"white","padding":"40px 20px","border-radius":"12px 12px 0 0","margin":"-20px -20px 0 -20px"}},
{"id": "card1", "row": 1, "col": 0,
"style": {"background":"#f8f9fa","padding":"24px","border-radius":"10px","border-top":"3px solid #6C63FF"}},
{"id": "card2", "row": 1, "col": 1,
"style": {"background":"#f8f9fa","padding":"24px","border-radius":"10px","border-top":"3px solid #6C63FF"}},
{"id": "card3", "row": 1, "col": 2,
"style": {"background":"#f8f9fa","padding":"24px","border-radius":"10px","border-top":"3px solid #6C63FF"}},
{"id": "footer", "row": 2, "col": 0, "colspan": 3,
"style": {"text-align":"center","padding":"20px","border-top":"1px solid #eee","color":"#999","font-size":"0.9em"}},
],
},
},
}
# ══════════════════════════════════════════════════════
# HTML 生成逻辑
# ══════════════════════════════════════════════════════
def get_cells(spec):
"""Get cells list from spec, supporting both top-level and grid.cells locations"""
cells = spec.get("cells", [])
if not cells:
cells = spec.get("grid", {}).get("cells", [])
return cells
def collect_all_css(template_spec):
"""Collect all CSS from composite modules used in this template"""
styles = []
cells = get_cells(template_spec)
grid_def = template_spec.get("grid", {})
# Apply style_preset if specified
preset_name = template_spec.get("style_preset", "")
if preset_name and preset_name in STYLE_PRESETS:
preset = STYLE_PRESETS[preset_name]
card_style = template_spec.get("card_style", {})
# Merge preset card_style (preset values are defaults, spec values override)
merged = {**preset["card_style"], **card_style}
template_spec["card_style"] = merged
# Add preset font family to body
preset_font = preset.get("font", "")
if preset_font:
template_spec["_preset_font"] = preset_font
else:
template_spec["_preset_font"] = ""
# Global grid CSS
rows = grid_def.get("rows", 1)
cols = grid_def.get("cols", 1)
gap = grid_def.get("gap", "0")
# Body background: use card bg if solid color, else default light
card = template_spec.get("card_style", DEFAULT_CARD_STYLE)
card_bg = card.get("bg", "#f5f7fa")
body_bg = card_bg if card_bg.startswith("#") else ("#000" if "backdrop" in card else "#eef2f7")
preset_font = template_spec.get("_preset_font", "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif")
styles.append(f"body {{ margin:0; padding:0; background:{body_bg}; display:flex; align-items:center; justify-content:center; min-height:100vh; font-family:{preset_font}; }}")
# Card style (skip backdrop-filter/border unless explicitly set — avoids clipping)
card_css_parts = [
f"max-width: {card.get('max_width','400px')};",
f"width: {card.get('width','100%')};",
f"background: {card.get('bg','rgba(255,255,255,0.82)')};",
f"border-radius: {card.get('border_radius','36px')};",
f"box-shadow: {card.get('shadow','0 20px 35px -8px rgba(0,0,0,0.15)')};",
f"padding: {card.get('padding','24px 20px 28px 20px')};",
"margin: 0 auto;",
"overflow: hidden;",
"transition: transform 0.2s ease;",
]
if "backdrop" in card:
card_css_parts.append(f"backdrop-filter: {card['backdrop']};")
if "webkit_backdrop" in card:
card_css_parts.append(f"-webkit-backdrop-filter: {card['webkit_backdrop']};")
if "border" in card:
card_css_parts.append(f"border: {card['border']};")
else:
card_css_parts.append("border: none;")
card_css = ".grid-card {\n " + "\n ".join(card_css_parts) + "\n}\n.grid-card:hover { transform:scale(1.01); }\n"
# Grid container
container_css = f"""
.grid-container {{
display: grid;
grid-template-columns: repeat({cols}, 1fr);
grid-template-rows: auto;
gap: {gap};
}}
"""
styles.append(card_css)
styles.append(container_css)
# Cell positions
for cell in cells:
r = cell.get("row", 0) + 1
c = cell.get("col", 0) + 1
rs = cell.get("rowspan", 1)
cs = cell.get("colspan", 1)
cell_id = cell.get("id", f"cell-{r}-{c}")
pos_css = f"#cell-{cell_id} {{ grid-row: {r} / span {rs}; grid-column: {c} / span {cs}; }}"
# Cell-level style: if cell has a "style" dict, use it directly (avoids duplicate properties)
cell_style = cell.get("style", {})
if cell_style:
pos_css += f"\n#cell-{cell_id} {{ {css_dict_to_str(cell_style)} }}"
else:
cell_bg = cell.get("bg", "transparent")
cell_pad = cell.get("padding", "4px")
pos_css += f"\n#cell-{cell_id} {{ background:{cell_bg}; padding:{cell_pad}; }}"
styles.append(pos_css)
# Composite module CSS
seen_modules = set()
for cell in cells:
module_name = cell.get("module", "")
if module_name.startswith("composite:"):
mname = module_name.split(":", 1)[1]
if mname in COMPOSITE_MODULES and mname not in seen_modules:
mod_css = COMPOSITE_MODULES[mname].get("css", "")
if mod_css:
styles.append(mod_css)
seen_modules.add(mname)
# Animation keyframes
styles.append("""
@keyframes gridFadeIn { from { opacity:0; transform:translateY(20px); } to { opacity:1; transform:translateY(0); } }
@keyframes gridSlideIn { from { opacity:0; transform:translateX(-30px); } to { opacity:1; transform:translateX(0); } }
* { margin:0; padding:0; box-sizing:border-box; }
.edit-text { border:1px dashed transparent; padding:2px 4px; min-height:1em; outline:none; }
.edit-text:hover { border-color:#aaa; background:rgba(108,99,255,0.05); }
.edit-text:focus { border-color:#6C63FF; background:white; }
""")
return "\n".join(styles)
def build_cell_html(template_spec):
"""Build HTML for each cell in the grid (支持组件式和旧模块格式)"""
cells = get_cells(template_spec)
parts = []
# 导入组件引擎
try:
from module_assembler import render_cell_content, cell_constraint_css
_HAVE_COMPONENT_ENGINE = True
except Exception:
_HAVE_COMPONENT_ENGINE = False
for cell in cells:
cell_id = cell.get("id", "cell-x")
module_name = cell.get("module", "")
raw_html = cell.get("html", "")
components = cell.get("components", None)
# 优先级1:组件式 → 使用 module_assembler
if components and _HAVE_COMPONENT_ENGINE:
content = render_cell_content(cell, cell_id)
# 优先级2:旧格式 raw HTML
elif raw_html:
content = raw_html
# 优先级3:旧格式 composite module
elif module_name.startswith("composite:"):
mname = module_name.split(":", 1)[1]
mod = COMPOSITE_MODULES.get(mname, {})
content = mod.get("template", f"<div data-field='{cell_id}'>[{mname}]</div>")
else:
content = f'<div data-field="{cell_id}" class="edit-text">[{cell_id}]</div>'
# 约束CSS
constraint_css = ""
if _HAVE_COMPONENT_ENGINE and cell.get("constraint"):
constraint_css = cell_constraint_css(cell)
elif _HAVE_COMPONENT_ENGINE and components:
constraint_css = cell_constraint_css(cell)
cell_html = f'<div id="cell-{cell_id}" class="grid-cell" style="{constraint_css}">{content}</div>'
parts.append(cell_html)
return "\n".join(parts)
def generate_html(template_spec):
"""Generate complete HTML from template spec"""
# Pre-generation grid validation
grid = template_spec.get("grid", {})
rows = grid.get("rows", 0)
cols = grid.get("cols", 0)
if rows < 1 or cols < 1:
print("[ERROR] Grid spec 缺少有效的 rows/cols")
return "<html><body><p>Grid 规格错误</p></body></html>"
all_css = collect_all_css(template_spec)
body = build_cell_html(template_spec)
custom_scripts = template_spec.get("scripts", "")
if custom_scripts.strip():
custom_scripts = "\n<script>\n" + custom_scripts + "\n</script>\n"
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{template_spec.get('name','Grid HTML')}</title>
<style>
{all_css}
</style>
</head>
<body>
<div class="grid-card">
<div class="grid-container">
{body}
</div>
</div>
<!-- Editor toolbar -->
<div id="editor-bar" style="display:none;position:fixed;top:0;left:0;right:0;background:#1e1e2e;color:white;padding:6px 14px;z-index:9999;font-size:13px;gap:8px;align-items:center;flex-wrap:wrap;box-shadow:0 2px 12px rgba(0,0,0,0.3);">
<span style="color:#6C63FF;font-weight:bold;font-size:12px;">🛠️</span>
<button onclick="execCmd('bold')" title="粗体" style="background:#333;color:#fff;border:1px solid #666;padding:3px 8px;border-radius:4px;cursor:pointer;font-weight:bold;font-size:12px;">B</button>
<button onclick="execCmd('italic')" title="斜体" style="background:#333;color:#fff;border:1px solid #666;padding:3px 8px;border-radius:4px;cursor:pointer;font-style:italic;font-size:12px;">I</button>
<button onclick="execCmd('underline')" title="下划线" style="background:#333;color:#fff;border:1px solid #666;padding:3px 8px;border-radius:4px;cursor:pointer;text-decoration:underline;font-size:12px;">U</button>
<select id="editor-font-family" onchange="applyFontFamily()" title="字体" style="background:#333;color:#fff;border:1px solid #666;padding:2px 4px;border-radius:4px;font-size:11px;max-width:110px;">
<option value="">字体</option>
<option value="system-ui, -apple-system, sans-serif">系统默认</option>
<option value="'Microsoft YaHei', sans-serif">微软雅黑</option>
<option value="'PingFang SC', sans-serif">苹方</option>
<option value="SimSun, serif">宋体</option>
<option value="SimHei, sans-serif">黑体</option>
<option value="'Noto Sans SC', sans-serif">Noto Sans</option>
<option value="Consolas, monospace">Consolas 等宽</option>
<option value="'Courier New', monospace">Courier</option>
</select>
<select id="editor-font-weight" onchange="applyFontWeight()" title="字重" style="background:#333;color:#fff;border:1px solid #666;padding:2px 4px;border-radius:4px;font-size:11px;width:60px;">
<option value="">字重</option>
<option value="100">100 细</option>
<option value="300">300 轻</option>
<option value="400">400 常规</option>
<option value="500">500 中</option>
<option value="600">600 半粗</option>
<option value="700">700 粗</option>
<option value="900">900 超粗</option>
</select>
<select id="editor-font-size" onchange="applyFontSize()" title="字号" style="background:#333;color:#fff;border:1px solid #666;padding:2px 4px;border-radius:4px;font-size:11px;width:55px;">
<option value="">字号</option>
<option value="9">9px</option>
<option value="11">11px</option>
<option value="12">12px</option>
<option value="13">13px</option>
<option value="14">14px</option>
<option value="15">15px</option>
<option value="16">16px</option>
<option value="18">18px</option>
<option value="20">20px</option>
<option value="24">24px</option>
<option value="28">28px</option>
<option value="32">32px</option>
<option value="36">36px</option>
<option value="48">48px</option>
</select>
<label title="字色" style="color:#ccc;font-size:11px;display:inline-flex;align-items:center;gap:2px;">🎨<input type="color" id="editor-font-color" value="#333333" onchange="applyFontColor()" style="width:22px;height:22px;border:none;cursor:pointer;padding:0;"></label>
<div style="flex:1;"></div>
<button onclick="previewHTML()" style="background:#3F51B5;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px;">👁️ 预览</button>
<button onclick="exportHTML()" style="background:#00B894;color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;font-weight:bold;font-size:11px;">✅ 生成</button>
<button onclick="closeEditor()" style="background:#E17055;color:#fff;border:none;padding:3px 8px;border-radius:4px;cursor:pointer;font-size:11px;">❌</button>
</div>
<script>
let editMode = false;
function enableEditor() {{
editMode = true;
document.getElementById('editor-bar').style.display = 'flex';
document.querySelectorAll('[data-field]').forEach(el => {{
el.setAttribute('contenteditable', 'true');
el.classList.add('edit-text');
}});
// Mark images as clickable
document.querySelectorAll('img.editable-img').forEach(img => {{
img.style.outline = '2px dashed #FF6584';
img.style.outlineOffset = '2px';
img.style.cursor = 'pointer';
}});
}}
function closeEditor() {{
editMode = false;
document.getElementById('editor-bar').style.display = 'none';
document.querySelectorAll('[data-field]').forEach(el => {{
el.removeAttribute('contenteditable');
el.classList.remove('edit-text');
}});
document.querySelectorAll('img.editable-img').forEach(img => {{
img.style.outline = '';
img.style.cursor = '';
}});
}}
function execCmd(cmd) {{ document.execCommand(cmd); }}
function previewHTML() {{
const clone = document.querySelector('.grid-card').cloneNode(true);
clone.querySelectorAll('[contenteditable]').forEach(el => el.removeAttribute('contenteditable'));
clone.querySelectorAll('.edit-text').forEach(el => {{ el.style.border = ''; el.style.background = ''; }});
const w = window.open('', '_blank');
w.document.write('<!DOCTYPE html>\\n' + clone.outerHTML);
w.document.close();
}}
// Get the currently focused editable element
function getActiveEditorElement() {{
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {{
let node = sel.getRangeAt(0).startContainer;
while (node && node.nodeType === 3) node = node.parentNode;
return node ? node.closest('[data-field]') || node.closest('[contenteditable]') : null;
}}
return null;
}}
function applyFontFamily() {{
const el = getActiveEditorElement();
if (!el) return;
el.style.fontFamily = document.getElementById('editor-font-family').value;
}}
function applyFontWeight() {{
const el = getActiveEditorElement();
if (!el) return;
el.style.fontWeight = document.getElementById('editor-font-weight').value;
}}
function applyFontSize() {{
const el = getActiveEditorElement();
if (!el) return;
el.style.fontSize = document.getElementById('editor-font-size').value;
}}
function applyFontColor() {{
const el = getActiveEditorElement();
if (!el) return;
el.style.color = document.getElementById('editor-font-color').value;
}}
function replaceImage(imgEl) {{
const url = prompt('输入图片URL(或留空用占位图):', imgEl.src || '');
if (url === null) return;
if (url.trim() === '') {{
imgEl.src = 'data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%27200%27 height=%27200%27%3E%3Crect fill=%27%23ddd%27 width=%27200%27 height=%27200%27/%3E%3Ctext x=%2750%25%27 y=%2750%25%27 text-anchor=%27middle%27 fill=%27%23999%27 font-size=%2716%27%3E点击换图%3C/text%3E%3C/svg%3E';
}} else {{
imgEl.src = url;
}}
}}
// Make images clickable
document.addEventListener('click', function(e) {{
const target = e.target.closest('img.editable-img');
if (target && editMode) {{
e.preventDefault();
e.stopPropagation();
replaceImage(target);
}}
}});
// Drag & drop image onto editable-img elements
document.addEventListener('dragover', function(e) {{
const target = e.target.closest('img.editable-img');
if (target && editMode) {{
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
target.style.outline = '3px solid #00B894';
}}
}});
document.addEventListener('dragleave', function(e) {{
const target = e.target.closest('img.editable-img');
if (target) {{
target.style.outline = '2px dashed #FF6584';
}}
}});
document.addEventListener('drop', function(e) {{
e.preventDefault();
const target = e.target.closest('img.editable-img');
if (!target || !editMode) return;
const file = e.dataTransfer.files[0];
if (!file || !file.type.startsWith('image/')) {{
alert('请拖入图片文件');
return;
}}
// Read file as data URL
const reader = new FileReader();
reader.onload = function(ev) {{
target.src = ev.target.result;
target.style.outline = '2px dashed #FF6584';
console.log('[Editor] 图片已通过拖放替换');
}};
reader.readAsDataURL(file);
}});
function exportHTML() {{
// Generate clean HTML by cloning and stripping editing UI
const clone = document.querySelector('.grid-card').cloneNode(true);
clone.querySelectorAll('[contenteditable]').forEach(el => el.removeAttribute('contenteditable'));
clone.querySelectorAll('.edit-text').forEach(el => {{
el.style.border = '';
el.style.background = '';
}});
const cleanHtml = '<!DOCTYPE html>\\n' + clone.outerHTML;
const blob = new Blob([cleanHtml], {{type:'text/html'}});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'final.html';
a.click();
alert('✅ 最终HTML已生成!');
closeEditor();
}}
document.addEventListener('keydown', e => {{
if (e.ctrlKey && e.key === 'e') {{ e.preventDefault(); editMode ? closeEditor() : enableEditor(); }}
}});
console.log('💡 提示:按 Ctrl+E 进入/退出编辑模式 | 点击图片输入链接 | 拖拽图片文件到图片上替换');
</script>
<!-- Custom scripts from spec -->
{custom_scripts}
</body>
</html>"""
# Post-generation audit (pass silent for initial generation)
audit_passed = print_audit_report(html, template_spec, silent=True)
if not audit_passed:
print("[WARN] 审计发现严重问题,请检查生成的 HTML")
print_generation_guide(template_spec)
return html
def print_generation_guide(spec=None):
"""强制输出的生成说明 — 每次生成后必须调用"""
name = spec.get("name", "") if spec else ""
grid = spec.get("grid", {}) if spec else {}
n_cells = len(grid.get("cells", []))
rs = grid.get("rows", "?")
cs = grid.get("cols", "?")
style_preset = spec.get("style_preset", "") if spec else ""
lines = []
lines.append("=" * 58)
if name:
lines.append(f" [OK] 生成完成: {name}")
else:
lines.append(" [OK] HTML 生成完成")
lines.append("=" * 58)
lines.append("")
lines.append(" [编辑] 浏览器打开 HTML 后按 Ctrl+E 进入编辑模式:")
lines.append(" 文字 -> 点击直接编辑 图片 -> 点击输入URL / 拖放文件替换")
lines.append(" 字体 -> 选字体家族/字重 字号/字色 -> 调字号/选颜色")
lines.append(" 完成 -> Ctrl+S 或点 [生成] 按钮")
lines.append("")
lines.append(" [创作模式]")
lines.append(" 骨架创作 -> 定 NxM 网格 -> 选模块放入格子 -> 填充内容")
lines.append(" 自由创作 -> AI 参考模块库直接写 HTML -> 填充内容")
lines.append("")
lines.append(" [审计] 两种模式均自动执行 -- 保证:")
lines.append(" 1. HTML 结构完整 (DOCTYPE/标签平衡/闭合)")
lines.append(" 2. 网格定义正确 (无越界/无重叠)")
lines.append(" 3. 编辑功能可用 (data-field 标记/svg占位图)")
lines.append(" 4. 渲染无风险 (backdrop-filter 裁剪/背景色异常)")
lines.append(" 5. 与技能要求一致 (模块引用有效/CSS平衡)")
lines.append("")
lines.append(" [可用资源] (命令行)")
lines.append(" --list-templates 查看预置方案模板")
lines.append(" --list-modules 查看模块列表")
lines.append(" --list-presets 查看样式预设")
lines.append(" --save-as <名> 固化当前方案为用户模板")
lines.append(" --export-interfaces 导出接口定义供 AI 参考")
lines.append("")
if style_preset:
lines.append(f" [当前样式] {style_preset}")
lines.append(f" [当前网格] {rs}x{cs}, {n_cells} 格")
lines.append("=" * 58)
msg = "\n" + "\n".join(lines) + "\n"
try:
print(msg)
except UnicodeEncodeError:
# Fallback for terminals without full Unicode support
print(msg.encode("ascii", errors="replace").decode("ascii"))
# ══════════════════════════════════════════════════════
# 主入口
# ══════════════════════════════════════════════════════
def list_modules():
"""Print all available modules (base + composite)"""
print("=== Base Modules (CSS Primitives) ===")
for name, info in BASE_MODULES.items():
cat = "css"
desc = info.get("desc", "")
print(f" base:{name} — {desc} [{cat}]")
print("\n=== Composite Modules (可复用组件) ===")
for name, info in COMPOSITE_MODULES.items():
bases = info.get("base", [])
base_str = ", ".join(bases) if bases else "—"
print(f" composite:{name} — {info['desc']}")
print(f" 引用 base: {base_str}")
def list_templates():
"""Print all built-in templates"""
print("=== 内置模板 (Grid Spec) ===")
for name, spec in BUILTIN_TEMPLATES.items():
grid = spec.get("grid", {})
cells = grid.get("cells", [])
# For file-based templates, try to load the actual file for accurate info
file_name = spec.get("file", "")
if file_name:
file_path = SKILL_DIR / file_name
if file_path.exists():
try:
with open(file_path, "r", encoding="utf-8") as f:
file_spec = json.load(f)
file_grid = file_spec.get("grid", {})
file_cells = file_grid.get("cells", [])
grid = file_grid
cells = file_cells
except Exception:
pass
source = spec.get("source", "")
src_info = f" [来源: {source}]" if source else ""
has_js = spec.get("file", "").endswith(".json") and cells
js_tag = " [交互JS]" if has_js else ""
print(f" {name}: {spec['name']} ({grid.get('rows','?')}×{grid.get('cols','?')}, {len(cells)} cells){src_info}{js_tag}")
print(f" {spec['desc']}")
# Also list user templates
list_user_templates()
def load_grid_spec(spec_path_or_name):
"""Load grid spec from file or built-in template name"""
p = Path(spec_path_or_name)
if p.exists():
spec = safe_read_json(p)
if spec is None:
sys.exit(1)
return spec
# Check built-in templates
if spec_path_or_name in BUILTIN_TEMPLATES:
entry = BUILTIN_TEMPLATES[spec_path_or_name]
# Support file-based built-in templates (e.g., the interactive calendar)
if "file" in entry:
file_path = SKILL_DIR / entry["file"]
if file_path.exists():
spec = safe_read_json(file_path)
if spec is None:
show_error("模板错误", f"内置模板 '{spec_path_or_name}' 引用的文件损坏: {file_path}")
sys.exit(1)
# Merge card_style and other metadata from the entry
for key in ["name", "desc", "source", "card_style"]:
if key in entry and key not in spec:
spec[key] = entry[key]
return spec
else:
show_error("文件错误", f"内置模板 '{spec_path_or_name}' 引用的文件不存在: {file_path}",
"这可能是技能安装不完整。尝试重新安装 hug-html 技能。")
# Fallback: use built-in entry without external file
return entry
return entry
# Check user templates directory
user_file = USER_TEMPLATES_DIR / f"{spec_path_or_name}.json"
if user_file.exists():
spec = safe_read_json(user_file)
if spec:
return spec
show_error("模板错误", f"用户模板 '{spec_path_or_name}' 解析失败",
f"请检查文件格式: {user_file}")
# Not found anywhere — print helpful message and exit
msg_lines = [
"",
"[模板/文件错误] 找不到模板或 Spec 文件: " + spec_path_or_name,
" 可用内置模板:",
]
for t in BUILTIN_TEMPLATES:
msg_lines.append(f" {t}")
user_files = sorted(USER_TEMPLATES_DIR.glob("*.json"))
if user_files:
msg_lines.append(" 用户自定义模板:")
for f in user_files:
msg_lines.append(f" {f.stem}")
msg_lines.append(" [提示] 使用 --list-templates 查看所有模板, 使用 --list-modules 查看模块")
try:
print(f"\n❌ 找不到模板或 Spec 文件: {spec_path_or_name}")
print(" 可用内置模板:")
for t in BUILTIN_TEMPLATES:
print(f" {t}")
user_files = sorted(USER_TEMPLATES_DIR.glob("*.json"))
if user_files:
print(" 用户自定义模板:")
for f in user_files:
print(f" {f.stem}")
print(" 💡 提示: 使用 --list-templates 查看所有模板, 使用 --list-modules 查看模块")
except UnicodeEncodeError:
print("\n".join(msg_lines))
sys.exit(1)
def save_template(template_spec, name):
"""Save template spec to scripts/templates/ directory (built-in)"""
BUILTIN_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
out = BUILTIN_TEMPLATES_DIR / f"{name}.json"
try:
content = json.dumps(template_spec, ensure_ascii=False, indent=2)
if safe_write_text(out, content, f"模板 {name}"):
print(f"[OK] 模板已保存: {out}")
return str(out)
except Exception as e:
show_error("文件错误", f"保存模板失败: {e}")
return ""
def save_user_template(template_spec, name, description=""):
"""Save a grid spec as a user-defined template (方案模板)"""
USER_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
spec = dict(template_spec)
spec["_type"] = "user_template"
spec["_version"] = 1
if description:
spec["desc"] = description
if "name" not in spec or not spec.get("name"):
spec["name"] = name
out = USER_TEMPLATES_DIR / f"{name}.json"
# Version check - don't overwrite without incrementing
if out.exists():
try:
with open(out, "r", encoding="utf-8") as f:
existing = json.load(f)
spec["_version"] = existing.get("_version", 1) + 1
except Exception:
spec["_version"] = 1
try:
content = json.dumps(spec, ensure_ascii=False, indent=2)
if safe_write_text(out, content, f"用户模板 {name}"):
print(f"[OK] 方案模板已固化: {out}")
print(f" 名称: {name}")
print(f" 版本: v{spec['_version']}")
grid = spec.get("grid", {})
print(f" 网格: {grid.get('rows','?')}×{grid.get('cols','?')}, {len(grid.get('cells',[]))} cells")
return str(out)
except Exception as e:
show_error("文件错误", f"保存用户模板失败: {e}")
return ""
def list_user_templates():
"""List all user-defined templates"""
USER_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
files = sorted(USER_TEMPLATES_DIR.glob("*.json"))
if not files:
print(" (暂无用户自定义方案模板)")
return
print("=== 用户方案模板 ===")
for f in files:
try:
with open(f, "r", encoding="utf-8") as fh:
spec = json.load(fh)
name = spec.get("name", f.stem)
desc = spec.get("desc", "")
grid = spec.get("grid", {})
ver = spec.get("_version", 1)
n_cells = len(grid.get("cells", []))
print(f" {f.stem}: {name} (v{ver}, {grid.get('rows','?')}×{grid.get('cols','?')}, {n_cells} cells)")
if desc:
print(f" {desc}")
except Exception:
print(f" {f.stem}: (文件无法解析)")
def export_interfaces():
"""Export complete interface specification as JSON for LLM consumption"""
return {
"_schema_version": "2.0.0",
"interfaces": {
"grid_spec": {
"desc": "方案模板 / Grid Spec — 骨架结构+骨架样式+模块分配的完整定义",
"schema": {
"name": "模板名称",
"desc": "模板描述",
"style_preset": "可选: business / academic / festive / mourning / tech",
"card_style": {
"max_width": "卡片最大宽度, 如'400px'",
"bg": "背景色/渐变, 如 '#ffffff' / 'rgba(...)' / 'linear-gradient(...)'",
"border_radius": "圆角, 如 '36px'",
"shadow": "阴影CSS, 如 '0 4px 12px rgba(0,0,0,0.08)'",
"padding": "内边距, 如 '24px 20px'",
"backdrop": "毛玻璃: 'blur(25px)' (可选)",
"webkit_backdrop": "毛玻璃WebKit: 'blur(25px)' (可选)",
"border": "边框, 如 '1px solid rgba(...)' (可选)"
},
"grid": {
"rows": "行数 (int)",
"cols": "列数 (int)",
"gap": "格子间距, 如 '8px'",
"cells": [
{
"id": "单元格唯一ID",
"row": "行索引 (0-based)",
"col": "列索引 (0-based)",
"rowspan": "跨行数 (可选, 默认1)",
"colspan": "跨列数 (可选, 默认1)",
"module": "引用的复合模块, 如 'composite:header-entity'",
"html": "直接HTML内容 (与module二选一)",
"style": "单元格级CSS覆盖, 如 {'background':'#f5f5f5', 'padding':'16px'}"
}
]
},
"scripts": "自定义JavaScript (可选)"
}
},
"base_module": {
"desc": "基础模块 / Base — CSS原语,作用于具体元素",
"format": "base:模块名",
"examples": {
"base:font-size-xl": "大标题 28px Semibold",
"base:color-dark": "深色文字 #1a2a3a",
"base:bg-gradient-purple": "粉紫渐变背景",
"base:radius-lg": "大圆角 24px",
"base:pad-md": "中间距 16x20",
"base:shadow-glass": "毛玻璃阴影",
"base:img-circle": "圆形图片裁剪",
"base:flex-center": "Flex居中",
"base:anim-fade": "淡入动画"
}
},
"composite_module": {
"desc": "复合模块 / Composite — 可复用HTML组件",
"format": "composite:模块名",
"list": {k: v["desc"] for k, v in COMPOSITE_MODULES.items()}
},
"style_preset": {
"desc": "样式预设 — 一键切换配色和字体",
"list": {k: v["name"] for k, v in STYLE_PRESETS.items()}
}
}
}
# ══════════════════════════════════════════════════════
# 生成后审计
# ══════════════════════════════════════════════════════
def audit_html(html_str, template_spec=None):
"""
生成后HTML审查:检查结构完整性和常见问题。
返回 (passed: bool, issues: list)
"""
issues = []
# 1. 文档结构
if '<!DOCTYPE html>' not in html_str:
issues.append("[CRITICAL] 缺少 DOCTYPE 声明")
if '<html' not in html_str.lower():
issues.append("[CRITICAL] 缺少 <html> 标签")
if '</html>' not in html_str:
issues.append("[CRITICAL] 缺少 </html> 关闭标签")
if '<body' not in html_str.lower():
issues.append("[CRITICAL] 缺少 <body> 标签")
if '</body>' not in html_str:
issues.append("[WARN] 缺少 </body> 关闭标签")
# 2. 标签平衡(排除 script/style 内容内的标签,用正则精确匹配)
body_only = html_str.split("<body")[-1].split("</body>")[0] if "</body>" in html_str else html_str
clean_body = re.sub(r'<script>.*?</script>', '', body_only, flags=re.DOTALL)
clean_body = re.sub(r'<style>.*?</style>', '', clean_body, flags=re.DOTALL)
for tag in ['div', 'table', 'tr', 'td', 'th', 'h1', 'h2', 'h3', 'p', 'span', 'ul', 'li', 'a', 'button']:
opens = len(re.findall(rf'<{tag}(\s|>|/>)', clean_body))
closes = len(re.findall(rf'</{tag}>', clean_body))
diff = opens - closes
if abs(diff) > 2:
issues.append(f"[WARN] 标签 <{tag}> 可能不平衡 (开={opens}, 关={closes}, 差={diff})")
# 3. data-field / data-module 引用
if template_spec:
cells = get_cells(template_spec)
for cell in cells:
module_name = cell.get("module", "")
cell_id = cell.get("id", "")
if module_name.startswith("composite:"):
mname = module_name.split(":", 1)[1]
if mname not in COMPOSITE_MODULES:
issues.append(f"[WARN] 单元格 '{cell_id}' 引用了未知模块 '{module_name}'")
# 4. 检查 <img> 标签
img_tags = html_str.count('<img')
img_alts = len(re.findall(r'<img[^>]*alt=', html_str))
if img_tags > 0 and img_alts < img_tags:
issues.append(f"[WARN] 有 {img_tags - img_alts}/{img_tags} 个 <img> 缺少 alt 属性")
# 5. 检查内联样式中的可疑属性
suspicious = []
for pat in ['position:absolute', 'position:fixed', 'z-index:9999']:
if pat in html_str.lower():
suspicious.append(pat)
if suspicious:
issues.append(f"[INFO] 检测到定位属性: {', '.join(suspicious)}(编辑工具栏使用,正常)")
# 6. 检查样式块是否有效
style_blocks = re.findall(r'<style>(.*?)</style>', html_str, re.DOTALL)
for i, block in enumerate(style_blocks):
if not block.strip():
issues.append(f"[WARN] 样式块 #{i+1} 为空")
# Check for unbalanced braces
opens = block.count('{')
closes = block.count('}')
if opens != closes:
issues.append(f"[WARN] 样式块 #{i+1} 花括号不平衡 ({{={opens}, }}={closes})")
# 7. 检查脚本块
script_blocks = re.findall(r'<script>(.*?)</script>', html_str, re.DOTALL)
for i, block in enumerate(script_blocks):
if not block.strip():
issues.append(f"[WARN] 脚本块 #{i+1} 为空")
# 8. 检查 meta viewport
if '<meta name="viewport"' not in html_str:
issues.append("[WARN] 缺少 viewport meta 标签")
# 9. Grid spec 验证(仅当 template_spec 提供时)
if template_spec:
grid = template_spec.get("grid", {})
rows = grid.get("rows", 0)
cols = grid.get("cols", 0)
cells = grid.get("cells", [])
if rows > 0 and cols > 0 and cells:
# 9a. 检查单元格是否越界
occupied = {}
for cell in cells:
r = cell.get("row", 0)
c = cell.get("col", 0)
rs = cell.get("rowspan", 1)
cs = cell.get("colspan", 1)
if r + rs > rows:
issues.append(f"[WARN] 单元格 '{cell.get('id','?')}' rowspan({rs}) 超出网格边界({rows}行)")
if c + cs > cols:
issues.append(f"[WARN] 单元格 '{cell.get('id','?')}' colspan({cs}) 超出网格边界({cols}列)")
# 9b. 检查单元格是否重叠
for rr in range(r, r + rs):
for cc in range(c, c + cs):
key = f"{rr},{cc}"
if key in occupied:
issues.append(f"[CRITICAL] 单元格 '{cell.get('id','?')}' 与 '{occupied[key]}' 在位置 ({rr},{cc}) 重叠")
else:
occupied[key] = cell.get("id", "?")
# 10. 渲染风险检查
# 10a. backdrop-filter 但无 overflow hidden → 内容可能裁剪
if "backdrop-filter" in html_str and "overflow: hidden" not in html_str:
issues.append("[WARN] backdrop-filter 使用但无 overflow:hidden,可能导致内容裁剪")
# 10b. body 背景色异常
body_bg_match = re.search(r'body\s*\{[^}]*background\s*:\s*(#[0-9a-fA-F]{3,6}|rgba?\([^)]+\))', html_str, re.DOTALL)
if body_bg_match:
bg = body_bg_match.group(1)
if bg in ("#000", "#000000") and "backdrop-filter" not in html_str:
issues.append("[INFO] body 背景为纯黑色,非毛玻璃模板建议用浅色背景")
elif bg.startswith("rgba") and "backdrop-filter" not in html_str:
issues.append("[WARN] body 使用半透明背景 bg 但无 backdrop-filter,显示可能不正常")
# 10c. 检查 grid-card 内存在固定定位编辑工具栏 → 正常(INFO)
if "position:fixed" in html_str and "editor-bar" in html_str:
pass # 正常,编辑器工具栏
passed = len([i for i in issues if i.startswith("[CRITICAL]")]) == 0
return passed, issues
def print_audit_report(html_str, template_spec=None, silent=False):
"""Print audit results. silent=True skips printing (for non-interactive use)."""
if silent:
return audit_html(html_str, template_spec)[0]
passed, issues = audit_html(html_str, template_spec)
try:
print("\n=== HTML 生成后审查 ===")
if not issues:
print(" [OK] 全部通过,无问题")
else:
for issue in issues:
print(f" {issue}")
print(f" 结果: {'[PASS] 通过' if passed else '[FAIL] 有严重问题'}")
except UnicodeEncodeError:
# Fallback for terminals that can't handle Unicode
print("\n=== HTML Post-Generation Audit ===")
if not issues:
print(" [OK] All passed")
else:
for issue in issues:
clean = issue.replace("[CRITICAL]", "[ERR]").replace("[WARN]", "[WARN]").replace("[INFO]", "[INFO]")
print(f" {clean}")
print(f" Result: {'[PASS]' if passed else '[FAIL]'}")
return passed
def main():
try:
_main_impl()
except SystemExit as e:
# argparse 自身错误退出或正常退出 → 静默处理
pass
except KeyboardInterrupt:
print("\n⚠️ 用户中断操作")
except Exception as e:
show_error("内部错误", f"程序发生未预期的错误: {type(e).__name__}", "请检查参数是否正确。使用 --help 查看完整参数说明。")
# 仅 debug 模式下输出详细堆栈
if "--debug" in sys.argv:
traceback.print_exc()
def _main_impl():
ap = argparse.ArgumentParser(description="Grid-based HTML Module Engine")
ap.add_argument("--spec", help="Path to grid spec JSON or built-in template name")
ap.add_argument("--output", "-o", help="Output HTML file path")
ap.add_argument("--list-modules", action="store_true", help="List all available modules")
ap.add_argument("--list-templates", action="store_true", help="List all built-in templates")
ap.add_argument("--demo", action="store_true", help="Quick demo mode")
ap.add_argument("--template", help="Template name for demo")
ap.add_argument("--save", help="Save built-in template to templates/ directory")
ap.add_argument("--show-css", action="store_true", help="Show base module CSS for reference")
ap.add_argument("--audit", help="Audit an existing HTML file", metavar="FILE")
ap.add_argument("--list-presets", action="store_true", help="List available style presets")
ap.add_argument("--save-as", help="Save current grid spec as user template (方案模板固化)", metavar="NAME")
ap.add_argument("--list-user-templates", action="store_true", help="List user-defined templates")
ap.add_argument("--export-interfaces", help="Export complete interface spec as JSON file", metavar="FILE")
ap.add_argument("--desc", help="Description for --save-as", default="")
ap.add_argument("--debug", action="store_true", help="显示详细错误堆栈(调试用)")
args = ap.parse_args()
if args.list_modules:
list_modules()
return
if args.list_templates:
list_templates()
return
if args.show_css:
print("=== Base Module CSS Reference ===")
for name, info in BASE_MODULES.items():
css_str = css_dict_to_str(info["css"])
print(f" base:{name}")
print(f" {css_str}")
return
if args.list_presets:
print("=== 样式预设 (Style Presets) ===")
for name, preset in STYLE_PRESETS.items():
cs = preset["card_style"]
print(f" {name}: {preset['name']}")
print(f" 背景: {cs.get('bg','')}, 圆角: {cs.get('border_radius','')}")
print(f" 字体: {preset.get('font','')}, 主色: {preset.get('primary','')}")
return
if args.audit:
p = Path(args.audit)
if not p.exists():
show_error("文件错误", f"找不到 HTML 文件: {args.audit}")
return
html_str = p.read_text(encoding="utf-8")
print_audit_report(html_str)
return
if args.list_user_templates:
list_user_templates()
return
if args.export_interfaces:
interfaces = export_interfaces()
out = Path(args.export_interfaces)
safe_write_text(out, json.dumps(interfaces, ensure_ascii=False, indent=2), "接口定义")
print(f"[OK] 接口定义已导出: {out}")
print(f" Grid Spec 标准格式: 参考 interfaces.grid_spec.schema")
print(f" 可用 Base 模块: {len(BASE_MODULES)} 个")
print(f" 可用 Composite 模块: {len(COMPOSITE_MODULES)} 个")
print(f" 可用样式预设: {len(STYLE_PRESETS)} 种")
return
# Save-as: 将当前 spec 保存为用户方案模板
if args.save_as:
if args.spec:
spec = load_grid_spec(args.spec)
elif args.demo and args.template in BUILTIN_TEMPLATES:
spec = BUILTIN_TEMPLATES[args.template]
else:
show_error("参数错误", "--save-as 需要 --spec <模板名/路径> 或 --demo --template <内置模板名>",
"用法示例: python scripts/grid_builder.py --save-as my-template --spec harmony-app")
return
save_user_template(spec, args.save_as, args.desc)
print_generation_guide(spec)
return
# Save built-in template
if args.save:
if args.save in BUILTIN_TEMPLATES:
save_template(BUILTIN_TEMPLATES[args.save], args.save)
else:
show_error("模板错误", f"未知模板: {args.save}", f"可用模板: {', '.join(BUILTIN_TEMPLATES.keys())}")
return
# Demo mode
if args.demo:
tpl_name = args.template or "harmony-app"
if tpl_name not in BUILTIN_TEMPLATES:
show_error("模板错误", f"未知模板: {tpl_name}", f"可用模板: {', '.join(BUILTIN_TEMPLATES.keys())}")
return
spec = BUILTIN_TEMPLATES[tpl_name]
out_path = args.output or str(OUTPUT_DIR / f"{tpl_name}.html")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
html = generate_html(spec)
safe_write_text(out_path, html, f"HTML {tpl_name}")
print(f"[OK] 已生成: {out_path}")
print(f" 模板: {spec['name']}")
print(f" 网格: {spec['grid']['rows']}×{spec['grid']['cols']}")
return
# Normal mode: load spec → generate
if not args.spec:
ap.print_help()
return
spec = load_grid_spec(args.spec)
out_path = args.output or str(OUTPUT_DIR / "output.html")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
html = generate_html(spec)
safe_write_text(out_path, html, "HTML 输出")
print(f"[OK] 已生成: {out_path}")
if __name__ == "__main__":
main()