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);
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;
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() {
const newLayer = layer.clone();
newLayer.scale({ x: 1, y: 1 });
newLayer.position({ x: 0, y: 0 });
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);
}
const contentBox = newLayer.getClientRect();
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);
newLayer.position({
x: -contentBox.x + padding,
y: -contentBox.y + padding
});
return { stage: exportStage };
}
function exportToPng() {
if (!stage) return alert('请先生成图片');
const { stage: exportStage } = getFullContentLayer();
try {
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);
}
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-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>
<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>