1

5 阅读4分钟
// script.js

// 示例 JSON 数据字符串
const EXAMPLE_JSON = `{
  "name": "智慧社区管理系统",
  "children": [
    {
      "name": "物业管理",
      "children": [
        { "name": "房产信息管理", "children": [] },
        { "name": "住户档案管理", "children": [] },
        { "name": "装修申请审批", "children": [] }
      ]
    },
    {
      "name": "费用收缴",
      "children": [
        { "name": "物业费计算", "children": [] },
        { "name": "水电费抄表", "children": [] },
        { "name": "在线支付接口", "children": [] },
        { "name": "欠费自动提醒", "children": [] }
      ]
    },
    {
      "name": "智能安防",
      "children": [
        { "name": "视频监控中心", "children": [] },
        { "name": "门禁人脸识别", "children": [] },
        { "name": "电子巡更管理", "children": [] },
        { "name": "火灾报警联动", "children": [] }
      ]
    },
    {
      "name": "便民服务",
      "children": [
        { "name": "报事报修", "children": [] },
        { "name": "投诉建议", "children": [] },
        { "name": "社区公告发布", "children": [] }
      ]
    }
  ]
}`;

let CONFIG = {
    nodePaddingX: 15,      
    nodePaddingY: 10,      
    levelHeight: 130,      
    siblingGap: 30,        
    fontSize: 14,          
    lineHeightRatio: 1.4,  
    fontFamily: 'Arial, "Microsoft YaHei", sans-serif',
    nodeColor: '#ffffff',
    nodeStroke: '#000000',
    textColor: '#000000',
    lineColor: '#000000',
    lineWidth: 2           
};

let moduleCount = 0;
let stage = null;
let layer = null;

// 初始化
document.addEventListener('DOMContentLoaded', () => {
    addModuleRow(); 
    setupEventListeners();
    document.getElementById('configFontSize').value = CONFIG.fontSize;
    document.getElementById('configLineWidth').value = CONFIG.lineWidth;
    document.getElementById('configLevelHeight').value = CONFIG.levelHeight;
});

function setupEventListeners() {
    document.getElementById('addModuleBtn').addEventListener('click', addModuleRow);
    document.getElementById('generateBtn').addEventListener('click', handleGenerate);
    document.getElementById('exportPngBtn').addEventListener('click', exportToPng);
    document.getElementById('exportSvgBtn').addEventListener('click', exportToSvg);
    
    // JSON 粘贴相关事件
    document.getElementById('parseJsonBtn').addEventListener('click', handleParseJson);
    document.getElementById('fillExampleBtn').addEventListener('click', fillExampleData);
    document.getElementById('clearJsonBtn').addEventListener('click', clearJsonInput);
}

function addModuleRow() {
    const container = document.getElementById('modulesContainer');
    const index = moduleCount++;
    
    const div = document.createElement('div');
    div.className = 'module-row';
    div.id = `module-${index}`;
    
    let defaultName = '';
    let defaultFuncs = '';
    
    if (index === 0) {
        defaultName = '销售计划管理';
        defaultFuncs = '销售历史资料管理,编制年度销售计划';
    } else if (index === 1) {
        defaultName = '合同管理';
        defaultFuncs = '合同有效性审查,合同执行情况分析,合同登记和变更';
    } else if (index === 2) {
        defaultName = '销售核算与统计';
        defaultFuncs = '销量收入核算,销量利润核算,销量统计分析';
    }

    div.innerHTML = `
        <button class="btn-remove" onclick="removeModule(${index})">删除</button>
        <label>模块名称 (横排):</label>
        <input type="text" class="mod-name" placeholder="模块名称" value="${defaultName}">
        <label>功能列表 (竖排显示,用逗号分隔):</label>
        <input type="text" class="mod-funcs" placeholder="功能 1,功能 2" value="${defaultFuncs}">
    `;
    container.appendChild(div);
}

window.removeModule = function(index) {
    const el = document.getElementById(`module-${index}`);
    if (el) el.remove();
};

function collectData() {
    const systemName = document.getElementById('systemName').value.trim() || '系统名称';
    const modules = [];
    
    const rows = document.querySelectorAll('.module-row');
    rows.forEach(row => {
        const name = row.querySelector('.mod-name').value.trim();
        const funcsStr = row.querySelector('.mod-funcs').value.trim();
        
        if (name) {
            const children = funcsStr ? funcsStr.split(/[,,、]/).map(s => s.trim()).filter(s => s) : [];
            modules.push({
                name: name,
                children: children.map(c => ({ name: c, children: [] }))
            });
        }
    });

    return { name: systemName, children: modules };
}

