Импорт пресетов сборок из ZIP-пакета
Крупные сборки раздают готовый комплект настроек архивом. Панель разбирает его сама: находит серверный .ini, песочницу и точки появления, показывает, что нашла (сколько модов, карт, параметров), и только по подтверждению раскладывает файлы в новый или существующий профиль. Порты, пароли и название сервера при этом можно оставить свои — они относятся к машине, а не к сборке; у нового профиля они берутся из активного. Прежние конфиги остаются рядом как .bak, мир не трогается, а импорт в работающий профиль отклоняется: PZ перезаписал бы файлы своими при остановке. Посторонние .ini из архива (настройки модов) за конфиг сервера не принимаются: он опознаётся по знакомым параметрам. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1562,6 +1562,159 @@ $("#profile-add-form").addEventListener("submit", async (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Импорт пресета сборки
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Сборки модов раздают готовый комплект настроек одним архивом. Панель сначала
|
||||
// показывает, что в нём нашла, и только потом что-то пишет: пресет заменяет
|
||||
// конфиг профиля целиком, и делать это вслепую нельзя.
|
||||
let presetSets = [];
|
||||
|
||||
// Файл отправляется дважды — на разбор и на импорт. Держать его на сервере
|
||||
// между запросами не за чем: он уже лежит в браузере.
|
||||
async function postFile(path, form) {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { [CSRF_HEADER]: "1" },
|
||||
credentials: "same-origin",
|
||||
body: form,
|
||||
});
|
||||
if (response.status === 401) {
|
||||
showAuth(false);
|
||||
throw new Error(t("auth.expired"));
|
||||
}
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || t("error.http", { status: response.status }));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function presetFile() {
|
||||
const input = $("#preset-file");
|
||||
return input.files && input.files[0] ? input.files[0] : null;
|
||||
}
|
||||
|
||||
$("#preset-file").addEventListener("change", async () => {
|
||||
const file = presetFile();
|
||||
$("#preset-details").hidden = true;
|
||||
if (!file) return;
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
toast(t("import.reading"));
|
||||
try {
|
||||
const data = await postFile("/api/presets/preview", form);
|
||||
presetSets = data.presets || [];
|
||||
renderPresetSets();
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
|
||||
function renderPresetSets() {
|
||||
if (!presetSets.length) return;
|
||||
|
||||
const picker = $("#preset-set");
|
||||
picker.innerHTML = presetSets
|
||||
.map((set) => `<option value="${escapeHTML(set.name)}">${escapeHTML(set.name)}</option>`)
|
||||
.join("");
|
||||
// Выбор нужен, только когда в архиве несколько наборов.
|
||||
$("#preset-pick").hidden = presetSets.length < 2;
|
||||
|
||||
$("#preset-details").hidden = false;
|
||||
renderPresetSummary();
|
||||
}
|
||||
|
||||
function currentPreset() {
|
||||
const name = $("#preset-set").value;
|
||||
return presetSets.find((set) => set.name === name) || presetSets[0];
|
||||
}
|
||||
|
||||
function renderPresetSummary() {
|
||||
const set = currentPreset();
|
||||
if (!set) return;
|
||||
|
||||
const lines = [];
|
||||
if (set.server_name) lines.push(t("import.server", { name: set.server_name }));
|
||||
if (set.has_ini) {
|
||||
lines.push(t("import.mods", { mods: set.mods, workshop: set.workshop_items }));
|
||||
if ((set.maps || []).length) lines.push(t("import.maps", { maps: set.maps.join(", ") }));
|
||||
} else {
|
||||
lines.push(t("import.noINI"));
|
||||
}
|
||||
lines.push(t("import.counts", { settings: set.settings, sandbox: set.sandbox_settings }));
|
||||
lines.push(t(set.has_spawn_regions ? "import.spawnYes" : "import.spawnNo"));
|
||||
|
||||
$("#preset-summary").replaceChildren(...lines.map((line) => {
|
||||
const el = document.createElement("div");
|
||||
el.textContent = line;
|
||||
return el;
|
||||
}));
|
||||
|
||||
// Идентификатор профиля предлагаем по имени файлов из архива: обычно оно и
|
||||
// называет сборку.
|
||||
$("#preset-id").value = set.name.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 32);
|
||||
$("#preset-profile").innerHTML = profiles.list
|
||||
.map((p) => `<option value="${escapeHTML(p.id)}">${escapeHTML(p.title || p.id)}</option>`)
|
||||
.join("");
|
||||
$("#preset-profile").value = profiles.active;
|
||||
}
|
||||
|
||||
$("#preset-set").addEventListener("change", renderPresetSummary);
|
||||
|
||||
$("#preset-target").addEventListener("change", () => {
|
||||
const existing = $("#preset-target").value === "existing";
|
||||
$("#preset-new-fields").hidden = existing;
|
||||
$("#preset-existing-fields").hidden = !existing;
|
||||
});
|
||||
|
||||
$("#btn-preset-import").addEventListener("click", async () => {
|
||||
const file = presetFile();
|
||||
const set = currentPreset();
|
||||
if (!file || !set) {
|
||||
toast(t("import.noFile"), "err");
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = $("#preset-target").value === "existing";
|
||||
const profile = existing ? $("#preset-profile").value : $("#preset-id").value.trim();
|
||||
if (!confirm(t("import.confirm", { name: set.name, profile }))) return;
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("preset", set.name);
|
||||
form.append("target", existing ? "existing" : "new");
|
||||
form.append("profile", profile);
|
||||
form.append("title", set.server_name || set.name);
|
||||
form.append("keep", $$("#preset-details [data-keep]")
|
||||
.filter((box) => box.checked)
|
||||
.map((box) => box.dataset.keep)
|
||||
.join(","));
|
||||
|
||||
const button = $("#btn-preset-import");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await postFile("/api/presets/import", form);
|
||||
ok(t("import.done", { profile: result.profile, files: (result.files || []).join(", ") }));
|
||||
if ((result.kept || []).length) {
|
||||
toast(t("import.kept", { profile: result.kept_from, keys: result.kept.join(", ") }));
|
||||
}
|
||||
$("#preset-file").value = "";
|
||||
$("#preset-details").hidden = true;
|
||||
await loadProfiles();
|
||||
// Импортированный профиль мог оказаться активным — вкладки показывают
|
||||
// именно его конфиг.
|
||||
loadTab(currentTab);
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Бэкапы
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -299,6 +299,33 @@ const STRINGS = {
|
||||
"profiles.copyNone": "не копировать",
|
||||
"profiles.create": "Создать профиль",
|
||||
|
||||
"import.title": "Импорт пресета сборки",
|
||||
"import.fileHint": "ZIP-пакет сборки: панель возьмёт из него серверный .ini, настройки песочницы и точки появления, остальное пропустит.",
|
||||
"import.reading": "Читаю архив…",
|
||||
"import.set": "Набор из архива",
|
||||
"import.server": "Сервер: {name}",
|
||||
"import.mods": "Моды: {mods} · пакеты мастерской: {workshop}",
|
||||
"import.maps": "Карты: {maps}",
|
||||
"import.counts": "Настройки сервера: {settings} · песочница: {sandbox}",
|
||||
"import.spawnYes": "Точки появления: есть",
|
||||
"import.spawnNo": "Точки появления: нет",
|
||||
"import.noINI": "Конфига сервера в наборе нет — импортируются только настройки песочницы.",
|
||||
"import.target": "Куда импортировать",
|
||||
"import.targetNew": "В новый профиль",
|
||||
"import.targetExisting": "Поверх существующего профиля",
|
||||
"import.newID": "Идентификатор нового профиля",
|
||||
"import.profile": "Профиль",
|
||||
"import.overwriteHint": "Конфиги профиля будут заменены, прежние останутся рядом с расширением .bak. Мир не трогается.",
|
||||
"import.keep": "Оставить свои значения вместо тех, что в пресете:",
|
||||
"import.keepPorts": "Порты и RCON",
|
||||
"import.keepAccess": "Пароль сервера и объявляемый IP",
|
||||
"import.keepIdentity": "Имя и описание сервера",
|
||||
"import.apply": "Импортировать",
|
||||
"import.noFile": "Выберите ZIP-пакет сборки",
|
||||
"import.confirm": "Импортировать набор «{name}» в профиль {profile}?\n\nКонфиги профиля будут заменены, прежние останутся рядом с расширением .bak.",
|
||||
"import.done": "Пресет импортирован в профиль {profile}. Записано файлов: {files}.",
|
||||
"import.kept": "Свои значения взяты из профиля {profile}: {keys}",
|
||||
|
||||
"settings.versionTitle": "Версия панели",
|
||||
"settings.title": "Настройки менеджера",
|
||||
"settings.save": "Сохранить настройки",
|
||||
@@ -596,6 +623,33 @@ const STRINGS = {
|
||||
"profiles.copyNone": "do not copy",
|
||||
"profiles.create": "Create profile",
|
||||
|
||||
"import.title": "Import a collection preset",
|
||||
"import.fileHint": "A collection ZIP package: the panel takes the server .ini, the sandbox settings and the spawn regions out of it and skips the rest.",
|
||||
"import.reading": "Reading the archive…",
|
||||
"import.set": "Preset from the archive",
|
||||
"import.server": "Server: {name}",
|
||||
"import.mods": "Mods: {mods} · Workshop items: {workshop}",
|
||||
"import.maps": "Maps: {maps}",
|
||||
"import.counts": "Server settings: {settings} · sandbox: {sandbox}",
|
||||
"import.spawnYes": "Spawn regions: included",
|
||||
"import.spawnNo": "Spawn regions: none",
|
||||
"import.noINI": "The preset has no server config — only the sandbox settings will be imported.",
|
||||
"import.target": "Where to import",
|
||||
"import.targetNew": "Into a new profile",
|
||||
"import.targetExisting": "Over an existing profile",
|
||||
"import.newID": "Identifier of the new profile",
|
||||
"import.profile": "Profile",
|
||||
"import.overwriteHint": "The profile configs will be replaced, the previous ones stay next to them with a .bak extension. The world is left alone.",
|
||||
"import.keep": "Keep your own values instead of the ones from the preset:",
|
||||
"import.keepPorts": "Ports and RCON",
|
||||
"import.keepAccess": "Server password and announced IP",
|
||||
"import.keepIdentity": "Server name and description",
|
||||
"import.apply": "Import",
|
||||
"import.noFile": "Choose a collection ZIP package",
|
||||
"import.confirm": "Import the preset \"{name}\" into the profile {profile}?\n\nThe profile configs will be replaced, the previous ones stay next to them with a .bak extension.",
|
||||
"import.done": "Preset imported into the profile {profile}. Files written: {files}.",
|
||||
"import.kept": "Your own values were taken from the profile {profile}: {keys}",
|
||||
|
||||
"settings.versionTitle": "Panel version",
|
||||
"settings.title": "Manager settings",
|
||||
"settings.save": "Save settings",
|
||||
|
||||
@@ -255,6 +255,53 @@
|
||||
|
||||
<div id="profile-cards" class="cards"></div>
|
||||
|
||||
<h3 data-i18n="import.title"></h3>
|
||||
<div class="card import-card">
|
||||
<p class="muted small" data-i18n="import.fileHint"></p>
|
||||
<input type="file" id="preset-file" accept=".zip">
|
||||
|
||||
<div id="preset-details" hidden>
|
||||
<div class="cfg-field" id="preset-pick" hidden>
|
||||
<label for="preset-set" data-i18n="import.set"></label>
|
||||
<select id="preset-set"></select>
|
||||
</div>
|
||||
|
||||
<div class="preset-summary" id="preset-summary"></div>
|
||||
|
||||
<div class="fields">
|
||||
<div class="cfg-field">
|
||||
<label for="preset-target" data-i18n="import.target"></label>
|
||||
<select id="preset-target">
|
||||
<option value="new" data-i18n="import.targetNew"></option>
|
||||
<option value="existing" data-i18n="import.targetExisting"></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cfg-field" id="preset-new-fields">
|
||||
<label for="preset-id" data-i18n="import.newID"></label>
|
||||
<input type="text" id="preset-id" autocomplete="off">
|
||||
<div class="hint" data-i18n="profiles.idHint"></div>
|
||||
</div>
|
||||
<div class="cfg-field" id="preset-existing-fields" hidden>
|
||||
<label for="preset-profile" data-i18n="import.profile"></label>
|
||||
<select id="preset-profile"></select>
|
||||
<div class="hint" data-i18n="import.overwriteHint"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="muted small" data-i18n="import.keep"></p>
|
||||
<div class="preset-keep">
|
||||
<label class="checkbox"><input type="checkbox" data-keep="ports" checked>
|
||||
<span data-i18n="import.keepPorts"></span></label>
|
||||
<label class="checkbox"><input type="checkbox" data-keep="access" checked>
|
||||
<span data-i18n="import.keepAccess"></span></label>
|
||||
<label class="checkbox"><input type="checkbox" data-keep="identity" checked>
|
||||
<span data-i18n="import.keepIdentity"></span></label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" id="btn-preset-import" data-i18n="import.apply"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 data-i18n="profiles.newTitle"></h3>
|
||||
<form id="profile-add-form" class="fields">
|
||||
<div class="cfg-field">
|
||||
|
||||
@@ -570,3 +570,25 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
|
||||
margin-top: 18px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Импорт пресета: карточка с разбором архива и параметрами импорта. */
|
||||
.import-card { margin-bottom: 20px; }
|
||||
.import-card input[type=file] {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
margin: 4px 0 12px;
|
||||
padding: 8px;
|
||||
color: var(--text);
|
||||
background: var(--bg-soft);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.preset-summary {
|
||||
margin: 10px 0 14px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-soft);
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.preset-keep { display: flex; flex-wrap: wrap; gap: 14px; margin: 6px 0 14px; }
|
||||
|
||||
Reference in New Issue
Block a user