Files
PzConfigStudio/templates/index.html
2025-12-26 18:49:41 +08:00

919 lines
40 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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;
}
.ts-dropdown .ts-dropdown-content { padding: 6px; }
.ts-dropdown [data-selectable].option {
border-radius: 10px;
margin: 2px 0;
padding: 8px 10px;
transition: background-color .12s ease, transform .12s ease, box-shadow .12s ease;
}
.ts-dropdown [data-selectable].option:hover {
background: rgba(79, 70, 229, 0.08) !important;
transform: translateX(2px);
}
.ts-dropdown .option.active,
.ts-dropdown .option.active.create {
background: rgba(79, 70, 229, 0.12) !important;
color: rgba(15, 23, 42, 0.96) !important;
box-shadow: inset 0 0 0 1px rgba(79, 70, 229, 0.16);
}
.ts-dropdown .option.active:hover { background: rgba(79, 70, 229, 0.14) !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>