function handleGenerate() {
    updateConfigFromInputs();
    const data = collectData();
    if (!data.children || data.children.length === 0) {
        alert('请至少添加一个模块和功能。');
        return;
    }
    drawChart(data);
}

function updateConfigFromInputs() {
    const fontSize = parseInt(document.getElementById('configFontSize').value) || 14;
    const lineWidth = parseInt(document.getElementById('configLineWidth').value) || 2;
    const levelHeight = parseInt(document.getElementById('configLevelHeight').value) || 130;

    CONFIG.fontSize = fontSize;
    CONFIG.lineWidth = lineWidth;
    CONFIG.levelHeight = levelHeight;
    CONFIG.lineHeight = fontSize * CONFIG.lineHeightRatio; 
}

// --- 核心绘图逻辑 ---

function drawChart(rootData) {
    const container = document.getElementById('canvasContainer');
    if (stage) stage.destroy();
    
    const tempCanvas = document.createElement('canvas');
    const ctx = tempCanvas.getContext('2d');
    ctx.font = `${CONFIG.fontSize}px ${CONFIG.fontFamily}`;

    function calculateDimensions(node, level) {
        node._level = level;
        let textWidth = 0;
        let textHeight = 0;
        // 只有 Level 2 (第三层,即功能点) 竖排
        let isVertical = (level === 2);

        if (isVertical) {
            const chars = node.name.split('');
            let maxCharWidth = 0;
            chars.forEach(char => {
                const m = ctx.measureText(char);
                if (m.width > maxCharWidth) maxCharWidth = m.width;
            });
            textWidth = maxCharWidth;
            textHeight = chars.length * CONFIG.lineHeight;
            if (chars.length === 1) textHeight = CONFIG.fontSize; 
        } else {
            const metrics = ctx.measureText(node.name);
            textWidth = metrics.width;
            textHeight = CONFIG.fontSize;
        }

        const nodeWidth = Math.max(textWidth + (CONFIG.nodePaddingX * 2), isVertical ? 40 : 80);
        const nodeHeight = Math.max(textHeight + (CONFIG.nodePaddingY * 2), isVertical ? 60 : 40);
        
        node._width = nodeWidth;
        node._height = nodeHeight;
        node._isVertical = isVertical;
        
        let subtreeWidth = nodeWidth;

        if (node.children && node.children.length > 0) {
            let childrenTotalWidth = 0;
            node.children.forEach((child, idx) => {
                const childDims = calculateDimensions(child, level + 1);
                childrenTotalWidth += childDims.totalWidth;
                if (idx < node.children.length - 1) {
                    childrenTotalWidth += CONFIG.siblingGap;
                }
            });
            subtreeWidth = Math.max(subtreeWidth, childrenTotalWidth);
        }
        
        node._subtreeWidth = subtreeWidth;
        return { totalWidth: subtreeWidth, height: nodeHeight };
    }

    calculateDimensions(rootData, 0);

    stage = new Konva.Stage({
        container: 'canvasContainer',
        width: container.clientWidth,
        height: container.clientHeight,
        draggable: true
    });

    const scaleBy = 1.1;
    stage.on('wheel', (e) => {
        e.evt.preventDefault();
        const oldScale = stage.scaleX();
        const pointer = stage.getPointerPosition();
        const mousePointTo = {
            x: (pointer.x - stage.x()) / oldScale,
            y: (pointer.y - stage.y()) / oldScale,
        };
        const newScale = e.evt.deltaY > 0 ? oldScale / scaleBy : oldScale * scaleBy;
        stage.scale({ x: newScale, y: newScale });
        const newPos = {
            x: pointer.x - mousePointTo.x * newScale,
            y: pointer.y - mousePointTo.y * newScale,
        };
        stage.position(newPos);
    });

    layer = new Konva.Layer();
    stage.add(layer);

    function drawNode(node, x, y) {
        const w = node._width;
        const h = node._height;
        const nodeX = x - w / 2;
        const nodeY = y;

        if (node.children && node.children.length > 0) {
            const nextY = y + h + CONFIG.levelHeight;
            
            let tempTotalWidth = 0;
            node.children.forEach((c, i) => {
                tempTotalWidth += c._subtreeWidth;
                if (i < node.children.length - 1) tempTotalWidth += CONFIG.siblingGap;
            });

            let startX = x - tempTotalWidth / 2;
            let currentX = startX;

            node.children.forEach(child => {
                const childCenterX = currentX + child._subtreeWidth / 2;
                
                const line = new Konva.Line({
                    points: [
                        x, nodeY + h,          
                        x, nextY - 20,         
                        childCenterX, nextY - 20, 
                        childCenterX, nextY    
                    ],
                    stroke: CONFIG.lineColor,
                    strokeWidth: CONFIG.lineWidth,
                    lineCap: 'round',
                    lineJoin: 'round'
                });
                layer.add(line);

                drawNode(child, childCenterX, nextY);
                currentX += child._subtreeWidth + CONFIG.siblingGap;
            });
        }

        const rect = new Konva.Rect({
            x: nodeX,
            y: nodeY,
            width: w,
            height: h,
            fill: CONFIG.nodeColor,
            stroke: CONFIG.nodeStroke,
            strokeWidth: CONFIG.lineWidth,
            cornerRadius: 4,
            shadowColor: 'rgba(0,0,0,0.1)',
            shadowBlur: 4,
            shadowOffsetX: 1,
            shadowOffsetY: 1
        });
        layer.add(rect);

        let displayText = node.name;
        if (node._isVertical) {
            displayText = node.name.split('').join('\n');
        }

        const text = new Konva.Text({
            x: nodeX,
            y: nodeY,
            width: w,
            height: h,
            text: displayText,
            fontSize: CONFIG.fontSize,
            fontFamily: CONFIG.fontFamily,
            fill: CONFIG.textColor,
            align: 'center',
            verticalAlign: 'middle',
            lineHeight: node._isVertical ? (CONFIG.lineHeight / CONFIG.fontSize) : 1.2
        });
        layer.add(text);
    }

    const startX = stage.width() / 2; 
    const startY = 40;
    drawNode(rootData, startX, startY);
}

