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

205
pz_webui.py Normal file
View File

@@ -0,0 +1,205 @@
from __future__ import annotations
import argparse
import io
import sys
import zipfile
from datetime import datetime
import hashlib
import re
from pathlib import Path
from typing import Any
from flask import Flask, Response, redirect, render_template, request, url_for
from pz_config.parsers import parse_sandboxvars_lua, parse_server_ini
from pz_config.writers import write_sandboxvars_lua, write_server_ini
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Project Zomboid 配置 WebUI 编辑器")
parser.add_argument("--ini", default="server.ini", help="server.ini 路径(默认:./server.ini")
parser.add_argument(
"--lua",
default="server_SandboxVars.lua",
help="server_SandboxVars.lua 路径(默认:./server_SandboxVars.lua",
)
parser.add_argument(
"--translations",
default=str(Path("i18n") / "sandboxvars_zh.json"),
help="SandboxVars 中文说明 JSON默认./i18n/sandboxvars_zh.json",
)
parser.add_argument("--host", default="127.0.0.1", help="监听地址默认127.0.0.1")
parser.add_argument("--port", type=int, default=5050, help="监听端口默认5050")
parser.add_argument("--debug", action="store_true", help="Flask debug 模式")
return parser.parse_args(argv)
def _coerce_value(value_type: str, raw: str) -> bool | int | float | str:
if value_type == "bool":
return raw.lower() == "true"
if value_type == "int":
return int(raw)
if value_type == "float":
return float(raw)
return raw
def create_app(ini_path: str, lua_path: str, translations_path: str) -> Flask:
app = Flask(__name__)
ini_path = str(Path(ini_path))
lua_path = str(Path(lua_path))
translations_path = str(Path(translations_path))
def _stable_group_id(name: str) -> str:
ascii_slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "group"
digest = hashlib.md5(name.encode("utf-8")).hexdigest()[:8]
return f"{ascii_slug}-{digest}"
long_text_keys = {
"ServerWelcomeMessage",
"PublicDescription",
"Mods",
"Map",
"SpawnItems",
"ClientCommandFilter",
"ClientActionLogs",
"BadWordReplacement",
"BadWordListFile",
"GoodWordListFile",
"LootItemRemovalList",
"WorldItemRemovalList",
"Map.MapAllKnown",
}
@app.get("/")
def index() -> str:
translations = translations_path if Path(translations_path).exists() else None
ini_cfg = parse_server_ini(ini_path)
lua_cfg = parse_sandboxvars_lua(lua_path, translations_json=translations)
# Group Lua settings by their top-level table.
lua_groups: dict[str, list[Any]] = {}
for s in lua_cfg.settings:
lua_groups.setdefault(s.group, []).append(s)
for group_settings in lua_groups.values():
group_settings.sort(key=lambda s: s.line_index)
lua_groups_payload: list[dict[str, Any]] = []
for group_name, items in lua_groups.items():
first_line = min(s.line_index for s in items) if items else 0
lua_groups_payload.append(
{
"name": group_name,
"id": _stable_group_id(group_name),
"settings": items,
"count": len(items),
"order": first_line,
}
)
lua_groups_payload.sort(key=lambda g: g["order"])
lua_setting_count = sum(len(items) for items in lua_groups.values())
return render_template(
"index.html",
ini_settings=ini_cfg.settings,
lua_groups=lua_groups_payload,
lua_setting_count=lua_setting_count,
ini_path=ini_path,
lua_path=lua_path,
has_translations=Path(translations_path).exists(),
translations_path=translations_path,
long_text_keys=long_text_keys,
saved=request.args.get("saved") == "1",
)
def _read_updates() -> tuple[dict[str, object], dict[str, object], list[str]]:
translations = translations_path if Path(translations_path).exists() else None
ini_cfg = parse_server_ini(ini_path)
lua_cfg = parse_sandboxvars_lua(lua_path, translations_json=translations)
ini_updates: dict[str, object] = {}
lua_updates: dict[str, object] = {}
errors: list[str] = []
for s in ini_cfg.settings:
field = f"ini__{s.path}"
if s.value_type == "bool":
ini_updates[s.path] = field in request.form
continue
raw = request.form.get(field, "")
try:
ini_updates[s.path] = _coerce_value(s.value_type, raw)
except Exception:
errors.append(f"server.ini: {s.path} 值非法:{raw!r}")
for s in lua_cfg.settings:
field = f"lua__{s.path}"
if s.value_type == "bool":
lua_updates[s.path] = field in request.form
continue
raw = request.form.get(field, "")
try:
lua_updates[s.path] = _coerce_value(s.value_type, raw)
except Exception:
errors.append(f"server_SandboxVars.lua: {s.path} 值非法:{raw!r}")
return ini_updates, lua_updates, errors
@app.post("/download.zip")
def download_zip() -> Response:
translations = translations_path if Path(translations_path).exists() else None
ini_cfg = parse_server_ini(ini_path)
lua_cfg = parse_sandboxvars_lua(lua_path, translations_json=translations)
ini_updates, lua_updates, errors = _read_updates()
if errors:
return Response("\n".join(errors), status=400, mimetype="text/plain; charset=utf-8")
ini_out = write_server_ini(ini_cfg, ini_updates)
lua_out = write_sandboxvars_lua(lua_cfg, lua_updates)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr(Path(ini_path).name, ini_out)
zf.writestr(Path(lua_path).name, lua_out)
buf.seek(0)
filename = f"pz-config-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
return Response(
buf.getvalue(),
mimetype="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@app.post("/save")
def save_to_disk() -> Response:
translations = translations_path if Path(translations_path).exists() else None
ini_cfg = parse_server_ini(ini_path)
lua_cfg = parse_sandboxvars_lua(lua_path, translations_json=translations)
ini_updates, lua_updates, errors = _read_updates()
if errors:
return Response("\n".join(errors), status=400, mimetype="text/plain; charset=utf-8")
ini_out = write_server_ini(ini_cfg, ini_updates)
lua_out = write_sandboxvars_lua(lua_cfg, lua_updates)
Path(ini_path).write_text(ini_out, encoding="utf-8", newline="")
Path(lua_path).write_text(lua_out, encoding="utf-8", newline="")
return redirect(url_for("index", saved="1"))
return app
def main(argv: list[str]) -> int:
args = _parse_args(argv)
app = create_app(args.ini, args.lua, args.translations)
app.run(host=args.host, port=args.port, debug=args.debug)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))