319f683a16
- 8 categories / 50 generators covering middleware, databases, runtimes, systemd services, system tools, network, monitoring, security - VNC supports XFCE / GNOME / KDE Plasma / MATE / LXQt desktops - Node.js versions 18-26 (incl. current LTS 24 Krypton and current 26) - Live preview, copy / download / multi-script bundle in web UI - Distro-aware (Ubuntu/Debian/CentOS/RHEL/Rocky/Alma/Fedora) - All 50 generators pass bash -n syntax check - Zero pip dependencies (Flask stdlib only)
254 lines
10 KiB
HTML
254 lines
10 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ gen.title }} · Shell-Gen{% endblock %}
|
|
{% block body %}
|
|
<main class="container">
|
|
<nav class="breadcrumb">
|
|
<a href="{{ url_for('index') }}">← 返回首页</a>
|
|
<span class="cat-icon">{{ category_icons.get(gen.category, '📦') }}</span>
|
|
<span class="cat-label">{{ category_labels.get(gen.category, gen.category) }}</span>
|
|
</nav>
|
|
|
|
<header class="gen-header">
|
|
<div class="gen-icon">{{ gen.icon }}</div>
|
|
<div class="gen-meta">
|
|
<h1>{{ gen.title }}</h1>
|
|
<p>{{ gen.description }}</p>
|
|
<div class="gen-tags">
|
|
{% for t in gen.tags %}<span class="tag">#{{ t }}</span>{% endfor %}
|
|
{% for o in gen.os_support %}<span class="os">{{ o }}</span>{% endfor %}
|
|
{% if gen.dangerous %}<span class="dangerous">⚠️ 危险操作</span>{% endif %}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{% if gen.warnings %}
|
|
<div class="warnings">
|
|
<h3>⚠️ 注意事项</h3>
|
|
<ul>
|
|
{% for w in gen.warnings %}<li>{{ w }}</li>{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="grid-2col">
|
|
<section class="form-card">
|
|
<h2>📝 参数</h2>
|
|
<form id="gen-form" data-gen="{{ gen.id }}">
|
|
{% for f in gen.fields %}
|
|
<div class="field" data-group="{{ f.group or '' }}">
|
|
<label for="f-{{ f.name }}">
|
|
{{ f.label }}
|
|
{% if f.required %}<span class="req">*</span>{% endif %}
|
|
</label>
|
|
{% if f.type == 'select' %}
|
|
<select id="f-{{ f.name }}" name="{{ f.name }}" {% if f.required %}required{% endif %}>
|
|
{% for opt in f.options %}<option value="{{ opt }}" {% if opt == f.default %}selected{% endif %}>{{ opt }}</option>{% endfor %}
|
|
</select>
|
|
{% elif f.type == 'textarea' %}
|
|
<textarea id="f-{{ f.name }}" name="{{ f.name }}" rows="4" placeholder="{{ f.placeholder }}" {% if f.required %}required{% endif %}>{{ f.default }}</textarea>
|
|
{% elif f.type == 'checkbox' %}
|
|
<label class="checkbox">
|
|
<input type="checkbox" id="f-{{ f.name }}" name="{{ f.name }}" value="yes"
|
|
{% if f.default in ('yes', 'true', '1', 'on') %}checked{% endif %}>
|
|
<span>启用</span>
|
|
</label>
|
|
{% elif f.type == 'number' %}
|
|
<input type="number" id="f-{{ f.name }}" name="{{ f.name }}"
|
|
value="{{ f.default }}" placeholder="{{ f.placeholder }}"
|
|
min="{{ f.min or '' }}" max="{{ f.max or '' }}" step="{{ f.step or '' }}"
|
|
{% if f.required %}required{% endif %}>
|
|
{% elif f.type == 'password' %}
|
|
<input type="password" id="f-{{ f.name }}" name="{{ f.name }}"
|
|
value="{{ f.default }}" placeholder="{{ f.placeholder }}"
|
|
autocomplete="new-password">
|
|
{% else %}
|
|
<input type="text" id="f-{{ f.name }}" name="{{ f.name }}"
|
|
value="{{ f.default }}" placeholder="{{ f.placeholder }}"
|
|
{% if f.required %}required{% endif %}>
|
|
{% endif %}
|
|
{% if f.help %}<p class="help">{{ f.help }}</p>{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
<div class="form-actions">
|
|
<button type="submit" class="btn btn-primary" id="btn-preview">👁 预览</button>
|
|
<button type="button" class="btn" id="btn-download">💾 下载 .sh</button>
|
|
<button type="button" class="btn btn-secondary" id="btn-add-bundle">📦 加入套餐</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="preview-card">
|
|
<div class="preview-header">
|
|
<h2>📄 脚本预览</h2>
|
|
<div class="preview-actions">
|
|
<button type="button" class="btn btn-small" id="btn-copy">📋 复制</button>
|
|
<span class="muted" id="line-count">0 行</span>
|
|
</div>
|
|
</div>
|
|
<pre id="script-pre"><code>// 点击「👁 预览」生成脚本</code></pre>
|
|
<div class="copy-toast" id="copy-toast">已复制</div>
|
|
<div id="err-box" class="err-box hidden"></div>
|
|
</section>
|
|
</div>
|
|
|
|
{% if gen.post_steps or gen.verify_steps %}
|
|
<section class="after">
|
|
{% if gen.post_steps %}
|
|
<h3>🛠 安装后操作</h3>
|
|
<ol class="post-steps">
|
|
{% for s in gen.post_steps %}<li><code>{{ s }}</code></li>{% endfor %}
|
|
</ol>
|
|
{% endif %}
|
|
{% if gen.verify_steps %}
|
|
<h3>✅ 验证</h3>
|
|
<ol class="verify-steps">
|
|
{% for s in gen.verify_steps %}<li><code>{{ s }}</code></li>{% endfor %}
|
|
</ol>
|
|
{% endif %}
|
|
</section>
|
|
{% endif %}
|
|
</main>
|
|
|
|
<div class="bundle-drawer" id="bundle-drawer" hidden>
|
|
<div class="bundle-header">
|
|
<h3>📦 套餐 (multi-script bundle)</h3>
|
|
<span id="bundle-count" class="muted">0 项</span>
|
|
<button class="btn btn-small" id="btn-bundle-clear">清空</button>
|
|
<button class="btn btn-small btn-primary" id="btn-bundle-download">下载 .zip</button>
|
|
<button class="btn btn-small" id="btn-bundle-close">✕</button>
|
|
</div>
|
|
<ul id="bundle-list" class="bundle-list"></ul>
|
|
</div>
|
|
|
|
<script>
|
|
const GEN_ID = {{ gen.id | tojson }};
|
|
const BUNDLE_KEY = "shell-gen-bundle";
|
|
|
|
function collectForm() {
|
|
const form = document.getElementById("gen-form");
|
|
const fd = new FormData(form);
|
|
const params = {};
|
|
for (const [k, v] of fd.entries()) {
|
|
if (v !== "" || document.querySelector(`[name="${k}"]`).type === "checkbox") {
|
|
params[k] = v;
|
|
}
|
|
}
|
|
// unchecked checkboxes won't be in fd
|
|
for (const el of form.querySelectorAll('input[type="checkbox"]')) {
|
|
params[el.name] = el.checked ? "yes" : "no";
|
|
}
|
|
return params;
|
|
}
|
|
|
|
async function preview() {
|
|
const params = collectForm();
|
|
const r = await fetch("/api/preview", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({generator: GEN_ID, params})
|
|
});
|
|
const d = await r.json();
|
|
const pre = document.getElementById("script-pre");
|
|
const err = document.getElementById("err-box");
|
|
if (!d.ok) {
|
|
err.textContent = d.error;
|
|
err.classList.remove("hidden");
|
|
pre.textContent = "// 错误: " + d.error;
|
|
return;
|
|
}
|
|
err.classList.add("hidden");
|
|
pre.innerHTML = "<code>" + escapeHtml(d.script) + "</code>";
|
|
document.getElementById("line-count").textContent = d.script.split("\n").length + " 行";
|
|
}
|
|
|
|
document.getElementById("gen-form").addEventListener("submit", e => { e.preventDefault(); preview(); });
|
|
document.getElementById("btn-download").addEventListener("click", async () => {
|
|
const params = collectForm();
|
|
const r = await fetch("/api/download", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({generator: GEN_ID, params})
|
|
});
|
|
if (!r.ok) { alert("下载失败"); return; }
|
|
const blob = await r.blob();
|
|
const a = document.createElement("a");
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = GEN_ID + ".sh";
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
});
|
|
document.getElementById("btn-copy").addEventListener("click", () => {
|
|
const txt = document.getElementById("script-pre").innerText;
|
|
navigator.clipboard.writeText(txt).then(() => {
|
|
const t = document.getElementById("copy-toast");
|
|
t.classList.add("show");
|
|
setTimeout(() => t.classList.remove("show"), 1500);
|
|
});
|
|
});
|
|
|
|
// Bundle drawer
|
|
function loadBundle() {
|
|
try { return JSON.parse(localStorage.getItem(BUNDLE_KEY) || "[]"); } catch { return []; }
|
|
}
|
|
function saveBundle(b) { localStorage.setItem(BUNDLE_KEY, JSON.stringify(b)); renderBundle(); }
|
|
function renderBundle() {
|
|
const list = loadBundle();
|
|
document.getElementById("bundle-count").textContent = list.length + " 项";
|
|
const ul = document.getElementById("bundle-list");
|
|
if (list.length === 0) {
|
|
ul.innerHTML = '<li class="empty">还没有添加任何生成器,点击「📦 加入套餐」加入。</li>';
|
|
return;
|
|
}
|
|
ul.innerHTML = list.map((it, i) => `
|
|
<li>
|
|
<span class="b-id">${escapeHtml(it.generator)}</span>
|
|
<span class="b-title">${escapeHtml(it.title || '')}</span>
|
|
<button class="btn btn-small" data-rm="${i}">移除</button>
|
|
</li>
|
|
`).join("");
|
|
ul.querySelectorAll("[data-rm]").forEach(b => {
|
|
b.addEventListener("click", () => {
|
|
const idx = parseInt(b.dataset.rm, 10);
|
|
const items = loadBundle();
|
|
items.splice(idx, 1);
|
|
saveBundle(items);
|
|
});
|
|
});
|
|
}
|
|
document.getElementById("btn-add-bundle").addEventListener("click", () => {
|
|
const items = loadBundle();
|
|
if (items.some(it => it.generator === GEN_ID)) return alert("已在套餐中");
|
|
const titleMatch = document.querySelector(".gen-header h1")?.textContent || GEN_ID;
|
|
items.push({generator: GEN_ID, params: collectForm(), title: titleMatch});
|
|
saveBundle(items);
|
|
const drawer = document.getElementById("bundle-drawer");
|
|
drawer.hidden = false;
|
|
});
|
|
document.getElementById("btn-bundle-close").addEventListener("click", () => document.getElementById("bundle-drawer").hidden = true);
|
|
document.getElementById("btn-bundle-clear").addEventListener("click", () => saveBundle([]));
|
|
document.getElementById("btn-bundle-download").addEventListener("click", async () => {
|
|
const items = loadBundle();
|
|
if (items.length === 0) return alert("套餐为空");
|
|
const r = await fetch("/api/bundle", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({items})
|
|
});
|
|
if (!r.ok) return alert("打包失败");
|
|
const blob = await r.blob();
|
|
const a = document.createElement("a");
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = "shell-gen-bundle.zip";
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
});
|
|
renderBundle();
|
|
|
|
// initial preview
|
|
preview();
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, c => ({
|
|
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
|
}[c]));
|
|
}
|
|
</script>
|
|
{% endblock %}
|