// --- 导出功能 (已修复截断问题) ---

function getFullContentLayer() {
    // 1. 克隆图层
    const newLayer = layer.clone();
    
    // 2. 【关键修复】重置变换:消除缩放和平移的影响,获取真实尺寸
    newLayer.scale({ x: 1, y: 1 });
    newLayer.position({ x: 0, y: 0 });
    
    // 3. 创建一个临时 Stage 用于测量和导出
    const tempContainerId = 'temp-export-container';
    let tempDiv = document.getElementById(tempContainerId);
    if (!tempDiv) {
        tempDiv = document.createElement('div');
        tempDiv.id = tempContainerId;
        tempDiv.style.display = 'none';
        document.body.appendChild(tempDiv);
    }

    // 4. 获取真实的内容边界
    const contentBox = newLayer.getClientRect();
    
    // 5. 增加安全边距 (防止阴影被切)
    const padding = 100; 
    const width = Math.ceil(contentBox.width + padding * 2);
    const height = Math.ceil(contentBox.height + padding * 2);

    const exportStage = new Konva.Stage({
        container: tempContainerId,
        width: width,
        height: height
    });
    
    exportStage.add(newLayer);

    // 6. 移动图层,确保所有内容都在正坐标区域 (从 padding 开始)
    newLayer.position({
        x: -contentBox.x + padding,
        y: -contentBox.y + padding
    });
    
    return { stage: exportStage };
}

function exportToPng() {
    if (!stage) return alert('请先生成图片');
    const { stage: exportStage } = getFullContentLayer();
    try {
        // pixelRatio: 2 提高清晰度
        const dataUrl = exportStage.toDataURL({ mimeType: 'image/png', pixelRatio: 2 });
        downloadFile(dataUrl, 'system_architecture.png');
    } catch (e) {
        console.error(e);
        alert('导出 PNG 失败');
    } finally {
        exportStage.destroy();
    }
}

function exportToSvg() {
    if (!stage) return alert('请先生成图片');
    const { stage: exportStage } = getFullContentLayer();
    try {
        const svgString = exportStage.toSVG();
        const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
        const url = URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = url;
        link.download = 'system_architecture.svg';
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        URL.revokeObjectURL(url);
    } catch (e) {
        console.error(e);
        alert('导出 SVG 失败');
    } finally {
        exportStage.destroy();
    }
}

