小助手内嵌 GM 页:网页重定向 + 物品分页 + 装备强化增幅发放

- 90CN.dll 新增 IE 控件接管,把腾讯小助手页面重定向到本地 GM 页
- gmweb:物品列表改分页(每页 10/20/50,列表固定 6 行高)
- 发放走 grant-options,支持强化 0-31、增幅属性+等级、锻造 0-8、品质
- 服务端补齐 amplify_level:等级统一写入 ext_data0/packed_flag_byte/reinforce/upgrade_level
- 增幅与强化互斥校验,避免写出客户端无法创建的装备
This commit is contained in:
hanfuit committed 2026-11-05 12:20:46 +08:00
1 parent 6e570fe820
commit 18e98ca7c9
13 files changed
+1206 -11

No files matched your search

+402
View File
@@ -0,0 +1,402 @@
/* GM 控制台前端 —— 严格 ES5 + XMLHttpRequest,兼容 IE11 内嵌浏览器。
所有请求走同源 /api/*,由本地 serve.py 代理到服务端 admin 口并注入 token。 */
(function () {
'use strict';
var S = {
charId: '', charName: '', chars: [],
itemId: 0, itemName: '', picked: null, items: [],
page: 1, pageSize: 20, total: 0
};
/* ---------------------------------------------------------------- 基础 */
function $(id) { return document.getElementById(id); }
function log(msg, cls) {
var box = $('log');
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(msg);
box.appendChild(d);
box.scrollTop = box.scrollHeight;
while (box.childNodes.length > 120) { box.removeChild(box.firstChild); }
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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('连不上本地代理(serve.py 没启动?)', 'err'); cb(null); return; }
var data = null;
try { data = JSON.parse(xhr.responseText); } catch (e) { data = null; }
if (!data) { log('响应不是 JSON:' + xhr.responseText.substring(0, 120), 'err'); cb(null); return; }
if (data.success === false || data.ok === false) {
log('失败:' + (data.message || data.error || '未知错误'), 'err');
cb(null);
return;
}
cb(data);
};
xhr.send(body ? JSON.stringify(body) : null);
}
function setOnline(on, text) {
$('dot').className = 'dot ' + (on ? 'on' : 'off');
$('stat').innerHTML = esc(text);
}
/* ---------------------------------------------------------------- 标签页 */
function switchTab(name) {
var tabs = document.getElementsByClassName('tab');
for (var i = 0; i < tabs.length; i++) {
tabs[i].className = 'tab' + (tabs[i].getAttribute('data-tab') === name ? ' on' : '');
}
var panes = document.getElementsByClassName('pane');
for (var j = 0; j < panes.length; j++) {
panes[j].className = 'pane' + (panes[j].id === 'pane-' + name ? ' on' : '');
}
}
function bindTabs() {
var tabs = document.getElementsByClassName('tab');
for (var i = 0; i < tabs.length; i++) {
tabs[i].onclick = function () { switchTab(this.getAttribute('data-tab')); };
}
}
/* ---------------------------------------------------------------- 状态页 */
function loadStatus() {
api('/dnf/gm/status', 'GET', null, function (d) {
if (!d) { setOnline(false, '服务端未响应'); return; }
var v = d.data || {};
$('v-online').innerHTML = esc(v.online_characters == null ? '-' : v.online_characters);
$('v-repo').innerHTML = v.repository_ready ? '就绪' : '未就绪';
$('v-items').innerHTML = v.item_catalog_ready ? '就绪' : '未就绪';
$('v-cap').innerHTML = esc(v.level_cap == null ? '-' : v.level_cap);
setOnline(true, '已连接 127.0.0.1:18111');
});
}
/* ---------------------------------------------------------------- 角色 */
function renderChars() {
var box = $('char-list');
if (!S.chars.length) { box.innerHTML = '<div class="empty">没有角色</div>'; return; }
var h = [];
for (var i = 0; i < S.chars.length; i++) {
var c = S.chars[i];
h.push('<div class="li' + (String(c.character_id) === String(S.charId) ? ' on' : '') +
'" data-id="' + esc(c.character_id) + '" data-name="' + esc(c.name) + '" data-gold="' + esc(c.gold) +
'" data-level="' + esc(c.level) + '"><span class="nm">' + esc(c.name) +
'</span><span class="meta">Lv.' + esc(c.level) + ' · 金币 ' + esc(c.gold) +
(c.online ? ' · 在线' : '') + '</span></div>');
}
box.innerHTML = h.join('');
var lis = box.getElementsByClassName('li');
for (var k = 0; k < lis.length; k++) {
lis[k].onclick = function () { pickChar(this); };
}
}
function pickChar(el) {
S.charId = el.getAttribute('data-id');
S.charName = el.getAttribute('data-name');
$('in-gold').value = el.getAttribute('data-gold');
$('in-level').value = el.getAttribute('data-level');
$('cur').innerHTML = esc(S.charName) + ' <span style="color:#7c8496">(id=' + esc(S.charId) + ')</span>';
renderChars();
log('已选角色:' + S.charName, 'tip');
}
function loadChars() {
var q = $('q-char').value;
var path = '/local/dnf-gm/characters' + (q ? '?character_name=' + encodeURIComponent(q) : '');
api(path, 'GET', null, function (d) {
if (!d) { return; }
S.chars = d.data || [];
renderChars();
log('角色 ' + S.chars.length + ' 个');
if (S.chars.length === 1) { pickChar($('char-list').getElementsByClassName('li')[0]); }
});
}
function setGold() {
if (!requireChar()) { return; }
var v = parseInt($('in-gold').value, 10);
if (isNaN(v) || v < 0) { log('金币要填非负整数', 'err'); return; }
api('/dnf/gm/characters/' + S.charId + '/gold', 'POST', { value: v }, function (d) {
if (d) { log('金币已改为 ' + v, 'ok'); loadChars(); }
});
}
function setLevel() {
if (!requireChar()) { return; }
var v = parseInt($('in-level').value, 10);
if (isNaN(v) || v < 1) { log('等级要填正整数', 'err'); return; }
api('/dnf/gm/characters/' + S.charId + '/level', 'POST', { level: v }, function (d) {
if (d) { log('等级已改为 ' + v, 'ok'); loadChars(); }
});
}
function requireChar() {
if (!S.charId) { log('先在「角色」页选一个角色', 'err'); return false; }
return true;
}
/* ---------------------------------------------------------------- 物品 */
/* 装备类型标签:PVF 里是 "[weapon]" 这种,去掉括号后映射成中文。 */
var EQUIP_LABEL = {
'weapon': '武器', 'coat': '上衣', 'shoulder': '头肩', 'pants': '下装',
'shoes': '鞋', 'waist': '腰带', 'amulet': '项链', 'wrist': '手镯',
'ring': '戒指', 'support': '辅助装备', 'magic stone': '魔法石',
'earring': '耳环', 'support weapon': '辅助武器', 'avatar': '时装',
'creature': '宠物', 'artifact': '神器'
};
/* 与服务器 gmItemSupportsEquipmentAttributes 保持一致:只有这些类型吃得下强化/增幅。 */
var UPGRADABLE = {
'weapon': 1, 'coat': 1, 'shoulder': 1, 'pants': 1, 'shoes': 1, 'waist': 1,
'amulet': 1, 'wrist': 1, 'ring': 1, 'support': 1, 'magic stone': 1,
'earring': 1, 'support weapon': 1
};
function equipTag(value) {
return String(value == null ? '' : value)
.replace(/[\[\]`]/g, '').replace(/^\s+|\s+$/g, '').toLowerCase();
}
function kindText(it) {
if (!it) { return ''; }
if (it.kind !== 'equipment') { return '道具'; }
var tag = equipTag(it.equipment_type);
return '装备 · ' + (EQUIP_LABEL[tag] || tag || '未知');
}
function canUpgrade(it) {
if (!it || it.kind !== 'equipment') { return false; }
return UPGRADABLE[equipTag(it.equipment_type)] === 1;
}
function clamp(v, lo, hi) {
if (isNaN(v)) { return lo; }
return v < lo ? lo : (v > hi ? hi : v);
}
function renderItems() {
var box = $('item-list');
var pages = S.total > 0 ? Math.ceil(S.total / S.pageSize) : 1;
$('page-info').innerHTML = '第 ' + S.page + ' / ' + pages + ' 页 · 共 ' + S.total + ' 条';
$('btn-prev').disabled = S.page <= 1;
$('btn-next').disabled = S.page >= pages;
if (!S.items.length) {
box.innerHTML = '<div class="empty">没有匹配的物品</div>';
return;
}
var h = [];
for (var i = 0; i < S.items.length; i++) {
var it = S.items[i];
h.push('<div class="li' + (it.item_id === S.itemId ? ' on' : '') +
'" data-i="' + i + '">' +
'<span class="nm">' + esc(it.name) + '</span>' +
'<span class="meta">id ' + esc(it.item_id) + ' · ' + esc(kindText(it)) +
(canUpgrade(it) ? '' : '') + '</span></div>');
}
box.innerHTML = h.join('');
var lis = box.getElementsByClassName('li');
for (var k = 0; k < lis.length; k++) {
lis[k].onclick = function () {
var index = parseInt(this.getAttribute('data-i'), 10);
pickItem(S.items[index]);
};
}
}
function renderPicked() {
var box = $('picked');
if (!S.itemId) {
box.innerHTML = '未选择物品 —— 先搜索,再点一行';
} else {
var it = S.picked;
var hint = '';
if (it && it.kind === 'equipment') {
hint = canUpgrade(it) ? '可强化 / 增幅' : '该类型不支持强化(时装类)';
} else {
hint = '普通道具,属性值会被忽略';
}
box.innerHTML = esc(S.itemName) + ' <span class="dim">(id ' + esc(S.itemId) + ')</span> · ' + esc(hint);
}
var off = !canUpgrade(S.picked);
$('in-reinforce').disabled = off;
$('in-amplify').disabled = off;
$('in-amplify-level').disabled = off;
$('in-forging').disabled = off;
$('in-quality').disabled = off;
}
function pickItem(it) {
if (!it) { return; }
S.itemId = it.item_id;
S.itemName = it.name;
S.picked = it;
renderItems();
renderPicked();
log('已选物品:' + it.name + '(id ' + it.item_id + ')', 'tip');
}
function loadItems(page) {
var q = $('q-item').value;
var size = parseInt($('in-page-size').value, 10) || 20;
var p = page || 1;
if (p < 1) { p = 1; }
S.pageSize = size;
var offset = (p - 1) * size;
api('/dnf/gm/items/page?limit=' + size + '&offset=' + offset +
'&q=' + encodeURIComponent(q || ''), 'GET', null, function (d) {
if (!d) { return; }
var pageData = d.data || {};
S.items = pageData.items || [];
S.total = pageData.total || 0;
S.page = p;
renderItems();
log('物品 ' + S.items.length + ' 条 / 共 ' + S.total + ' 条');
});
}
/* 组装强化/增幅/锻造/品质:增幅优先,红字装不再叠加白字强化。 */
function readOptions() {
var out = { reinforce: 0, amplify_type: 0, amplify_level: 0, forging: 0, equipment_quality: '' };
if (!canUpgrade(S.picked)) { return out; }
var amp = parseInt($('in-amplify').value, 10) || 0;
var ampLevel = parseInt($('in-amplify-level').value, 10) || 0;
var rein = parseInt($('in-reinforce').value, 10) || 0;
var forge = parseInt($('in-forging').value, 10) || 0;
if (amp > 0) {
out.amplify_type = clamp(amp, 1, 4);
out.amplify_level = clamp(ampLevel, 0, 31);
out.forging = 0;
} else {
out.reinforce = clamp(rein, 0, 31);
out.forging = clamp(forge, 0, 8);
}
out.equipment_quality = $('in-quality').value || '';
return out;
}
function optText(o) {
var parts = [];
if (o.amplify_type > 0) {
parts.push('增幅' + o.amplify_type + ' +' + o.amplify_level);
} else if (o.reinforce > 0) {
parts.push('强化 +' + o.reinforce);
}
if (o.forging > 0) { parts.push('锻造 +' + o.forging); }
if (o.equipment_quality) {
parts.push('品质' + (o.equipment_quality === 'top' ? '最上级' : '随机'));
}
return parts.length ? '(' + parts.join(',') + ')' : '';
}
function grantItem() {
if (!requireChar()) { return; }
if (!S.itemId) { log('先搜一个物品并选中', 'err'); return; }
var n = parseInt($('in-count').value, 10) || 1;
var o = readOptions();
api('/local/dnf-gm/item/grant-options', 'POST', {
character_id: String(S.charId), item_id: S.itemId, count: n,
reinforce: o.reinforce, amplify_type: o.amplify_type, amplify_level: o.amplify_level,
forging: o.forging, equipment_quality: o.equipment_quality
}, function (d) {
if (!d) { return; }
var data = d.data || {};
log('已发 ' + S.itemName + ' ×' + n + optText(o) +
(data.slot != null ? ',格位 ' + data.list_type + ':' + data.slot : ''), 'ok');
});
}
function mailItem() {
if (!requireChar()) { return; }
if (!S.itemId) { log('先搜一个物品并选中', 'err'); return; }
var n = parseInt($('in-count').value, 10) || 1;
var o = readOptions();
api('/dnf/gm/characters/' + S.charId + '/mail', 'POST', {
item_id: S.itemId, count: n, title: 'GM 物品', body: 'GM 发放',
expire_days: 7, reinforce: o.reinforce, amplify_type: o.amplify_type,
amplify_level: o.amplify_level, equipment_quality: o.equipment_quality,
forging: o.forging, emblem_item_ids: [], enchant_bead_item_id: 0
}, function (d) {
if (d) { log('已邮寄 ' + S.itemName + ' ×' + n + optText(o), 'ok'); }
});
}
/* ---------------------------------------------------------------- 邮件 */
function sendMail() {
if (!requireChar()) { return; }
var gold = parseInt($('in-goldmail').value, 10) || 0;
api('/local/dnf-gm/mail', 'POST', {
character_name: S.charName,
title: $('in-title').value || 'GM 邮件',
body: $('in-body').value || '',
gold: gold,
attachments: []
}, function (d) {
if (d) { log('邮件已发送给 ' + S.charName, 'ok'); }
});
}
/* ---------------------------------------------------------------- 启动 */
function bind(id, fn) {
var el = $(id);
if (el) { el.onclick = fn; }
}
function init() {
bindTabs();
bind('btn-refresh', function () { loadStatus(); log('刷新状态'); });
bind('btn-resync', function () {
if (!requireChar()) { return; }
api('/local/quest-resync', 'POST', { character_id: parseInt(S.charId, 10) }, function (d) {
if (d) { log('角色数据已同步', 'ok'); }
});
});
bind('btn-search-char', loadChars);
bind('btn-gold', setGold);
bind('btn-level', setLevel);
bind('btn-search-item', function () { loadItems(1); });
bind('btn-prev', function () { if (S.page > 1) { loadItems(S.page - 1); } });
bind('btn-next', function () { loadItems(S.page + 1); });
bind('btn-grant', grantItem);
bind('btn-mailitem', mailItem);
bind('btn-mail', sendMail);
$('in-page-size').onchange = function () { loadItems(1); };
$('q-char').onkeydown = function (e) { if ((e || window.event).keyCode === 13) { loadChars(); } };
$('q-item').onkeydown = function (e) { if ((e || window.event).keyCode === 13) { loadItems(1); } };
renderPicked();
log('GM 控制台已加载', 'tip');
loadStatus();
loadChars();
setInterval(loadStatus, 15000);
}
if (window.addEventListener) { window.addEventListener('load', init, false); }
else { window.attachEvent('onload', init); }
})();
+122
View File
@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="utf-8">
<title>GM 控制台</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="hd">
<div id="hd-t">GM 控制台</div>
<div id="hd-s"><span id="dot" class="dot off"></span><span id="stat">连接中…</span></div>
</div>
<div id="tabs">
<a class="tab on" data-tab="home" href="javascript:void(0)">状态</a>
<a class="tab" data-tab="char" href="javascript:void(0)">角色</a>
<a class="tab" data-tab="item" href="javascript:void(0)">发物品</a>
<a class="tab" data-tab="mail" href="javascript:void(0)">邮件</a>
</div>
<div id="body">
<div class="pane on" id="pane-home">
<table class="kv">
<tr><th>在线角色</th><td id="v-online">-</td></tr>
<tr><th>角色库</th><td id="v-repo">-</td></tr>
<tr><th>物品库</th><td id="v-items">-</td></tr>
<tr><th>等级上限</th><td id="v-cap">-</td></tr>
</table>
<div class="sec">当前角色</div>
<div id="cur" class="cur">未选择 —— 到「角色」页选一个</div>
<div class="sec">快捷操作</div>
<div class="row">
<button id="btn-refresh" class="btn">刷新状态</button>
<button id="btn-resync" class="btn">同步角色数据</button>
</div>
</div>
<div class="pane" id="pane-char">
<div class="row">
<input id="q-char" class="inp" placeholder="角色名 / 账号,留空列全部">
<button id="btn-search-char" class="btn">查询</button>
</div>
<div id="char-list" class="list"></div>
<div class="sec">改数值</div>
<div class="row">
<label class="lb">金币</label><input id="in-gold" class="inp sm" value="">
<button id="btn-gold" class="btn">写入</button>
</div>
<div class="row">
<label class="lb">等级</label><input id="in-level" class="inp sm" value="">
<button id="btn-level" class="btn">写入</button>
</div>
</div>
<div class="pane" id="pane-item">
<div class="row">
<input id="q-item" class="inp" placeholder="物品名关键字,如 太刀 / 强化">
<select id="in-page-size" class="inp sm">
<option value="10">每页 10 行</option>
<option value="20" selected>每页 20 行</option>
<option value="50">每页 50 行</option>
</select>
<button id="btn-search-item" class="btn">搜索</button>
</div>
<div id="item-list" class="list fixed"></div>
<div class="pager">
<button id="btn-prev" class="btn sm">上一页</button>
<span id="page-info" class="pg">-</span>
<button id="btn-next" class="btn sm">下一页</button>
</div>
<div id="picked" class="cur">未选择物品 —— 先搜索,再点一行</div>
<div class="sec">装备属性 <span class="dim">(武器 / 防具 / 首饰)</span></div>
<div class="row">
<label class="lb">强化</label><input id="in-reinforce" class="inp xs" value="0">
<label class="lb">增幅</label><select id="in-amplify" class="inp xs2">
<option value="0" selected>不增幅</option>
<option value="1">1 力量</option>
<option value="2">2 智力</option>
<option value="3">3 体力</option>
<option value="4">4 精神</option>
</select>
</div>
<div class="row">
<label class="lb">增幅等级</label><input id="in-amplify-level" class="inp xs" value="0">
<label class="lb">锻造</label><input id="in-forging" class="inp xs" value="0">
</div>
<div class="row">
<label class="lb">品质</label><select id="in-quality" class="inp sm">
<option value="" selected>默认</option>
<option value="random">随机</option>
<option value="top">最上级</option>
</select>
<label class="lb">数量</label><input id="in-count" class="inp xs" value="1">
</div>
<div class="row">
<button id="btn-grant" class="btn pri">发到背包</button>
<button id="btn-mailitem" class="btn">发到邮箱</button>
</div>
<div class="hint">强化 0-31、增幅等级 0-31、锻造 0-8。增幅填了就直接覆盖强化(红字装不能同时白字强化),非装备行忽略这些值。</div>
</div>
<div class="pane" id="pane-mail">
<div class="row"><label class="lb">标题</label><input id="in-title" class="inp" value="GM 邮件"></div>
<div class="row"><label class="lb">内容</label><input id="in-body" class="inp" value="来自 GM 的问候"></div>
<div class="row"><label class="lb">附金币</label><input id="in-goldmail" class="inp sm" value="0"></div>
<div class="row">
<button id="btn-mail" class="btn pri">发送到当前角色</button>
</div>
<div class="hint">收件人取「角色」页选中的角色;未选中会提示。</div>
</div>
</div>
<div id="log"></div>
<script src="/app.js"></script>
</body>
</html>
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""90CN GM 页本地托管 + 接口代理。
为什么需要这一层:
1. 客户端里那个窗口是 IE11 内核,页面要同源才能发 XHR,跨域到 18111 会被拦;
2. admin token 不应该写进页面 JS(会被任何人读到),由本进程在服务端注入。
用法:
python serve.py # 默认 127.0.0.1:18112
python serve.py --port 18112
python serve.py --runtime Z:\\90server\\Server\\runtime
页面访问:http://127.0.0.1:18112/
接口转发:/api/<path> -> http://127.0.0.1:18111/<path>
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib import error, request
HERE = Path(__file__).resolve().parent
MIME = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".png": "image/png",
".ico": "image/x-icon",
}
CONFIG = {"admin": "127.0.0.1:18111", "token": ""}
# 关键:本机回环不能走系统/环境代理。环境里存在 http_proxy 时 urllib 会照单全收,
# 结果连 127.0.0.1:18111 都被转发到代理,报 "upstream connect failed"。
OPENER = request.build_opener(request.ProxyHandler({}))
# --------------------------------------------------------------------- 发现
def _candidate_runtimes() -> list[Path]:
out: list[Path] = []
env = os.environ.get("DNF90_RUNTIME")
if env:
out.append(Path(env))
out += [
Path(r"Z:\90server\Server\runtime"),
HERE.parent / "server" / "runtime",
HERE.parent / "runtime",
]
return out
def discover_admin() -> tuple[str, str]:
"""从 instance.json / dnfbridge.toml 里读 adminListen 与 adminToken。"""
token_env = os.environ.get("DNF90_ADMIN_TOKEN")
for root in _candidate_runtimes():
inst = root / "config" / "instance.json"
if inst.is_file():
try:
cfg = json.loads(inst.read_text(encoding="utf-8"))
srv = cfg.get("server", {})
listen = srv.get("adminListen") or CONFIG["admin"]
token = token_env or srv.get("adminToken") or ""
if token:
return listen, token
except (OSError, ValueError):
pass
toml = root / "configs" / "dnfbridge.toml"
if toml.is_file():
try:
txt = toml.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
m = re.search(r'^\s*listen\s*=\s*"([^"]+)"', txt, re.M)
listen = m.group(1) if m else CONFIG["admin"]
m = re.search(r'^\s*token\s*=\s*"([^"]+)"', txt, re.M)
token = token_env or (m.group(1) if m else "")
if token:
return listen, token
return CONFIG["admin"], token_env or ""
# --------------------------------------------------------------------- 代理
def proxy(method: str, path: str, body: bytes | None) -> tuple[int, bytes, str]:
url = "http://%s%s" % (CONFIG["admin"], path)
headers = {"X-Admin-Token": CONFIG["token"]}
if method == "POST":
headers["Content-Type"] = "application/json"
# 服务端对写操作要求确认头 + 幂等键
headers["X-Admin-Confirm"] = "gmweb"
headers["X-Admin-Idempotency-Key"] = str(uuid.uuid4())
req = request.Request(url, data=body, headers=headers, method=method)
try:
with OPENER.open(req, timeout=20) as resp:
return resp.status, resp.read(), resp.headers.get("Content-Type", "application/json")
except error.HTTPError as exc:
return exc.code, exc.read(), exc.headers.get("Content-Type", "application/json")
except error.URLError as exc:
msg = json.dumps({"success": False, "message": "服务端未响应:%s" % exc.reason},
ensure_ascii=False).encode()
return 502, msg, "application/json; charset=utf-8"
# --------------------------------------------------------------------- HTTP
class Handler(BaseHTTPRequestHandler):
server_version = "90CN-GMWeb/1.0"
def log_message(self, fmt, *args): # 静默,避免刷屏
pass
def _send(self, code: int, payload: bytes, ctype: str) -> None:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
try:
self.wfile.write(payload)
except OSError:
pass
def _static(self, path: str) -> None:
rel = "index.html" if path in ("/", "") else path.lstrip("/")
target = (HERE / rel).resolve()
if HERE not in target.parents and target != HERE:
self._send(403, b"forbidden", "text/plain")
return
if not target.is_file():
self._send(404, b"not found", "text/plain")
return
self._send(200, target.read_bytes(), MIME.get(target.suffix.lower(), "application/octet-stream"))
def do_GET(self) -> None:
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])
def do_POST(self) -> None:
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)
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description="90CN GM 页本地托管 + 接口代理")
ap.add_argument("--port", type=int, default=18112)
ap.add_argument("--host", default="127.0.0.1")
args = ap.parse_args(argv)
listen, token = discover_admin()
CONFIG["admin"], CONFIG["token"] = listen, token
if not token:
print("警告:没找到 adminToken,接口会 401。可用 DNF90_ADMIN_TOKEN 环境变量指定。", file=sys.stderr)
srv = ThreadingHTTPServer((args.host, args.port), Handler)
print("GM 页: http://%s:%d/" % (args.host, args.port))
print("代理到: http://%s/api/ -> http://%s/" % (args.host + ":" + str(args.port), listen))
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+10
View File
@@ -0,0 +1,10 @@
@echo off
rem 启动 GM 页本地服务(127.0.0.1:18112)。pythonw 不留黑窗口。
rem token 与 admin 端口由 serve.py 自动从 runtime\config\instance.json 读取。
setlocal
cd /d "%~dp0"
set PYW=pythonw.exe
where pythonw.exe >nul 2>nul || set PYW=python.exe
start "" %PYW% "%~dp0serve.py" --port 18112
echo 已在后台启动:http://127.0.0.1:18112/
timeout /t 2 >nul
+98
View File
@@ -0,0 +1,98 @@
/* IE11 兼容:不用 CSS 变量、不用 flex、不用 grid。深色底 + 高对比文字。 */
html, body {
margin: 0; padding: 0; height: 100%;
background: #14161c; color: #d8dbe2;
font: 12px/1.5 "Microsoft YaHei", "SimSun", Tahoma, sans-serif;
overflow: hidden;
}
#hd {
height: 30px; padding: 0 10px;
background: #1d2029; border-bottom: 1px solid #2b3040;
line-height: 30px;
}
#hd-t { float: left; font-weight: bold; color: #e8c979; letter-spacing: 1px; }
#hd-s { float: right; color: #8d94a6; }
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 4px; margin-right: 6px; }
.dot.on { background: #4ec46a; }
.dot.off { background: #b04a4a; }
#tabs { height: 28px; background: #191c24; border-bottom: 1px solid #2b3040; padding-left: 4px; }
.tab {
float: left; padding: 0 14px; line-height: 28px; color: #98a0b3;
text-decoration: none; border-bottom: 2px solid transparent;
}
.tab:hover { color: #d8dbe2; }
.tab.on { color: #e8c979; border-bottom-color: #e8c979; background: #20242e; }
#body { position: absolute; top: 59px; bottom: 62px; left: 0; right: 0; overflow: auto; }
.pane { display: none; padding: 10px; }
.pane.on { display: block; }
table.kv { width: 100%; border-collapse: collapse; }
table.kv th {
text-align: left; width: 84px; color: #8d94a6; font-weight: normal;
padding: 4px 0; border-bottom: 1px solid #232734;
}
table.kv td { padding: 4px 0; border-bottom: 1px solid #232734; color: #dfe3ea; }
.sec {
margin: 12px 0 6px; color: #e8c979; font-size: 11px;
border-left: 3px solid #e8c979; padding-left: 6px; letter-spacing: 1px;
}
.cur {
padding: 8px; background: #1c2029; border: 1px solid #2b3040;
color: #9fd8a8;
}
.row { margin: 6px 0; }
.inp {
background: #0f1116; border: 1px solid #2f3547; color: #dfe3ea;
padding: 4px 6px; font: 12px "Microsoft YaHei", Tahoma, sans-serif;
width: 240px; outline: none;
}
.inp:focus { border-color: #e8c979; }
.inp.sm { width: 110px; }
.inp.xs { width: 60px; }
.inp.xs2 { width: 84px; }
select.inp { padding: 3px 2px; }
.inp[disabled] { opacity: .4; }
.lb { display: inline-block; width: 56px; color: #8d94a6; }
.btn {
background: #262c3a; border: 1px solid #39415a; color: #d8dbe2;
padding: 5px 12px; cursor: pointer; font: 12px "Microsoft YaHei", Tahoma, sans-serif;
margin-right: 6px;
}
.btn:hover { background: #303849; border-color: #4d5877; }
.btn.pri { background: #6b4f18; border-color: #a3781f; color: #ffe6ae; }
.btn.pri:hover { background: #7d5d1e; }
.btn[disabled] { opacity: .45; cursor: default; }
.list {
margin-top: 8px; max-height: 190px; overflow: auto;
border: 1px solid #2b3040; background: #101319;
}
/* 物品列表固定 6 行高度:行数再多也只占这一块,靠滚动 + 翻页看更多 */
.list.fixed { height: 156px; max-height: 156px; }
.li {
padding: 3px 8px; line-height: 20px; border-bottom: 1px solid #1c212b; cursor: pointer;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.li:hover { background: #1b2130; }
.li.on { background: #2a3550; color: #ffe6ae; }
.li .nm { color: #dfe3ea; }
.li .meta { color: #7c8496; font-size: 11px; margin-left: 6px; }
.empty { padding: 12px; color: #666e80; text-align: center; }
.dim { color: #7c8496; }
.pager { margin: 6px 0 2px; }
.pager .btn.sm { padding: 3px 10px; }
.pg { color: #8d94a6; font-size: 11px; margin: 0 8px; }
#log {
position: absolute; bottom: 0; left: 0; right: 0; height: 62px;
background: #0d0f14; border-top: 1px solid #2b3040;
padding: 4px 8px; overflow: auto; font: 11px/1.45 Consolas, monospace;
color: #8d94a6;
}
#log .ok { color: #6fcf87; }
#log .err { color: #e07070; }
#log .tip { color: #e8c979; }
.hint { color: #666e80; font-size: 11px; margin-top: 6px; }