feat:初版

This commit is contained in:
2025-12-26 18:10:39 +08:00
commit fd15f9eb8f
17 changed files with 3486 additions and 0 deletions

900
templates/index.html Normal file
View File

@@ -0,0 +1,900 @@
<!doctype html>
<html lang="zh-CN" data-bs-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>PZ Config Studio</title>
<!-- Tabler UI (Bootstrap 5 based) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta21/dist/css/tabler.min.css" />
<!-- Better selects -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.bootstrap5.min.css" />
<style>
:root {
--bg0: #f5f7ff;
--panel: rgba(255, 255, 255, 0.92);
--panel-border: rgba(15, 23, 42, 0.10);
--muted: rgba(15, 23, 42, 0.64);
--accent: rgba(79, 70, 229, 1);
}
body {
min-height: 100vh;
background:
radial-gradient(1200px 900px at 12% 6%,
rgba(79, 70, 229, 0.20) 0%,
rgba(79, 70, 229, 0.10) 36%,
rgba(79, 70, 229, 0.00) 72%),
radial-gradient(1100px 820px at 92% 14%,
rgba(2, 132, 199, 0.18) 0%,
rgba(2, 132, 199, 0.08) 38%,
rgba(2, 132, 199, 0.00) 74%),
radial-gradient(980px 740px at 52% 98%,
rgba(16, 185, 129, 0.14) 0%,
rgba(16, 185, 129, 0.06) 36%,
rgba(16, 185, 129, 0.00) 70%),
linear-gradient(180deg,
#fbfcff 0%,
var(--bg0) 46%,
#f2f7ff 100%);
background-attachment: fixed;
}
.app-shell { max-width: 1480px; }
.glass {
background: var(--panel);
border: 1px solid var(--panel-border);
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.10);
}
.navbar-brand { letter-spacing: 0.2px; }
.muted { color: var(--muted); }
code { color: rgba(29, 78, 216, 1); }
.app-header {
background: rgba(255, 255, 255, 0.86);
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
@media (min-width: 992px) {
.sidebar-sticky {
position: sticky;
top: 72px;
height: calc(100vh - 90px);
}
.sidebar-card { height: 100%; display: flex; flex-direction: column; }
.sidebar-scroll { overflow: auto; flex: 1 1 auto; }
}
.setting-row {
border: 1px solid rgba(15, 23, 42, 0.10);
background: rgba(255, 255, 255, 0.88);
border-radius: 14px;
padding: 14px 16px;
transition: background-color .12s ease, border-color .12s ease;
content-visibility: auto;
contain: content;
contain-intrinsic-size: 140px;
}
.setting-row:hover {
background: rgba(255, 255, 255, 0.96);
border-color: rgba(15, 23, 42, 0.14);
}
.setting-row.modified {
border-color: rgba(245, 158, 11, 0.55);
background: rgba(245, 158, 11, 0.10);
}
.setting-key { font-size: 0.92rem; }
.setting-summary { line-height: 1.35; }
.setting-summary { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.form-control, .form-select, textarea {
background: rgba(255, 255, 255, 0.96) !important;
border-color: rgba(15, 23, 42, 0.14) !important;
}
.form-control:focus, .form-select:focus, textarea:focus {
border-color: rgba(79, 70, 229, 0.55) !important;
box-shadow: 0 0 0 .25rem rgba(79, 70, 229, 0.18) !important;
}
.list-group-item { background: transparent; }
.list-group-item.active {
background: rgba(79, 70, 229, 0.10);
border-color: rgba(79, 70, 229, 0.22);
}
.tom-select .ts-control, .ts-wrapper.single .ts-control {
background: rgba(255, 255, 255, 0.96) !important;
border-color: rgba(15, 23, 42, 0.14) !important;
}
.ts-dropdown {
background: rgba(255, 255, 255, 0.98) !important;
border-color: rgba(15, 23, 42, 0.14) !important;
z-index: 2000 !important;
}
.btn-detail {
border-color: rgba(79, 70, 229, 0.30) !important;
color: rgba(67, 56, 202, 1) !important;
background: rgba(79, 70, 229, 0.06) !important;
}
.btn-detail:hover, .btn-detail:focus-visible {
border-color: rgba(79, 70, 229, 0.40) !important;
background: rgba(79, 70, 229, 0.12) !important;
}
</style>
</head>
<body data-default-scope="{{ ini_path }}||{{ lua_path }}">
{% macro sidebar(dismiss_offcanvas=false) -%}
<div class="card glass sidebar-card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">搜索(键名 / 中文说明 / 英文)</label>
<input
type="search"
class="form-control js-search"
placeholder="例如PVP / 端口 / Respawn / 僵尸 / Water..."
autocomplete="off"
spellcheck="false"
/>
</div>
<label class="form-check form-switch m-0">
<input class="form-check-input js-only-modified" type="checkbox" />
<span class="form-check-label">只看已修改(跨全部分组)</span>
</label>
<div class="d-grid gap-2 mt-3">
<button type="button" class="btn btn-outline-secondary js-restore-defaults" {% if dismiss_offcanvas %}data-bs-dismiss="offcanvas"{% endif %}>
恢复默认
</button>
<button type="button" class="btn btn-outline-secondary js-set-defaults" {% if dismiss_offcanvas %}data-bs-dismiss="offcanvas"{% endif %}>
当前设为默认
</button>
</div>
<div class="small muted mt-3">
<div>INI<code>{{ ini_settings | length }}</code></div>
<div>SandboxVars<code>{{ lua_setting_count }}</code></div>
<div class="mt-2">
文件:<code>{{ ini_path }}</code><br />
文件:<code>{{ lua_path }}</code>
</div>
{% if has_translations %}
<div class="mt-2">SandboxVars 中文说明:<code>{{ translations_path }}</code></div>
{% else %}
<div class="mt-2 text-warning">未找到 SandboxVars 中文说明 JSON将显示占位/英文)。</div>
{% endif %}
</div>
</div>
<div class="list-group list-group-flush sidebar-scroll">
<div class="list-group-header text-uppercase muted px-3 py-2 small">配置文件</div>
<a
class="list-group-item list-group-item-action d-flex align-items-center justify-content-between js-nav active"
href="#ini"
data-section="ini"
{% if dismiss_offcanvas %}data-bs-dismiss="offcanvas"{% endif %}
>
<div class="min-w-0">
<div class="fw-semibold text-truncate">server.ini</div>
<div class="small muted text-truncate">服务器核心/多人参数</div>
</div>
<span class="badge bg-azure-lt">{{ ini_settings | length }}</span>
</a>
<div class="list-group-header text-uppercase muted px-3 py-2 small">SandboxVars 分组</div>
{% for g in lua_groups %}
<a
class="list-group-item list-group-item-action d-flex align-items-center justify-content-between js-nav"
href="#lua-{{ g.id }}"
data-section="lua-{{ g.id }}"
{% if dismiss_offcanvas %}data-bs-dismiss="offcanvas"{% endif %}
>
<div class="min-w-0">
<div class="fw-semibold text-truncate">{{ g.name }}</div>
<div class="small muted text-truncate">{{ g.count }} 项</div>
</div>
<span class="badge bg-indigo-lt">{{ g.count }}</span>
</a>
{% endfor %}
</div>
</div>
{%- endmacro %}
<div class="page app-shell mx-auto">
<header class="navbar navbar-expand-md navbar-light sticky-top app-header">
<div class="container-xl">
<button
class="btn btn-outline-secondary d-lg-none me-2"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#sidebarOffcanvas"
aria-controls="sidebarOffcanvas"
>
菜单
</button>
<a class="navbar-brand d-flex align-items-center gap-2" href="/">
<span class="fw-semibold">PZ Config Studio</span>
<span class="badge bg-indigo-lt">WebUI</span>
</a>
<div class="ms-auto d-flex align-items-center gap-2">
<div class="d-none d-md-block small muted">
已修改 <span id="modifiedCount">0</span>
</div>
<button type="button" class="btn btn-outline-secondary d-none d-md-inline-flex" id="btnRestoreDefaults">
恢复默认
</button>
<button type="button" class="btn btn-outline-secondary d-none d-md-inline-flex" id="btnSetDefaults">
当前设为默认
</button>
<button class="btn btn-primary" type="submit" form="configForm" formaction="/download.zip">
下载 ZIP
</button>
<button
class="btn btn-warning"
type="submit"
form="configForm"
formaction="/save"
onclick="return confirm('确认要覆盖写回本地文件吗?建议先下载 ZIP 备份。');"
>
保存覆盖本地
</button>
</div>
</div>
</header>
<div class="offcanvas offcanvas-start" tabindex="-1" id="sidebarOffcanvas" aria-labelledby="sidebarOffcanvasLabel">
<div class="offcanvas-header">
<div class="offcanvas-title fw-semibold" id="sidebarOffcanvasLabel">配置导航</div>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body pt-0">
{{ sidebar(true) }}
</div>
</div>
<div class="page-wrapper">
<div class="page-body">
<div class="container-xl">
{% if saved %}
<div class="alert alert-success glass border-0 mb-3 py-2">
已保存覆盖到本地文件。
</div>
{% endif %}
<div class="row g-3">
<div class="col-12 col-lg-3 d-none d-lg-block">
<div class="sidebar-sticky">
{{ sidebar(false) }}
</div>
</div>
<div class="col-12 col-lg-9">
<form id="configForm" method="post">
<div id="noResults" class="alert alert-warning glass border-0 d-none">
没有匹配项(尝试清空搜索/取消“只看已修改”)。
</div>
<section class="config-section" data-section="ini" id="section-ini">
<div class="d-flex align-items-end justify-content-between mb-2">
<div>
<div class="h2 m-0">server.ini</div>
<div class="muted">按项编辑 · {{ ini_settings | length }} 项</div>
</div>
<div class="d-none d-md-block small muted">点击每项右侧“详情”查看完整中文说明</div>
</div>
<div class="card glass">
<div class="card-body">
<div class="d-flex flex-column gap-3">
{% for s in ini_settings %}
<div
class="setting-row setting-row-ini"
data-search="{{ (s.path ~ ' ' ~ s.description_zh) | lower }}"
data-min="{{ s.min_value if s.min_value is not none else '' }}"
data-max="{{ s.max_value if s.max_value is not none else '' }}"
data-default="{{ s.default_value if s.default_value else '' }}"
data-type="{{ s.value_type }}"
>
<div class="row g-3 align-items-start">
<div class="col-12 col-md-7">
<div class="d-flex align-items-start justify-content-between gap-2">
<div class="min-w-0">
<div class="d-flex align-items-center gap-2 flex-wrap">
<code class="setting-key text-truncate">{{ s.path }}</code>
<span class="badge bg-secondary-lt">{{ s.value_type }}</span>
{% if s.default_value %}<span class="badge bg-azure-lt">默认 {{ s.default_value }}</span>{% endif %}
{% if s.min_value is not none %}<span class="badge bg-indigo-lt">Min {{ s.min_value }}</span>{% endif %}
{% if s.max_value is not none %}<span class="badge bg-indigo-lt">Max {{ s.max_value }}</span>{% endif %}
</div>
<div class="setting-summary small muted mt-1">
{{ s.description_zh.split('\n', 1)[0] }}
</div>
</div>
<button
type="button"
class="btn btn-sm btn-outline-primary js-detail btn-detail"
data-bs-toggle="modal"
data-bs-target="#detailModal"
>
详情
</button>
</div>
<div class="d-none js-desc-zh">{{ s.description_zh }}</div>
<div class="d-none js-desc-en"></div>
</div>
<div class="col-12 col-md-5">
{% set name = 'ini__' ~ s.path %}
{% if s.choices %}
<select class="form-select setting-input js-select" name="{{ name }}" data-original="{{ s.value }}">
{% for v, label in s.choices.items() %}
<option value="{{ v }}" {% if (s.value | string) == v %}selected{% endif %}>{{ v }} · {{ label }}</option>
{% endfor %}
</select>
{% elif s.value_type == 'bool' %}
<label class="form-check form-switch">
<input
class="form-check-input setting-input"
type="checkbox"
name="{{ name }}"
data-original="{{ 'true' if s.value else 'false' }}"
{% if s.value %}checked{% endif %}
/>
<span class="form-check-label muted">开 / 关</span>
</label>
{% elif s.value_type in ['int','float'] %}
<input
class="form-control setting-input"
type="number"
name="{{ name }}"
value="{{ s.value }}"
data-original="{{ s.value }}"
{% if s.min_value is not none %}min="{{ s.min_value }}"{% endif %}
{% if s.max_value is not none %}max="{{ s.max_value }}"{% endif %}
step="{% if s.value_type == 'int' %}1{% else %}0.01{% endif %}"
/>
{% else %}
{% set v = s.value | string %}
{% if v | length > 80 or s.path in long_text_keys %}
<textarea class="form-control setting-input" name="{{ name }}" rows="3" data-original="{{ v }}">{{ v }}</textarea>
{% else %}
<input class="form-control setting-input" type="text" name="{{ name }}" value="{{ v }}" data-original="{{ v }}" />
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</section>
{% for g in lua_groups %}
<section class="config-section d-none" data-section="lua-{{ g.id }}" id="section-lua-{{ g.id }}">
<div class="d-flex align-items-end justify-content-between mb-2">
<div>
<div class="h2 m-0">SandboxVars · {{ g.name }}</div>
<div class="muted">按项编辑 · {{ g.count }} 项</div>
</div>
<div class="d-none d-md-block small muted">点击每项右侧“详情”查看完整中文/英文说明</div>
</div>
<div class="card glass">
<div class="card-body">
<div class="d-flex flex-column gap-3">
{% for s in g.settings %}
<div
class="setting-row setting-row-lua"
data-search="{{ (s.path ~ ' ' ~ s.description_zh ~ ' ' ~ (s.description_en or '')) | lower }}"
data-min="{{ s.min_value if s.min_value is not none else '' }}"
data-max="{{ s.max_value if s.max_value is not none else '' }}"
data-default="{{ s.default_value if s.default_value else '' }}"
data-type="{{ s.value_type }}"
>
<div class="row g-3 align-items-start">
<div class="col-12 col-md-7">
<div class="d-flex align-items-start justify-content-between gap-2">
<div class="min-w-0">
<div class="d-flex align-items-center gap-2 flex-wrap">
<code class="setting-key text-truncate">{{ s.path }}</code>
<span class="badge bg-secondary-lt">{{ s.value_type }}</span>
{% if s.default_value %}<span class="badge bg-azure-lt">默认 {{ s.default_value }}</span>{% endif %}
{% if s.min_value is not none %}<span class="badge bg-indigo-lt">Min {{ s.min_value }}</span>{% endif %}
{% if s.max_value is not none %}<span class="badge bg-indigo-lt">Max {{ s.max_value }}</span>{% endif %}
{% if s.choices %}<span class="badge bg-teal-lt">枚举</span>{% endif %}
</div>
<div class="setting-summary small muted mt-1">
{{ s.description_zh.split('\n', 1)[0] }}
</div>
</div>
<button
type="button"
class="btn btn-sm btn-outline-primary js-detail btn-detail"
data-bs-toggle="modal"
data-bs-target="#detailModal"
>
详情
</button>
</div>
<div class="d-none js-desc-zh">{{ s.description_zh }}</div>
<div class="d-none js-desc-en">{{ s.description_en or '' }}</div>
</div>
<div class="col-12 col-md-5">
{% set name = 'lua__' ~ s.path %}
{% if s.choices %}
<select class="form-select setting-input js-select" name="{{ name }}" data-original="{{ s.value }}">
{% for v, label in s.choices.items() %}
<option value="{{ v }}" {% if (s.value | string) == v %}selected{% endif %}>{{ v }} · {{ label }}</option>
{% endfor %}
</select>
{% elif s.value_type == 'bool' %}
<label class="form-check form-switch">
<input
class="form-check-input setting-input"
type="checkbox"
name="{{ name }}"
data-original="{{ 'true' if s.value else 'false' }}"
{% if s.value %}checked{% endif %}
/>
<span class="form-check-label muted">true / false</span>
</label>
{% elif s.value_type in ['int','float'] %}
<input
class="form-control setting-input"
type="number"
name="{{ name }}"
value="{{ s.value }}"
data-original="{{ s.value }}"
{% if s.min_value is not none %}min="{{ s.min_value }}"{% endif %}
{% if s.max_value is not none %}max="{{ s.max_value }}"{% endif %}
step="{% if s.value_type == 'int' %}1{% else %}0.01{% endif %}"
/>
{% else %}
{% set v = s.value | string %}
{% if v | length > 80 or s.path in long_text_keys %}
<textarea class="form-control setting-input" name="{{ name }}" rows="3" data-original="{{ v }}">{{ v }}</textarea>
{% else %}
<input class="form-control setting-input" type="text" name="{{ name }}" value="{{ v }}" data-original="{{ v }}" />
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</section>
{% endfor %}
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Detail modal (single reusable) -->
<div class="modal modal-blur fade" id="detailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content glass">
<div class="modal-header">
<div>
<div class="modal-title fw-semibold">
<code id="detailKey"></code>
</div>
<div class="small muted mt-1" id="detailMeta"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="small muted mb-2">中文说明</div>
<div id="detailZh" class="text-secondary" style="white-space: pre-line;"></div>
<details class="mt-4" id="detailEnWrap">
<summary class="small muted">英文原文</summary>
<div id="detailEn" class="text-secondary mt-2" style="white-space: pre-line;"></div>
</details>
</div>
</div>
</div>
</div>
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 3000;">
<div id="appToast" class="toast glass border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-body" id="appToastBody"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script>
<script>
const DEFAULT_TEMPLATE_STORAGE_KEY = 'pz_default_template_v1';
const STORAGE_KEY = 'pz_active_section';
let activeSection = 'ini';
let searchText = '';
let onlyModified = false;
let modifiedCount = 0;
let applyTimer = null;
let selectInitTimer = null;
let selectInitRaf = null;
function normalizeValue(el) {
if (el.type === 'checkbox') return el.checked ? 'true' : 'false';
return (el.value ?? '').toString();
}
function showToast(text) {
const toastEl = document.getElementById('appToast');
const bodyEl = document.getElementById('appToastBody');
if (!toastEl || !bodyEl) return;
bodyEl.textContent = text;
if (typeof bootstrap === 'undefined' || !bootstrap.Toast) return;
bootstrap.Toast.getOrCreateInstance(toastEl, { delay: 2200 }).show();
}
function getDefaultScope() {
return (document.body?.dataset?.defaultScope || 'global').toString();
}
function loadDefaultTemplate() {
try {
const raw = localStorage.getItem(DEFAULT_TEMPLATE_STORAGE_KEY);
if (!raw) return null;
const map = JSON.parse(raw);
if (!map || typeof map !== 'object') return null;
const scope = getDefaultScope();
const tpl = map[scope];
if (!tpl || typeof tpl !== 'object') return null;
return tpl;
} catch {
return null;
}
}
function saveDefaultTemplate(template) {
try {
const raw = localStorage.getItem(DEFAULT_TEMPLATE_STORAGE_KEY);
const map = raw ? (JSON.parse(raw) || {}) : {};
const scope = getDefaultScope();
map[scope] = template;
localStorage.setItem(DEFAULT_TEMPLATE_STORAGE_KEY, JSON.stringify(map));
} catch {}
}
function collectCurrentValues() {
const values = {};
document.querySelectorAll('.setting-input').forEach((el) => {
const name = el.getAttribute('name');
if (!name) return;
values[name] = normalizeValue(el);
});
return values;
}
function setElementValue(el, value) {
if (el.type === 'checkbox') {
el.checked = value === 'true';
return;
}
if (el.tagName === 'SELECT') {
// Ensure enhanced select when we apply values (better UX).
ensureEnhancedSelect(el);
if (el.tomselect) {
el.tomselect.setValue(value, true);
} else {
el.value = value;
}
return;
}
el.value = value;
}
function restoreDefaults() {
const tpl = loadDefaultTemplate();
const usedCustom = !!tpl;
document.querySelectorAll('.setting-input').forEach((el) => {
const name = el.getAttribute('name');
if (!name) return;
const v = tpl && Object.prototype.hasOwnProperty.call(tpl, name) ? tpl[name] : (el.dataset.original ?? '').toString();
setElementValue(el, (v ?? '').toString());
});
initialModifiedScan();
applyFilters();
scheduleInitSelects();
showToast(usedCustom ? '已恢复到自定义默认配置(未保存到文件)' : '已恢复到文件初始值(未保存到文件)');
}
function setCurrentAsDefaults() {
const values = collectCurrentValues();
saveDefaultTemplate(values);
showToast('已将当前配置保存为默认(保存在浏览器本地)');
}
function scheduleApplyFilters() {
if (applyTimer) clearTimeout(applyTimer);
applyTimer = setTimeout(applyFilters, 90);
}
function scheduleInitSelects() {
if (selectInitTimer) clearTimeout(selectInitTimer);
selectInitTimer = setTimeout(initVisibleSelects, 60);
}
function getSearchInputs() {
return Array.from(document.querySelectorAll('.js-search'));
}
function getOnlyModifiedToggles() {
return Array.from(document.querySelectorAll('.js-only-modified'));
}
function syncSearchInputs(source) {
getSearchInputs().forEach((el) => {
if (el === source) return;
if (el.value !== searchText) el.value = searchText;
});
}
function syncOnlyModifiedToggles(source) {
getOnlyModifiedToggles().forEach((el) => {
if (el === source) return;
if (el.checked !== onlyModified) el.checked = onlyModified;
});
}
function setActiveSection(sectionKey, { updateHash = true, scrollToTop = false } = {}) {
activeSection = sectionKey || 'ini';
try { localStorage.setItem(STORAGE_KEY, activeSection); } catch {}
document.querySelectorAll('.js-nav').forEach((a) => {
a.classList.toggle('active', (a.dataset.section || '') === activeSection);
});
if (updateHash) {
const h = activeSection === 'ini' ? '#ini' : '#' + activeSection;
history.replaceState(null, '', h);
}
applyFilters();
if (scrollToTop) window.scrollTo({ top: 0, behavior: 'auto' });
}
function isModified(el, row) {
const original = (el.dataset.original ?? '').toString();
const current = normalizeValue(el);
const type = (row?.dataset?.type ?? '').toString();
if (type === 'int' || type === 'float') {
const o = Number(original);
const c = Number(current);
if (Number.isFinite(o) && Number.isFinite(c)) return o !== c;
}
return original !== current;
}
function updateRowModifiedFromInput(el) {
const row = el.closest('.setting-row');
if (!row) return;
const was = row.classList.contains('modified');
const now = isModified(el, row);
if (was === now) return;
row.classList.toggle('modified', now);
modifiedCount += now ? 1 : -1;
document.getElementById('modifiedCount').textContent = String(modifiedCount);
}
function initialModifiedScan() {
modifiedCount = 0;
document.querySelectorAll('.setting-row').forEach((row) => {
const el = row.querySelector('.setting-input');
if (!el) return;
const now = isModified(el, row);
row.classList.toggle('modified', now);
if (now) modifiedCount += 1;
});
document.getElementById('modifiedCount').textContent = String(modifiedCount);
}
function applyFilters() {
const q = (searchText || '').toLowerCase().trim();
const globalMode = q.length > 0 || onlyModified;
const noResults = document.getElementById('noResults');
if (!globalMode) {
document.querySelectorAll('.config-section').forEach((section) => {
const key = (section.dataset.section ?? 'ini').toString();
section.classList.toggle('d-none', key !== activeSection);
});
const active = document.querySelector(`.config-section[data-section="${activeSection}"]`);
if (active) active.querySelectorAll('.setting-row').forEach((row) => row.classList.remove('d-none'));
noResults.classList.add('d-none');
scheduleInitSelects();
return;
}
let visibleRows = 0;
document.querySelectorAll('.config-section').forEach((section) => {
let anyVisible = false;
section.querySelectorAll('.setting-row').forEach((row) => {
const hay = (row.dataset.search ?? '').toString();
const matchQ = !q || hay.includes(q);
const matchM = !onlyModified || row.classList.contains('modified');
const visible = matchQ && matchM;
row.classList.toggle('d-none', !visible);
if (visible) { anyVisible = true; visibleRows += 1; }
});
section.classList.toggle('d-none', !anyVisible);
});
noResults.classList.toggle('d-none', visibleRows !== 0);
scheduleInitSelects();
}
function ensureEnhancedSelect(selectEl) {
if (typeof TomSelect === 'undefined') return;
if (!selectEl || selectEl.dataset.enhanced === '1') return;
selectEl.dataset.enhanced = '1';
new TomSelect(selectEl, {
create: false,
allowEmptyOption: true,
maxOptions: 200,
plugins: ['dropdown_input'],
dropdownParent: 'body',
});
}
function initVisibleSelects() {
if (typeof TomSelect === 'undefined') return;
if (selectInitRaf) cancelAnimationFrame(selectInitRaf);
const candidates = Array.from(document.querySelectorAll('select.js-select')).filter((el) => {
if (el.dataset.enhanced === '1') return false;
// Only enhance visible selects to keep things fast.
return el.offsetParent !== null;
});
if (candidates.length === 0) return;
let i = 0;
const BATCH = 6;
const step = () => {
const end = Math.min(i + BATCH, candidates.length);
for (; i < end; i++) ensureEnhancedSelect(candidates[i]);
if (i < candidates.length) {
selectInitRaf = requestAnimationFrame(step);
} else {
selectInitRaf = null;
}
};
selectInitRaf = requestAnimationFrame(step);
}
function init() {
initialModifiedScan();
// Navigation (works for both desktop + offcanvas lists).
document.addEventListener('click', (e) => {
const a = e.target.closest('.js-nav');
if (!a) return;
e.preventDefault();
setActiveSection(a.dataset.section || 'ini', { scrollToTop: true });
});
// Defaults actions (header + sidebar).
document.addEventListener('click', (e) => {
const restoreBtn = e.target.closest('#btnRestoreDefaults, .js-restore-defaults');
if (restoreBtn) {
restoreDefaults();
return;
}
const setBtn = e.target.closest('#btnSetDefaults, .js-set-defaults');
if (setBtn) {
setCurrentAsDefaults();
return;
}
});
// Search + only-modified (supports multiple mirrored controls).
getSearchInputs().forEach((el) => {
el.addEventListener('input', () => {
searchText = el.value || '';
syncSearchInputs(el);
scheduleApplyFilters();
});
});
getOnlyModifiedToggles().forEach((el) => {
el.addEventListener('change', () => {
onlyModified = !!el.checked;
syncOnlyModifiedToggles(el);
scheduleApplyFilters();
});
});
// Change tracking via event delegation (fast).
const form = document.getElementById('configForm');
form.addEventListener('input', (e) => {
const el = e.target;
if (!el || !el.classList || !el.classList.contains('setting-input')) return;
updateRowModifiedFromInput(el);
if (onlyModified) scheduleApplyFilters();
}, true);
form.addEventListener('change', (e) => {
const el = e.target;
if (!el || !el.classList || !el.classList.contains('setting-input')) return;
updateRowModifiedFromInput(el);
if (onlyModified) scheduleApplyFilters();
}, true);
// Lazy-enhance selects only when user interacts.
document.addEventListener('focusin', (e) => {
const sel = e.target.closest && e.target.closest('select.js-select');
if (sel) ensureEnhancedSelect(sel);
}, true);
document.addEventListener('pointerdown', (e) => {
const sel = e.target.closest && e.target.closest('select.js-select');
if (sel) ensureEnhancedSelect(sel);
}, true);
// Detail modal: one delegated handler.
const keyEl = document.getElementById('detailKey');
const metaEl = document.getElementById('detailMeta');
const zhEl = document.getElementById('detailZh');
const enWrap = document.getElementById('detailEnWrap');
const enEl = document.getElementById('detailEn');
document.addEventListener('click', (e) => {
const btn = e.target.closest('.js-detail');
if (!btn) return;
const row = btn.closest('.setting-row');
if (!row) return;
const key = row.querySelector('code')?.textContent ?? '';
const zh = row.querySelector('.js-desc-zh')?.textContent ?? '';
const en = row.querySelector('.js-desc-en')?.textContent ?? '';
const type = (row.dataset.type || '').toString();
const minV = (row.dataset.min || '').toString();
const maxV = (row.dataset.max || '').toString();
const defV = (row.dataset.default || '').toString();
keyEl.textContent = key;
zhEl.textContent = zh;
const metaParts = [];
if (type) metaParts.push(`类型:${type}`);
if (defV) metaParts.push(`默认:${defV}`);
if (minV) metaParts.push(`Min${minV}`);
if (maxV) metaParts.push(`Max${maxV}`);
metaEl.textContent = metaParts.join(' · ');
const hasEn = en.trim().length > 0;
enWrap.classList.toggle('d-none', !hasEn);
enEl.textContent = en;
});
// Restore active section from hash/storage.
const fromHash = (location.hash || '').replace(/^#/, '');
const fromStorage = (() => { try { return localStorage.getItem(STORAGE_KEY) || ''; } catch { return ''; } })();
const candidate = fromHash || fromStorage || 'ini';
const exists = document.querySelector(`.js-nav[data-section="${candidate}"]`);
setActiveSection(exists ? candidate : 'ini', { updateHash: false, scrollToTop: false });
scheduleInitSelects();
}
init();
</script>
</body>
</html>