小助手内嵌 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:
1 parent
6e570fe820
commit
18e98ca7c9
13 files changed
+1206
-11
No files matched your search
+402
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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); }
|
||||
})();
|
||||
@@ -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
@@ -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:]))
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user