diff --git a/README.md b/README.md index 60cfdef..57757eb 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ - **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные ОЗУ и диск на хосте, список игроков онлайн. - **Редактор конфигов** — `servertest.ini` и `SandboxVars.lua` прямо в - браузере, с поиском по параметрам и сохранением комментариев в файле. + браузере: параметры разложены по разделам, у каждого видно описание из + самого конфига, поиск идёт и по названию, и по описанию. - **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка загрузки, добавление по ссылке и разворачивание коллекций Steam целиком. - **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с @@ -192,6 +193,22 @@ Mod ID, который нужен серверу, лежит внутри сам Один пакет мастерской может содержать несколько модов — в списке они идут отдельными строками, и включать их можно по отдельности. +## Редактор конфигов + +Вкладки «Конфиг сервера» и «Песочница» показывают параметры не сплошным +списком, а разделами: «Основное», «PVP и урон», «Убежища», «Античит» и так +далее. Разделы сворачиваются, а поиск сверху раскрывает те, где нашлось +совпадение. Ключи, которых панель не знает (в новой версии игры их всегда +добавляют), не теряются — они собираются в раздел «Прочее». + +Описания параметров панель берёт из самого конфига: игра пишет их +комментариями на языке сервера. Полный текст виден при наведении, начало — +прямо под полем. + +Правка меняет ровно ту строку, которую поправили: комментарии, порядок и +переводы строк файла сохраняются, а рядом остаётся копия прежней версии с +расширением `.bak`. + ## Профили серверов Профиль — это отдельный сервер: свой мир, свой `*.ini`, свои настройки diff --git a/internal/api/handlers_config.go b/internal/api/handlers_config.go index bf5e0b5..17cc471 100644 --- a/internal/api/handlers_config.go +++ b/internal/api/handlers_config.go @@ -32,6 +32,9 @@ func (s *Server) handleGetServerConfig(w http.ResponseWriter, r *http.Request) { "path": path, "keys": ini.Keys(), "values": ini.Map(), + // Игра сама пишет в конфиг описания параметров на языке сервера — + // показываем их, вместо того чтобы придумывать свои. + "descriptions": ini.Comments(), }) } diff --git a/internal/pzconfig/ini.go b/internal/pzconfig/ini.go index 2af1695..62bdfef 100644 --- a/internal/pzconfig/ini.go +++ b/internal/pzconfig/ini.go @@ -16,6 +16,10 @@ type iniLine struct { Key string Value string Raw string + // Comment — описание параметра: строки комментария прямо перед ним. + // Project Zomboid пишет их на языке сервера, так что панели есть что + // показать человеку, не выдумывая своих формулировок. + Comment string } // INI — конфиг PZ в виде плоских key=value. Разбор сохраняет исходный порядок @@ -23,22 +27,46 @@ type iniLine struct { type INI struct { lines []iniLine index map[string]int + // crlf — файл был с виндовыми переводами строк. Сохраняем как было: иначе + // правка одного параметра переписывает весь файл целиком. + crlf bool } // ParseINI разбирает содержимое конфига. func ParseINI(data []byte) *INI { ini := &INI{index: make(map[string]int)} - text := strings.ReplaceAll(string(data), "\r\n", "\n") + raw := string(data) + ini.crlf = strings.Contains(raw, "\r\n") + text := strings.ReplaceAll(raw, "\r\n", "\n") + + // Комментарии копим до ближайшего параметра: в конфигах PZ описание стоит + // строкой выше. Пустая строка обрывает связь — иначе описание прилипло бы + // к следующему блоку. + var comment []string + for _, raw := range strings.Split(text, "\n") { trimmed := strings.TrimSpace(raw) key, value, found := strings.Cut(raw, "=") - if !found || trimmed == "" || strings.HasPrefix(trimmed, "#") { + + if strings.HasPrefix(trimmed, "#") { + comment = append(comment, strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))) ini.lines = append(ini.lines, iniLine{Raw: raw}) continue } + if !found || trimmed == "" { + comment = nil + ini.lines = append(ini.lines, iniLine{Raw: raw}) + continue + } + key = strings.TrimSpace(key) ini.index[key] = len(ini.lines) - ini.lines = append(ini.lines, iniLine{Key: key, Value: strings.TrimSpace(value)}) + ini.lines = append(ini.lines, iniLine{ + Key: key, + Value: strings.TrimSpace(value), + Comment: strings.Join(comment, " "), + }) + comment = nil } return ini } @@ -90,6 +118,17 @@ func (i *INI) Keys() []string { return out } +// Comments возвращает описания параметров: ключ -> текст комментария. +func (i *INI) Comments() map[string]string { + out := make(map[string]string, len(i.index)) + for _, l := range i.lines { + if l.Key != "" && l.Comment != "" { + out[l.Key] = l.Comment + } + } + return out +} + // Map отдаёт все параметры в виде словаря. func (i *INI) Map() map[string]string { out := make(map[string]string, len(i.index)) @@ -103,6 +142,11 @@ func (i *INI) Map() map[string]string { // Render собирает файл обратно в текст. func (i *INI) Render() []byte { + newline := "\n" + if i.crlf { + newline = "\r\n" + } + var buf bytes.Buffer for n, l := range i.lines { if l.Key == "" { @@ -113,7 +157,7 @@ func (i *INI) Render() []byte { buf.WriteString(l.Value) } if n < len(i.lines)-1 { - buf.WriteByte('\n') + buf.WriteString(newline) } } return buf.Bytes() diff --git a/internal/pzconfig/pzconfig_test.go b/internal/pzconfig/pzconfig_test.go index ec8cc88..f146535 100644 --- a/internal/pzconfig/pzconfig_test.go +++ b/internal/pzconfig/pzconfig_test.go @@ -154,3 +154,34 @@ func TestSandboxEntriesFollowFileOrder(t *testing.T) { } } } + +// TestKeepsWindowsLineEndings: конфиги, созданные игрой на Windows, приходят с +// CRLF. Если при сохранении переписать их в LF, изменится каждая строка файла — +// правка одного параметра превратится в полную перезапись. +func TestKeepsWindowsLineEndings(t *testing.T) { + ini := ParseINI([]byte("# Комментарий\r\nMaxPlayers=32\r\nPVP=true\r\n")) + ini.Set("MaxPlayers", "64") + + got := string(ini.Render()) + if strings.Count(got, "\r\n") != 3 { + t.Errorf("переводы строк не сохранены:\n%q", got) + } + if strings.Contains(strings.ReplaceAll(got, "\r\n", ""), "\n") { + t.Errorf("в файле появились одиночные LF:\n%q", got) + } + + // Файл с LF таким и остаётся. + unix := ParseINI([]byte("MaxPlayers=32\nPVP=true")) + unix.Set("PVP", "false") + if strings.Contains(string(unix.Render()), "\r") { + t.Error("в файл с LF добавились CR") + } + + sandbox := ParseSandbox([]byte("SandboxVars = {\r\n Zombies = 3,\r\n}\r\n")) + if err := sandbox.Set("Zombies", "1"); err != nil { + t.Fatal(err) + } + if strings.Count(string(sandbox.Render()), "\r\n") != 3 { + t.Errorf("песочница потеряла CRLF:\n%q", string(sandbox.Render())) + } +} diff --git a/internal/pzconfig/sandbox.go b/internal/pzconfig/sandbox.go index 25be2c3..a35a04e 100644 --- a/internal/pzconfig/sandbox.go +++ b/internal/pzconfig/sandbox.go @@ -24,6 +24,8 @@ type Sandbox struct { lines []string // index сопоставляет путь параметра с номером строки. index map[string]int + // crlf — файл был с виндовыми переводами строк; сохраняем как было. + crlf bool } var ( @@ -34,7 +36,9 @@ var ( // ParseSandbox разбирает содержимое SandboxVars.lua. func ParseSandbox(data []byte) *Sandbox { s := &Sandbox{index: make(map[string]int)} - text := strings.ReplaceAll(string(data), "\r\n", "\n") + raw := string(data) + s.crlf = strings.Contains(raw, "\r\n") + text := strings.ReplaceAll(raw, "\r\n", "\n") s.lines = strings.Split(text, "\n") var stack []string @@ -139,11 +143,16 @@ func (s *Sandbox) Set(path, value string) error { // Render собирает файл обратно. func (s *Sandbox) Render() []byte { + newline := "\n" + if s.crlf { + newline = "\r\n" + } + var buf bytes.Buffer for n, line := range s.lines { buf.WriteString(line) if n < len(s.lines)-1 { - buf.WriteByte('\n') + buf.WriteString(newline) } } return buf.Bytes() diff --git a/web/static/app.js b/web/static/app.js index 88221ac..6b90ad7 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -505,6 +505,197 @@ const CONFIG_HINTS = { const BOOLEAN_VALUES = new Set(["true", "false"]); +// Категории параметров servertest.ini. Порядок важен: в нём и рисуются +// разделы, а всё, чего здесь нет, попадает в «Прочее» — конфиг PZ меняется от +// версии к версии, и новые ключи не должны пропадать со страницы. +const CONFIG_GROUPS = [ + { + title: "Основное", + keys: ["PublicName", "PublicDescription", "Public", "Open", "MaxPlayers", + "ServerWelcomeMessage", "ResetID", "ServerPlayerID", "Seed", + "server_browser_announced_ip", "PublicServerIP", "ServerBrowserAnnouncedIP"], + }, + { + title: "Доступ и учётные записи", + keys: ["Password", "MaxAccountsPerUser", "DenyLoginOnOverloadedServer", + "AutoCreateUserInWhiteList", "DropOffWhiteListAfterDeath", "LoginQueueEnabled", + "LoginQueueConnectTimeout", "AllowCoop", "AllowNonAsciiUsername", + "SteamScoreboard", "SteamVAC", "DoLuaChecksum"], + }, + { + title: "Сеть и порты", + keys: ["DefaultPort", "UDPPort", "RCONPort", "RCONPassword", "PingLimit", "UPnP", + "MaxPacketsPerSecond", "ClientCommandFilter", "MultiplayerStatisticsPeriod"], + }, + { + title: "PVP и урон", + keys: ["PVP", "PVPLogToolChat", "PVPLogToolFile", "SafetySystem", "ShowSafety", + "SafetyToggleTimer", "SafetyCooldownTimer", "SafetyDisconnectDelay", + "PVPMeleeDamageModifier", "PVPFirearmDamageModifier", "PVPMeleeWhileHitReaction", + "KnockedDownAllowed", "SneakModeHideFromOtherPlayers", "PlayerBumpPlayer", + "UsePhysicsHitReaction", "CarEngineAttractionModifier"], + }, + { + title: "Мир и правила", + keys: ["Map", "SpawnPoint", "SpawnItems", "SaveWorldEveryMinutes", "PauseEmpty", + "NoFire", "AllowDestructionBySledgehammer", "SledgehammerOnlyInSafehouse", + "PlayerRespawnWithSelf", "PlayerRespawnWithOther", "FastForwardMultiplier", + "RemovePlayerCorpsesOnCorpseRemoval", "TrashDeleteAll", "BloodSplatLifespanDays", + "ItemNumbersLimitPerContainer", "SafehousePreventsLootRespawn", + "ConstructionPreventsLootRespawn", "HoursForLootRespawn", "MaxItemsForLootRespawn", + "SwitchZombiesOwnershipEachUpdate", "UltraSpeedDoesnotAffectToAnimals", + "SleepAllowed", "SleepNeeded", "SpeedLimit"], + }, + { + title: "Бэкапы самой игры", + keys: ["BackupsCount", "BackupsOnStart", "BackupsOnVersionChange", "BackupsPeriod"], + }, + { + title: "Моды", + keys: ["Mods", "WorkshopItems"], + }, + { + title: "Чат и голос", + keys: ["GlobalChat", "ChatStreams", "VoiceEnable", "VoiceMinDistance", "VoiceMaxDistance", + "Voice3D", "ChatMessageCharacterLimit", "ChatMessageSlowModeTime", "AnnounceDeath", + "AnnounceAnimalDeath", "MinutesPerPage", "BanKickGlobalSound"], + }, + { + title: "Фильтр брани", + keys: ["BadWordListFile", "GoodWordListFile", "BadWordPolicy", "BadWordReplacement"], + }, + { + title: "Видимость игроков", + keys: ["DisplayUserName", "ShowFirstAndLastName", "UsernameDisguises", + "HideDisguisedUserName", "MouseOverToSeeDisplayName", "HidePlayersBehindYou", + "MapRemotePlayerVisibility", "ShowCoordinates", "DisableScoreboard", + "HideAdminsInPlayerList"], + }, + { + title: "Убежища", + keys: ["PlayerSafehouse", "AdminSafehouse", "SafehouseAllowTrepass", "SafehouseAllowFire", + "SafehouseAllowLoot", "SafehouseAllowRespawn", "SafehouseDaySurvivedToClaim", + "SafeHouseRemovalTime", "SafehouseAllowNonResidential", "SafehouseDisableDisguises", + "MaxSafezoneSize", "DisableSafehouseWhenOwnerConnected", + "DisableSafehouseWhenPlayerConnected"], + }, + { + title: "Фракции и войны", + keys: ["Faction", "FactionDaySurvivedToCreate", "FactionPlayersRequiredForTag", + "War", "WarStartDelay", "WarDuration", "WarSafehouseHitPoints"], + }, + { + title: "Рации", + keys: ["DisableRadioStaff", "DisableRadioAdmin", "DisableRadioGM", "DisableRadioOverseer", + "DisableRadioModerator", "DisableRadioInvisible"], + }, + { + title: "Discord", + keys: ["DiscordEnable", "DiscordToken", "DiscordChannel", "DiscordChannelID", + "DiscordChatChannel", "DiscordLogChannel", "DiscordCommandChannel", "WebhookAddress"], + }, + { + title: "Античит и журналы", + keys: ["AntiCheatSafety", "AntiCheatSpeed", "AntiCheatNoClip", "AntiCheatHit", + "AntiCheatPacketException", "AntiCheatPermission", "AntiCheatXP", "AntiCheatSafeHouse", + "AntiCheatPlayer", "AntiCheatChecksum", "KickFastPlayers", "ClientActionLogs", "PerkLogs"], + // Нумерованных проверок два десятка, перечислять их поимённо бессмысленно. + prefixes: ["AntiCheatProtectionType"], + }, + { + title: "Транспорт", + keys: ["DisableVehicleTowing", "DisableTrailerTowing", "DisableBurntTowing"], + }, +]; + +// Категории верхнеуровневых параметров песочницы. Вложенные (ZombieLore.Speed +// и подобные) группируются сами — по имени таблицы, в которой лежат. +const SANDBOX_GROUPS = [ + { + title: "Зомби", + keys: ["Zombies", "Distribution", "ZombiesRespawn", "ZombieRespawn", "ZombieMigrate", + "ZombieVoronoiNoise", "ZombieAttractionMultiplier", "ZombieHealthImpact", + "ZombiePopLootEffect", "MultiHitZombies", "RearVulnerability", "SirenEffectsZombies", + "RallyGroupSize", "RallyTravelDistance", "RallyGroupSeparation", "RallyGroupRadius", + "HoursForCorpseRemoval", "DecayingCorpseHealthImpact", "BloodLevel", + "BloodSplatLifespanDays"], + }, + { + title: "Мир и время", + keys: ["DayLength", "StartYear", "StartMonth", "StartDay", "StartTime", "DayNightCycle", + "NightLength", "NightDarkness", "TimeSinceApo", "WaterShut", "ElecShut", + "WaterShutModifier", "ElecShutModifier", "Alarm", "AlarmDecay", "AlarmDecayModifier", + "LockedHouses", "ErosionSpeed", "ErosionDays", "EndRegen", "Helicopter", "MetaEvent", + "SleepingEvent", "AnnotatedMapChance", "VehicleStoryChance", "ZoneStoryChance", + "SurvivorHouseChance", "MaximumLootedBuildingRooms"], + }, + { + title: "Погода", + keys: ["ClimateCycle", "FogCycle", "Temperature", "TemperatureModifier", "Rain", + "RainModifier", "MaxFogIntensity", "MaxRainFxIntensity", "EnableSnowOnGround"], + }, + { + title: "Лут и предметы", + keys: ["LootRespawn", "SeenHoursPreventLootRespawn", "HoursForLootRespawn", + "MaxItemsForLootRespawn", "ConstructionPreventsLootRespawn", "RollsMultiplier", + "LootItemRemovalList", "RemoveStoryLoot", "RemoveZombieLoot", "WorldItemRemovalList", + "HoursForWorldItemRemoval", "ItemRemovalListBlacklistToggle", "MaximumLooted", + "DaysUntilMaximumLooted", "RuralLooted", "MaximumDiminishedLoot", + "DaysUntilMaximumDiminishedLoot", "SkillBookLoot", "RecipeResourceLoot"], + // *Loot, *LootNew и *LootFactor — десятки однотипных ключей. + prefixes: ["Food", "Canned", "Literature", "Medical", "SurvivalGears", "Weapon", + "RangedWeapon", "Ammo", "Mechanics", "Other", "Clothing", "Container", "Key", "Media", + "Memento", "Cookware", "Material", "Farming", "Tool"], + suffixes: ["Loot", "LootNew", "LootFactor"], + }, + { + title: "Персонаж и навыки", + keys: ["XpMultiplier", "XpMultiplierAffectsPassive", "StatsDecrease", "Endurance", + "Nutrition", "StarterKit", "InjurySeverity", "BoneFracture", "CharacterFreePoints", + "ConstructionBonusPoints", "NightVisionEnabled", "InjuredHealingModifier", + "MuscleStrainFactor", "DiscomfortFactor", "WoundInfectionFactor", "NegativeTraitsPenalty", + "EnablePoisoning", "ClothingDegradation", "AllClothesUnlocked", "NoBlackClothes", + "EasyClimbing", "AttackBlockMovements", "MetaKnowledge", "SeeNotLearntRecipe", + "LiteratureCooldown", "MinutesPerPage", "LevelForMediaXPCutoff", + "LevelForDismantleXPCutoff"], + }, + { + title: "Еда, вода и хозяйство", + keys: ["FoodRotSpeed", "FridgeFactor", "MaggotSpawn", "EnableTaintedWaterText", + "FishAbundance", "NatureAbundance", "PlantResilience", "PlantAbundance", "Farming", + "FarmingSpeedNew", "FarmingAmountNew", "CompostTime", "KillInsideCrops", + "PlantGrowingSeasons", "PlaceDirtAboveground", "ClayLakeChance", "ClayRiverChance", + "DaysForRottenFoodRemoval", "AidPackage"], + }, + { + title: "Животные", + keys: ["MaximumRatIndex", "DaysUntilMaximumRatIndex"], + prefixes: ["Animal"], + }, + { + title: "Транспорт", + keys: ["CarSpawnRate", "ChanceHasGas", "InitialGas", "CarGasConsumption", "LockedCar", + "CarGeneralCondition", "CarDamageOnImpact", "DamageToPlayerFromHitByACar", "TrafficJam", + "CarAlarm", "PlayerDamageFromCrash", "SirenShutoffHours", "RecentlySurvivorVehicles", + "EnableVehicles", "VehicleEasyUse", "MassInvasion"], + prefixes: ["FuelStation"], + }, + { + title: "Огнестрельное оружие", + keys: [], + prefixes: ["Firearm"], + }, + { + title: "Электричество и огонь", + keys: ["AllowExteriorGenerator", "FireSpread", "MaximumFireFuelHours", "LightBulbLifespan"], + prefixes: ["Generator"], + }, + { + title: "Служебное", + keys: ["VERSION"], + }, +]; + let serverConfigValues = {}; async function loadServerConfig() { @@ -519,24 +710,144 @@ async function loadServerConfig() { return; } serverConfigValues = data.values; - const container = $("#config-fields"); - data.keys.forEach((key) => { - container.appendChild(buildConfigField(key, data.values[key], CONFIG_HINTS[key])); - }); + const descriptions = data.descriptions || {}; + const entries = data.keys.map((key) => ({ + key, + value: data.values[key], + description: descriptions[key], + })); + renderGroupedFields("#config-fields", entries, CONFIG_GROUPS, CONFIG_HINTS); applyFieldFilter("#config-filter", "#config-fields"); refreshFieldSaveState(SAVE_TARGETS[0]); } +// groupEntries раскладывает параметры по разделам. Неизвестные ключи не +// теряются: они собираются в «Прочее» — конфиг PZ меняется от версии к версии. +function groupEntries(entries, groups) { + const byKey = new Map(); + entries.forEach((entry) => byKey.set(entry.key, entry)); + + const result = []; + const used = new Set(); + + groups.forEach((group) => { + const items = []; + group.keys.forEach((key) => { + const entry = byKey.get(key); + if (entry && !used.has(key)) { + used.add(key); + items.push(entry); + } + }); + (group.prefixes || []).forEach((prefix) => { + entries.forEach((entry) => { + if (used.has(entry.key)) return; + // Для лута prefixes перечисляет вид припасов, suffixes — вид ключа + // (FoodLoot, FoodLootNew, RareLootFactor): совпасть должны оба. + const suffixes = group.suffixes; + const matches = suffixes + ? suffixes.some((suffix) => entry.key === prefix + suffix) + : entry.key.startsWith(prefix); + if (matches) { + used.add(entry.key); + items.push(entry); + } + }); + }); + // Суффиксы без своей пары в prefixes — например RareLootFactor. + (group.suffixes || []).forEach((suffix) => { + entries.forEach((entry) => { + if (!used.has(entry.key) && entry.key.endsWith(suffix)) { + used.add(entry.key); + items.push(entry); + } + }); + }); + if (items.length) result.push({ title: group.title, items }); + }); + + // Вложенные параметры песочницы сами собой образуют разделы: у них путь вида + // ZombieLore.Speed. + const nested = new Map(); + const rest = []; + entries.forEach((entry) => { + if (used.has(entry.key)) return; + const dot = entry.key.indexOf("."); + if (dot > 0) { + const table = entry.key.slice(0, dot); + if (!nested.has(table)) nested.set(table, []); + nested.get(table).push(entry); + return; + } + rest.push(entry); + }); + + Array.from(nested.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .forEach(([table, items]) => result.push({ title: table, items })); + + if (rest.length) result.push({ title: "Прочее", items: rest }); + return result; +} + +// Какие разделы человек раскрыл: вкладка перерисовывается при каждом переходе, +// и без этого раскрытый раздел схлопывался бы обратно. +const openedGroups = new Map(); + +function rememberOpenGroups(containerSelector) { + const opened = new Set(); + $$(`${containerSelector} .cfg-group`).forEach((group) => { + if (group.open) opened.add(group.dataset.group); + }); + openedGroups.set(containerSelector, opened); +} + +// renderGroupedFields рисует разделы: свёрнутые по умолчанию, кроме первого — +// иначе сотня параметров снова превращается в простыню. +function renderGroupedFields(containerSelector, entries, groups, hints) { + const container = $(containerSelector); + container.replaceChildren(); + + const opened = openedGroups.get(containerSelector); + groupEntries(entries, groups).forEach((group, index) => { + const section = document.createElement("details"); + section.className = "cfg-group"; + section.dataset.group = group.title; + section.open = opened ? opened.has(group.title) : index === 0; + + const summary = document.createElement("summary"); + summary.innerHTML = + `${escapeHTML(group.title)} ${group.items.length}`; + section.appendChild(summary); + + const fields = document.createElement("div"); + fields.className = "fields"; + group.items.forEach((entry) => { + fields.appendChild(buildConfigField( + entry.key, entry.value, hints ? hints[entry.key] : null, entry.description)); + }); + section.appendChild(fields); + container.appendChild(section); + }); +} + // buildConfigField выбирает тип поля по текущему значению: булевы значения // удобнее переключать списком, числа — числовым полем. -function buildConfigField(key, value, hint) { +function buildConfigField(key, value, hint, description) { const wrapper = document.createElement("div"); wrapper.className = "cfg-field"; wrapper.dataset.key = key.toLowerCase(); - wrapper.dataset.hint = (hint || "").toLowerCase(); + // Поиск идёт и по описанию: по-русски параметр искать привычнее, чем по + // английскому имени ключа. + wrapper.dataset.hint = `${hint || ""} ${description || ""}`.toLowerCase(); const label = document.createElement("label"); label.textContent = hint || key; + if (description) { + // Полный текст — во всплывающей подсказке: описания бывают в несколько + // строк, и разворачивать их для каждого параметра значит утопить форму. + wrapper.title = description; + } wrapper.appendChild(label); let input; @@ -569,6 +880,13 @@ function buildConfigField(key, value, hint) { keyLabel.className = "key"; keyLabel.textContent = key; wrapper.appendChild(keyLabel); + + if (description) { + const hintLine = document.createElement("div"); + hintLine.className = "hint"; + hintLine.textContent = description; + wrapper.appendChild(hintLine); + } return wrapper; } @@ -633,11 +951,30 @@ $("#btn-config-save").addEventListener("click", async () => { function applyFieldFilter(inputSelector, containerSelector) { const filter = $(inputSelector).value.trim().toLowerCase(); + $$(`${containerSelector} .cfg-field`).forEach((field) => { field.hidden = filter && !field.dataset.key.includes(filter) && !field.dataset.hint.includes(filter); }); + + // Раздел без совпадений прячем целиком, а с совпадениями — раскрываем: + // искать параметр по свёрнутым разделам бессмысленно. + $$(`${containerSelector} .cfg-group`).forEach((group) => { + const visible = Array.from(group.querySelectorAll(".cfg-field")).filter((f) => !f.hidden); + group.hidden = visible.length === 0; + if (filter) { + group.open = visible.length > 0; + } + const count = group.querySelector(".cfg-count"); + if (count) { + count.textContent = filter ? `${visible.length}` : `${group.querySelectorAll(".cfg-field").length}`; + } + }); } +// toggle не всплывает, поэтому слушаем на фазе перехвата. +["#config-fields", "#sandbox-fields"].forEach((selector) => + $(selector).addEventListener("toggle", () => rememberOpenGroups(selector), true)); + $("#config-filter").addEventListener("input", () => applyFieldFilter("#config-filter", "#config-fields")); $("#sandbox-filter").addEventListener("input", () => applyFieldFilter("#sandbox-filter", "#sandbox-fields")); @@ -656,10 +993,8 @@ async function loadSandbox() { refreshFieldSaveState(SAVE_TARGETS[1]); return; } - const container = $("#sandbox-fields"); - data.entries.forEach((entry) => { - container.appendChild(buildConfigField(entry.path, entry.value, null)); - }); + const entries = data.entries.map((entry) => ({ key: entry.path, value: entry.value })); + renderGroupedFields("#sandbox-fields", entries, SANDBOX_GROUPS, null); applyFieldFilter("#sandbox-filter", "#sandbox-fields"); refreshFieldSaveState(SAVE_TARGETS[1]); } diff --git a/web/static/index.html b/web/static/index.html index 3212f19..7b2a2b0 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -149,7 +149,7 @@
- + @@ -165,7 +165,7 @@ - + diff --git a/web/static/style.css b/web/static/style.css index 65cc3f3..53b370a 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -267,6 +267,40 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; } gap: 12px; } +.cfg-group { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-soft); + margin-bottom: 10px; +} + +.cfg-group > summary { + padding: 11px 14px; + cursor: pointer; + font-weight: 600; + font-size: 14px; + user-select: none; + list-style: none; +} +.cfg-group > summary::-webkit-details-marker { display: none; } +.cfg-group > summary::before { + content: "▸"; + display: inline-block; + width: 14px; + color: var(--muted); +} +.cfg-group[open] > summary::before { content: "▾"; } +.cfg-group > summary:hover { background: var(--bg-card); border-radius: var(--radius); } + +.cfg-count { + color: var(--muted); + font-weight: 400; + font-size: 12px; + margin-left: 4px; +} + +.cfg-group > .fields { padding: 0 14px 14px; } + .cfg-field { background: var(--bg-card); border: 1px solid var(--border); @@ -275,7 +309,17 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; } } .cfg-field.changed { border-color: var(--accent-dark); } .cfg-field label { display: block; font-size: 13px; margin-bottom: 6px; } -.cfg-field .hint { color: var(--muted); font-size: 12px; margin-top: 5px; } +.cfg-field .hint { + color: var(--muted); + font-size: 12px; + margin-top: 5px; + /* Описания из конфига бывают в абзац: показываем начало, остальное — в + подсказке при наведении. */ + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} .cfg-field .key { color: var(--muted); font-family: var(--mono); font-size: 11px; } /* ---------- Моды ---------- */