function downloadFile(dataUrl, filename) {
    const link = document.createElement('a');
    link.href = dataUrl;
    link.download = filename;
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
}

// --- JSON 粘贴与解析功能 ---

function fillExampleData() {
    document.getElementById('jsonInput').value = EXAMPLE_JSON;
    showStatus('已填入示例数据,请点击“解析并生成”', '#27ae60');
}

function clearJsonInput() {
    document.getElementById('jsonInput').value = '';
    showStatus('', '');
}

function handleParseJson() {
    const jsonStr = document.getElementById('jsonInput').value.trim();
    if (!jsonStr) {
        showStatus('❌ 请输入 JSON 内容', '#e74c3c');
        return;
    }

    try {
        const data = JSON.parse(jsonStr);
        if (!data.name || !Array.isArray(data.children)) {
            throw new Error("JSON 格式错误:必须包含 'name' 和 'children' 字段。");
        }

        drawChart(data);
        fillInputsFromJson(data);
        showStatus('✅ 解析成功!图表已生成,表单已同步。', '#27ae60');

    } catch (err) {
        console.error(err);
        showStatus('❌ 解析失败:' + err.message, '#e74c3c');
        alert('JSON 格式错误,请检查语法。\n\n错误信息:' + err.message);
    }
}

function showStatus(msg, color) {
    const el = document.getElementById('jsonStatus');
    el.innerText = msg;
    el.style.color = color || '#666';
    if(msg) {
        setTimeout(() => {
            if(el.innerText === msg) el.innerText = '';
        }, 5000);
    }
}

function fillInputsFromJson(data) {
    document.getElementById('systemName').value = data.name || '';
    const container = document.getElementById('modulesContainer');
    const rows = container.querySelectorAll('.module-row');
    rows.forEach(row => row.remove());
    moduleCount = 0;

    if (data.children && Array.isArray(data.children)) {
        data.children.forEach((module) => {
            addModuleRow();
            const row = document.getElementById(`module-${moduleCount - 1}`);
            if (row) {
                row.querySelector('.mod-name').value = module.name || '';
                let funcsStr = '';
                if (module.children && Array.isArray(module.children)) {
                    funcsStr = module.children.map(c => c.name).join(',');
                }
                row.querySelector('.mod-funcs').value = funcsStr;
            }
        });
    }
}

