From cc546031b01c27dd944f809b040e8e687050cb82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=AE=9D=E4=BB=99=E5=B0=8A?= Date: Thu, 5 Nov 2026 18:19:24 +0800 Subject: [PATCH] =?UTF-8?q?gmweb=EF=BC=9A=E6=B8=B8=E6=88=8F=E5=86=85?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E6=8D=A2=E8=A3=85=E3=80=81=E6=96=B9=E6=A1=88?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=8E=E5=85=A8=E5=B1=80=E7=83=AD=E9=94=AE?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gmweb/app.js | 3 + gmweb/index.html | 39 +++++ gmweb/loadout.js | 386 +++++++++++++++++++++++++++++++++++++++++++++++ gmweb/serve.py | 148 +++++++++++++++++- gmweb/style.css | 13 ++ 5 files changed, 586 insertions(+), 3 deletions(-) create mode 100644 gmweb/loadout.js diff --git a/gmweb/app.js b/gmweb/app.js index 5a24c70..3217247 100644 --- a/gmweb/app.js +++ b/gmweb/app.js @@ -67,6 +67,9 @@ panes[j].className = 'pane' + (panes[j].id === 'pane-' + name ? ' on' : ''); } try { if (location.hash !== '#' + name) location.hash = '#' + name; } catch (e) { } + if (name === 'loadout' && typeof window.LoadoutTab === 'function') { + window.LoadoutTab(); + } } function bindTabs() { diff --git a/gmweb/index.html b/gmweb/index.html index 86f86d9..e9a02c4 100644 --- a/gmweb/index.html +++ b/gmweb/index.html @@ -11,6 +11,7 @@
状态 角色 + 一键换装 发物品 关于
@@ -129,6 +130,43 @@
强化 0-31、增幅等级 0-31、锻造 0-8。增幅填了就直接覆盖强化(红字装不能同时白字强化),非装备行忽略这些值。
+
+
+ + + + +
+
+
+
换装页
+
+ + + + +
+
方案
+
+
+ +
+
保存后会给当前角色创建一套换装方案。
+
+
+
+
方案详情
+
未选择方案
+
+
+ + +
+
每次换装完成后进入 10 秒冷却;冷却期间点击按钮或按全局热键都会被服务端拒绝。
+
+
+
+
@@ -193,5 +231,6 @@
+ diff --git a/gmweb/loadout.js b/gmweb/loadout.js new file mode 100644 index 0000000..a697118 --- /dev/null +++ b/gmweb/loadout.js @@ -0,0 +1,386 @@ +/* 一键换装(游戏内 GM 窗口) + * IE11 兼容:ES5 + XMLHttpRequest,不使用箭头函数/模板字符串/flex。 + * 数据:/api/loadout/config(本地 config.json),角色/装备/应用走 serve.py 代理到 18111。 + */ +(function () { + 'use strict'; + + var S = { + cfg: null, + chars: [], + charName: '', + page: 0, + scheme: -1, + equipment: [], + cooldown: {}, + timer: null + }; + + function keyText(code) { + if (code >= 112 && code <= 123) { return 'F' + (code - 111); } + if (code >= 96 && code <= 105) { return 'Num' + (code - 96); } + if (code >= 48 && code <= 57) { return String.fromCharCode(code); } + if (code >= 65 && code <= 90) { return String.fromCharCode(code); } + if (code === 13) { return 'Enter'; } + if (code === 32) { return 'Space'; } + return 'VK' + code; + } + + function modText(mods) { + var out = []; + if (mods & 2) { out.push('Ctrl'); } + if (mods & 1) { out.push('Alt'); } + if (mods & 4) { out.push('Shift'); } + return out; + } + + function $(id) { return document.getElementById(id); } + + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"'); + } + + function log(text, cls) { + var box = $('log'); + if (!box) { return; } + var d = document.createElement('div'); + if (cls) { d.className = cls; } + var t = new Date(); + var p = function (n) { return (n < 10 ? '0' : '') + n; }; + d.innerHTML = '[' + p(t.getHours()) + ':' + p(t.getMinutes()) + ':' + p(t.getSeconds()) + '] ' + esc(text); + box.appendChild(d); + box.scrollTop = box.scrollHeight; + while (box.childNodes.length > 120) { box.removeChild(box.firstChild); } + } + + function api(path, method, body, cb) { + var xhr = new XMLHttpRequest(); + xhr.open(method || 'GET', '/api' + path, true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.onreadystatechange = function () { + if (xhr.readyState !== 4) { return; } + if (xhr.status === 0) { + log('连不上本地代理(gmweb/serve.py 未启动?)', 'err'); + if (cb) { cb(null); } + return; + } + var data = null; + try { data = JSON.parse(xhr.responseText); } catch (e) { data = null; } + if (!data) { + log('响应不是 JSON:' + String(xhr.responseText || '').substring(0, 120), 'err'); + if (cb) { cb(null); } + return; + } + if (data.ok === false || data.success === false) { + log('失败:' + (data.error || data.message || '未知错误'), 'err'); + if (cb) { cb(null); } + return; + } + if (cb) { cb(data); } + }; + xhr.send(body ? JSON.stringify(body) : null); + } + + function msg(text) { + var el = $('lo-msg'); + if (el) { el.innerHTML = esc(text || ''); } + } + + function currentPage() { + if (!S.cfg || !S.cfg.Pages || !S.cfg.Pages.length) { return null; } + if (S.page < 0 || S.page >= S.cfg.Pages.length) { S.page = 0; } + return S.cfg.Pages[S.page]; + } + + function normalizeConfig(data) { + if (!data || !data.Pages || !data.Pages.length) { + data = { ServerUrl: 'http://127.0.0.1:18111', AdminToken: '', ActivePage: 0, + Pages: [{ Name: '默认页', Schemes: [] }] }; + } + if (!data.Pages[0].Schemes) { data.Pages[0].Schemes = []; } + S.page = parseInt(data.ActivePage, 10) || 0; + if (S.page < 0 || S.page >= data.Pages.length) { S.page = 0; } + return data; + } + + function loadHotkeys() { + api('/loadout/hotkeys', 'GET', null, function (d) { + var el = $('lo-hotkeys'); + if (!el) { return; } + if (!d || !d.data || !(d.data.schemes || []).length) { + el.innerHTML = '全局热键:当前页没有配置快捷键'; + return; + } + var parts = []; + var schemes = d.data.schemes; + for (var i = 0; i < schemes.length; i++) { + var sc = schemes[i]; + var mods = modText(sc.modifiers || 0); + mods.push(keyText(sc.keycode)); + parts.push(esc(sc.name || '') + ' → ' + esc(mods.join('+'))); + } + el.innerHTML = '全局热键(当前页):' + parts.join(' '); + }); + } + + function loadConfig(cb) { + api('/loadout/config', 'GET', null, function (d) { + if (!d) { msg('读取换装配置失败'); if (cb) { cb(false); } return; } + S.cfg = normalizeConfig(d.data || {}); + renderPages(); + renderSchemes(); + loadHotkeys(); + if (cb) { cb(true); } + }); + } + + function saveConfig(cb) { + if (!S.cfg) { return; } + S.cfg.ActivePage = S.page; + api('/loadout/config', 'POST', S.cfg, function (d) { + if (d) { msg('换装配置已保存'); log('换装配置已保存', 'ok'); loadHotkeys(); } + if (cb) { cb(!!d); } + }); + } + + function renderPages() { + var sel = $('lo-page'); + if (!sel) { return; } + var pages = (S.cfg && S.cfg.Pages) || []; + var h = []; + for (var i = 0; i < pages.length; i++) { + h.push(''); + } + sel.innerHTML = h.join(''); + sel.value = String(S.page); + } + + function renderSchemes() { + var box = $('lo-schemes'); + if (!box) { return; } + var page = currentPage(); + var schemes = (page && page.Schemes) || []; + if (!schemes.length) { + box.innerHTML = '
当前页还没有方案
'; + S.scheme = -1; + renderDetail(); + return; + } + var h = []; + for (var i = 0; i < schemes.length; i++) { + var sc = schemes[i]; + h.push('
' + + '' + esc(sc.Name || ('方案 ' + (i + 1))) + '' + + '' + esc(sc.CharacterName || '') + + ' · ' + ((sc.Slots || []).length) + ' 件
'); + } + box.innerHTML = h.join(''); + var lis = box.getElementsByClassName('li'); + for (var k = 0; k < lis.length; k++) { + lis[k].onclick = function () { + S.scheme = parseInt(this.getAttribute('data-i'), 10); + renderSchemes(); + }; + } + if (S.scheme < 0 || S.scheme >= schemes.length) { S.scheme = 0; } + var active = box.getElementsByClassName('li')[S.scheme]; + if (active) { active.className = 'li on'; } + renderDetail(); + } + + function renderDetail() { + var page = currentPage(); + var schemes = (page && page.Schemes) || []; + var detail = $('lo-detail'); + var slotsBox = $('lo-slots'); + if (!detail || !slotsBox) { return; } + var sc = schemes[S.scheme]; + if (!sc) { + detail.innerHTML = '未选择方案'; + slotsBox.innerHTML = ''; + return; + } + detail.innerHTML = '' + esc(sc.Name || '') + '' + + '' + esc(sc.CharacterName || '未绑定角色') + ''; + var slots = sc.Slots || []; + if (!slots.length) { + slotsBox.innerHTML = '
该方案没有保存装备
'; + return; + } + var h = []; + for (var i = 0; i < slots.length; i++) { + h.push('
部位 ' + esc(slots[i].slot) + '' + + 'ID ' + esc(slots[i].item_id) + '
'); + } + slotsBox.innerHTML = h.join(''); + } + + function loadChars() { + api('/loadout/characters', 'GET', null, function (d) { + if (!d) { msg('角色读取失败,确认服务端已启动'); return; } + S.chars = d.data || []; + var sel = $('lo-char'); + if (!sel) { return; } + var old = S.charName; + var h = ['']; + for (var i = 0; i < S.chars.length; i++) { + var c = S.chars[i]; + h.push(''); + } + sel.innerHTML = h.join(''); + if (old) { sel.value = old; } + if (!sel.value && S.chars.length === 1) { sel.value = S.chars[0].name; } + S.charName = sel.value || ''; + msg('角色 ' + S.chars.length + ' 个'); + }); + } + + function fetchEquipment(characterName, cb) { + api('/local/dnf-gm/loadout/equipment?character_id=' + encodeURIComponent(characterName), 'GET', null, function (d) { + if (!d) { cb(null); return; } + cb((d.data && d.data.slots) || []); + }); + } + + function saveCurrent() { + if (!S.charName) { msg('请先选择角色'); return; } + if (!S.cfg) { return; } + msg('正在读取当前装备…'); + fetchEquipment(S.charName, function (slots) { + if (!slots) { msg('读取当前装备失败'); return; } + if (!slots.length) { msg('当前没有可保存的装备'); return; } + var name = window.prompt('方案名称', S.charName + ' 当前装备'); + if (!name) { msg('已取消'); return; } + var page = currentPage(); + if (!page) { return; } + if (!page.Schemes) { page.Schemes = []; } + page.Schemes.push({ + Name: String(name), + CharacterName: S.charName, + Modifiers: 0, + KeyCode: 0, + Slots: slots + }); + S.scheme = page.Schemes.length - 1; + saveConfig(function (ok) { + if (ok) { renderSchemes(); msg('已保存方案:' + name); } + }); + }); + } + + function cooldownRemaining(name) { + var until = S.cooldown[name] || 0; + return Math.max(0, Math.ceil((until - Date.now()) / 1000)); + } + + function tickCooldown() { + var el = $('lo-cooldown'); + var page = currentPage(); + var schemes = (page && page.Schemes) || []; + var sc = schemes[S.scheme]; + if (!el) { return; } + if (!sc) { el.innerHTML = ''; return; } + var remain = cooldownRemaining(sc.CharacterName || ''); + el.innerHTML = remain > 0 ? ('换装冷却 ' + remain + ' 秒') : ''; + } + + function applyScheme() { + var page = currentPage(); + var schemes = (page && page.Schemes) || []; + var sc = schemes[S.scheme]; + if (!sc) { msg('请先选择方案'); return; } + if (!sc.CharacterName || !(sc.Slots || []).length) { msg('方案没有角色或装备'); return; } + var remain = cooldownRemaining(sc.CharacterName); + if (remain > 0) { msg('换装冷却中,剩余 ' + remain + ' 秒'); return; } + msg('正在应用方案…'); + api('/local/dnf-gm/loadout/apply', 'POST', { character_id: sc.CharacterName, slots: sc.Slots }, function (d) { + if (!d) { msg('应用失败'); return; } + var r = d.data || {}; + var applied = (r.applied || []).length; + var skipped = (r.skipped || []).length; + var sec = parseInt(r.cooldown_seconds, 10) || 0; + if (sec > 0) { + S.cooldown[sc.CharacterName] = Date.now() + sec * 1000; + } + msg('应用完成:成功 ' + applied + ' 件,跳过 ' + skipped + ' 件' + + (sec > 0 ? (',冷却 ' + sec + ' 秒') : '')); + log('换装 ' + sc.Name + ':成功 ' + applied + ',跳过 ' + skipped, 'ok'); + tickCooldown(); + }); + } + + function addPage() { + var name = window.prompt('新换装页名称', '新页面'); + if (!name || !S.cfg) { return; } + S.cfg.Pages.push({ Name: String(name), Schemes: [] }); + S.page = S.cfg.Pages.length - 1; + S.scheme = -1; + saveConfig(function (ok) { if (ok) { renderPages(); renderSchemes(); } }); + } + + function renamePage() { + var page = currentPage(); + if (!page || !S.cfg) { return; } + var name = window.prompt('重命名换装页', page.Name || ''); + if (!name) { return; } + page.Name = String(name); + saveConfig(function (ok) { if (ok) { renderPages(); renderSchemes(); } }); + } + + function deletePage() { + if (!S.cfg || S.cfg.Pages.length <= 1) { msg('至少保留一个换装页'); return; } + var page = currentPage(); + if (!page) { return; } + if (!window.confirm('删除换装页「' + page.Name + '」及其方案?')) { return; } + S.cfg.Pages.splice(S.page, 1); + S.page = Math.max(0, S.page - 1); + S.scheme = -1; + saveConfig(function (ok) { if (ok) { renderPages(); renderSchemes(); } }); + } + + function bind() { + var sel = $('lo-page'); + if (sel) { + sel.onchange = function () { + S.page = parseInt(this.value, 10) || 0; + S.scheme = -1; + renderSchemes(); + loadHotkeys(); + }; + } + var ch = $('lo-char'); + if (ch) { ch.onchange = function () { S.charName = this.value; }; } + var b; + if ((b = $('lo-refresh'))) { b.onclick = function () { loadChars(); }; } + if ((b = $('lo-save'))) { b.onclick = saveCurrent; } + if ((b = $('lo-apply'))) { b.onclick = applyScheme; } + if ((b = $('lo-page-add'))) { b.onclick = addPage; } + if ((b = $('lo-page-ren'))) { b.onclick = renamePage; } + if ((b = $('lo-page-del'))) { b.onclick = deletePage; } + } + + function loadTab() { + msg('正在读取换装配置…'); + loadConfig(function (ok) { + if (!ok) { return; } + loadChars(); + if (!S.timer) { S.timer = setInterval(tickCooldown, 500); } + }); + } + + window.LoadoutTab = loadTab; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bind); + } else { + bind(); + } + + // 通过 #loadout 直接打开时,app.js 可能已经在 loadout.js 之前切过页 + if ((location.hash || '').replace('#', '') === 'loadout') { + setTimeout(loadTab, 0); + } +})(); diff --git a/gmweb/serve.py b/gmweb/serve.py index aa46237..7835a93 100644 --- a/gmweb/serve.py +++ b/gmweb/serve.py @@ -115,6 +115,125 @@ def proxy(method: str, path: str, body: bytes | None) -> tuple[int, bytes, str]: return 502, msg, "application/json; charset=utf-8" +# --------------------------------------------------------------------- 换装配置 + +def _loadout_config_path() -> Path: + """LoadoutTool/config.json,优先部署根目录,其次运行目录。""" + roots = [HERE.parent] + env = os.environ.get("DNF90_SERVER_ROOT") + if env: + roots.insert(0, Path(env)) + for root in roots: + p = root / "LoadoutTool" / "config.json" + if p.is_file(): + return p + return roots[0] / "LoadoutTool" / "config.json" + + +def _default_loadout_config() -> dict: + return { + "ServerUrl": "http://127.0.0.1:18111", + "AdminToken": "", + "Pages": [{"Name": "默认页", "Schemes": []}], + "ActivePage": 0, + } + + +def _read_loadout_config() -> dict: + p = _loadout_config_path() + try: + if p.is_file(): + data = json.loads(p.read_text(encoding="utf-8-sig")) + if isinstance(data, dict): + if not isinstance(data.get("Pages"), list) or not data["Pages"]: + data["Pages"] = [{"Name": "默认页", "Schemes": []}] + data["ActivePage"] = max(0, min(int(data.get("ActivePage") or 0), len(data["Pages"]) - 1)) + # AdminToken 只留在本地文件,网页端永远拿不到。 + data["AdminToken"] = "" + return data + except (OSError, ValueError, TypeError): + pass + return _default_loadout_config() + + +def _write_loadout_config(data: dict) -> tuple[int, bytes, str]: + if not isinstance(data, dict): + return 400, b'{"ok":false,"error":"config must be an object"}', "application/json" + pages = data.get("Pages") + if not isinstance(pages, list) or not pages: + return 400, b'{"ok":false,"error":"Pages must be a non-empty list"}', "application/json" + p = _loadout_config_path() + try: + p.parent.mkdir(parents=True, exist_ok=True) + # 网页提交的配置里没有 token;保留磁盘上原来的 token。 + existing = {} + if p.is_file(): + try: + existing = json.loads(p.read_text(encoding="utf-8-sig")) + except (OSError, ValueError): + existing = {} + data = dict(data) + data["AdminToken"] = existing.get("AdminToken", "") if isinstance(existing, dict) else "" + tmp = p.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(p) + except OSError as exc: + return 500, json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False).encode(), "application/json" + return 200, b'{"ok":true}', "application/json" + + +def _loadout_hotkeys() -> dict: + """Return the active page's configured global hotkeys for the UI hint.""" + cfg = _read_loadout_config() + pages = cfg.get("Pages") or [] + try: + page = pages[int(cfg.get("ActivePage") or 0)] + except (IndexError, TypeError, ValueError): + page = pages[0] if pages else {} + out = [] + for scheme in (page.get("Schemes") or []): + keycode = int(scheme.get("KeyCode") or 0) + if keycode <= 0: + continue + out.append({ + "name": scheme.get("Name") or "", + "character": scheme.get("CharacterName") or "", + "keycode": keycode, + "modifiers": int(scheme.get("Modifiers") or 0), + }) + return {"page": page.get("Name") or "", "schemes": out} + + +def _loadout_characters() -> tuple[int, bytes, str]: + """Character list for the loadout page. + + The admin endpoint without a selector is bound to the logged-in account and + can be empty when the game client is still at the login screen. The + launcher's local DB API already exposes the full local character list, so + reuse it here and keep the loadout page usable before entering the game. + """ + try: + req = request.Request("http://127.0.0.1:19527/api/characters", + headers={"Accept": "application/json"}) + with OPENER.open(req, timeout=8) as resp: + data = json.loads(resp.read().decode("utf-8")) + chars = data.get("data") if isinstance(data, dict) else None + out = [] + for c in chars or []: + if not isinstance(c, dict): + continue + out.append({ + "character_id": str(c.get("character_id") or ""), + "name": c.get("name") or "", + "level": c.get("level") or 0, + "online": bool(c.get("online")), + }) + return 200, json.dumps({"ok": True, "data": out}, ensure_ascii=False).encode(), "application/json; charset=utf-8" + except (OSError, ValueError) as exc: + return 502, json.dumps({"ok": False, "error": "读取角色失败:%s" % exc}, + ensure_ascii=False).encode(), "application/json; charset=utf-8" + + # --------------------------------------------------------------------- HTTP class Handler(BaseHTTPRequestHandler): @@ -146,18 +265,41 @@ class Handler(BaseHTTPRequestHandler): self._send(200, target.read_bytes(), MIME.get(target.suffix.lower(), "application/octet-stream")) def do_GET(self) -> None: + route = self.path.split("?", 1)[0] + if route == "/api/loadout/config": + payload = json.dumps({"ok": True, "data": _read_loadout_config()}, ensure_ascii=False).encode() + self._send(200, payload, "application/json; charset=utf-8") + return + if route == "/api/loadout/characters": + code, payload, ctype = _loadout_characters() + self._send(code, payload, ctype) + return + if route == "/api/loadout/hotkeys": + payload = json.dumps({"ok": True, "data": _loadout_hotkeys()}, ensure_ascii=False).encode() + self._send(200, payload, "application/json; charset=utf-8") + return if self.path.startswith("/api/"): code, data, ctype = proxy("GET", self.path[4:], None) self._send(code, data, ctype) return - self._static(self.path.split("?")[0]) + self._static(route) def do_POST(self) -> None: + route = self.path.split("?", 1)[0] + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length else b"{}" + if route == "/api/loadout/config": + try: + data = json.loads(body.decode("utf-8")) + except ValueError: + self._send(400, b'{"ok":false,"error":"invalid json"}', "application/json") + return + code, payload, ctype = _write_loadout_config(data) + self._send(code, payload, ctype) + return if not self.path.startswith("/api/"): self._send(404, b"not found", "text/plain") return - length = int(self.headers.get("Content-Length") or 0) - body = self.rfile.read(length) if length else b"{}" code, data, ctype = proxy("POST", self.path[4:], body) self._send(code, data, ctype) diff --git a/gmweb/style.css b/gmweb/style.css index 7c7f2c7..6fc3787 100644 --- a/gmweb/style.css +++ b/gmweb/style.css @@ -112,6 +112,19 @@ select.inp { padding: 3px 2px; } #log .tip { color: #e8c979; } .hint { color: #666e80; font-size: 11px; margin-top: 6px; } +/* 一键换装:IE11 兼容,float 两栏 */ +.lo-wrap { margin-top: 4px; overflow: hidden; } +.lo-col { float: left; box-sizing: border-box; } +.lo-left { width: 42%; padding-right: 10px; } +.lo-right { width: 58%; border-left: 1px solid #2b3040; padding-left: 10px; min-height: 260px; } +.lo-slots { margin-top: 6px; border: 1px solid #2b3040; background: #101319; max-height: 166px; overflow: auto; } +.lo-slot { padding: 3px 8px; border-bottom: 1px solid #1c212b; font-size: 11px; color: #9aa2b4; } +.lo-slot b { color: #dfe3ea; font-weight: normal; } +.lo-slot .id { color: #e8c979; margin-left: 8px; } +.lo-scheme-meta { color: #7c8496; font-size: 11px; margin-left: 6px; } +.lo-empty { padding: 10px; color: #666e80; text-align: center; } +#lo-cooldown { margin-left: 8px; } + /* 背包页:刚发进去那一行的「新」标记 */ .li .new { font-style: normal; font-size: 11px; padding: 0 4px; margin-left: 4px;