feat:初版
This commit is contained in:
900
templates/index.html
Normal file
900
templates/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user