body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 20px; background-color: #f0f2f5; color: #333; }
.container { max-width: 1400px; margin: 0 auto; background: #fff; padding: 30px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
h1 { text-align: center; color: #2c3e50; margin-bottom: 30px; }
h2 { border-left: 5px solid #333; padding-left: 10px; color: #2c3e50; font-size: 1.2rem; }
h3 { font-size: 1rem; color: #555; margin-top: 20px; margin-bottom: 15px; }
.input-section, .canvas-section { margin-bottom: 30px; padding: 20px; background: #f9f9f9; border-radius: 8px; border: 1px solid #eee; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: 600; font-size: 0.9rem; }
input[type="text"], input[type="number"] { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; font-size: 14px; }

/* 样式设置区域 */
.style-settings { background: #eef2f5; padding: 15px; border-radius: 6px; border: 1px solid #dde2e6; margin-bottom: 20px; }
.settings-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }

/* JSON 粘贴区域样式 */
.json-paste-section { 
    background: #fff; 
    padding: 15px; 
    border: 1px dashed #3498db; 
    border-radius: 6px; 
    margin-bottom: 20px; 
    box-shadow: 0 2px 5px rgba(52, 152, 219, 0.1);
}
#jsonInput {
    width: 100%;
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
    font-family: 'Consolas', 'Monaco', monospace;
    font-size: 13px;
    box-sizing: border-box;
    resize: vertical;
    background-color: #fafafa;
}
#jsonInput:focus {
    border-color: #3498db;
    outline: none;
    background-color: #fff;
}

/* 按钮通用样式 */
.btn-group { margin-top: 20px; display: flex; gap: 10px; flex-wrap: wrap; }
button { padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; transition: background 0.2s; color: white; }
.btn-primary { background-color: #333; } .btn-primary:hover { background-color: #555; }
.btn-add { background-color: #27ae60; } .btn-add:hover { background-color: #219150; }
.btn-import { background-color: #3498db; } .btn-import:hover { background-color: #2980b9; }
.btn-secondary { background-color: #95a5a6; } .btn-secondary:hover { background-color: #7f8c8d; }
.btn-danger { background-color: #e74c3c; } .btn-danger:hover { background-color: #c0392b; }
.btn-remove { background-color: #e74c3c; padding: 5px 10px; font-size: 12px; position: absolute; top: 10px; right: 10px; }
.btn-remove:hover { background-color: #c0392b; }
.btn-export { background-color: #95a5a6; margin: 0 5px; } .btn-export:hover { background-color: #7f8c8d; }

/* 模块列表 */
.module-row { background: #fff; border: 1px solid #e0e0e0; padding: 15px; margin-bottom: 15px; border-radius: 6px; position: relative; box-shadow: 0 2px 4px rgba(0,0,0,0.02); }
.module-row input { margin-bottom: 10px; }
.module-row input:last-child { margin-bottom: 0; }

#canvasContainer { width: 100%; height: 600px; background-color: #fff; border: 2px dashed #ccc; border-radius: 4px; overflow: hidden; position: relative; }
.tip { font-size: 12px; color: #888; text-align: center; margin-top: 10px; }

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>系统功能架构图生成器 (粘贴版)</title>
    <link rel="stylesheet" href="styles.css">
    <script src="https://unpkg.com/konva@9.2.0/konva.min.js"></script>
</head>
<body>

<div class="container">
    <h1>系统功能架构图生成器</h1>

    <div class="input-section">
        <h2>1. 输入系统信息</h2>
        
        <!-- 系统名称 -->
        <div class="form-group">
            <label for="systemName">系统名称 (根节点):</label>
            <input type="text" id="systemName" value="销售系统管理" placeholder="例如:销售系统管理">
        </div>

        <!-- 全局样式设置 -->
        <div class="style-settings">
            <h3>⚙️ 全局样式设置</h3>
            <div class="settings-grid">
                <div class="setting-item">
                    <label for="configFontSize">字体大小 (px):</label>
                    <input type="number" id="configFontSize" value="14" min="10" max="40">
                </div>
                <div class="setting-item">
                    <label for="configLineWidth">线条/边框粗细 (px):</label>
                    <input type="number" id="configLineWidth" value="2" min="1" max="10">
                </div>
                <div class="setting-item">
                    <label for="configLevelHeight">层级垂直间距 (px):</label>
                    <input type="number" id="configLevelHeight" value="130" min="80" max="300">
                </div>
            </div>
        </div>

        <!-- 【修改】JSON 粘贴区域 -->
        <div class="json-paste-section">
            <h3>📋 直接粘贴 JSON 数据</h3>
            <p class="tip">在此处粘贴标准的树形 JSON 数据,点击“解析并生成”即可覆盖当前表单。</p>
            <textarea id="jsonInput" rows="6" placeholder='例如:{"name": "系统名", "children": [{"name": "模块 1", "children": [...]}]}'></textarea>
            <div class="btn-group" style="margin-top: 10px; justify-content: flex-start;">
                <button id="parseJsonBtn" class="btn-import">🚀 解析并生成</button>
                <button id="fillExampleBtn" class="btn-secondary">📝 填入示例数据</button>
                <button id="clearJsonBtn" class="btn-danger">🗑️ 清空</button>
            </div>
            <div id="jsonStatus" style="margin-top:8px; font-size:12px; font-weight:bold;"></div>
        </div>

        <!-- 模块列表 (手动编辑区) -->
        <div id="modulesContainer">
            <h3>模块与功能列表 (手动编辑)</h3>
            <!-- 动态添加模块 -->
        </div>

        <div class="btn-group">
            <button id="addModuleBtn" class="btn-add">+ 添加模块</button>
            <button id="generateBtn" class="btn-primary">生成架构图</button>
        </div>
    </div>

    <div class="canvas-section">
        <h2>2. 预览与导出</h2>
        <div id="canvasContainer"></div>
        <div class="export-buttons">
            <button id="exportPngBtn" class="btn-export">导出 PNG</button>
            <button id="exportSvgBtn" class="btn-export">导出 SVG</button>
        </div>
        <p class="tip">提示:鼠标滚轮缩放,拖动平移画布。</p>
    </div>
</div>

<div id="temp-export-container" style="display: none;"></div>

<script src="script.js"></script>
</body>
</html>