Files
90CNServer/gmweb/app.js
T
hanfuit b22579658e 小井盖改为自绘 GM 窗口(WebView2)+ 背包可见 + 启动器互斥
- 90CN.dll:新增 90CN_gm_window.cpp,自绘标题栏/边框的置顶窗口,内嵌 WebView2 承载 gmweb 页面
- 点小井盖时隐藏游戏内嵌 IE 面板、弹出控制台;关掉后还原,独占全屏压不住时可选 minimize_game
- 窗口每轮确认置顶,避免游戏抢窗口栈后消失
- gmweb:新增背包页,发放后自动跳转并高亮新入包的行(GET /local/dnf-gm/inventory/list)
- 启动器:新增 /console-status,控制台运行时置灰 GM 工具入口并阻止进入
- 服务端指纹回绑新 DLL(1258496 / 7A179B1B…)
2026-11-05 13:16:52 +08:00

564 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* GM 控制台前端 —— 严格 ES5 + XMLHttpRequest,兼容 IE11 内嵌浏览器。
所有请求走同源 /api/*,由本地 serve.py 代理到服务端 admin 口并注入 token。 */
(function () {
'use strict';
var S = {
charId: '', charName: '', chars: [], bagRows: [],
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' : '');
}
if (name === 'bag') { loadBag(0); }
}
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');
}
/* 分组 -> 类型选项。装备/时装/宠物走 tag(PVF 的 equipment type),
道具走 segment(服务端按 stackable type 归类成中文段)。
有了这两级下拉,不敲键盘也能把范围缩到十几页。 */
var TYPE_OPTIONS = {
equipment: [
['tag:weapon', '武器'], ['tag:coat', '上衣'], ['tag:shoulder', '头肩'],
['tag:pants', '下装'], ['tag:shoes', '鞋'], ['tag:waist', '腰带'],
['tag:amulet', '项链'], ['tag:wrist', '手镯'], ['tag:ring', '戒指'],
['tag:support', '辅助装备'], ['tag:magic stone', '魔法石'],
['tag:earring', '耳环'], ['tag:support weapon', '辅助武器'],
['tag:title name', '称号'], ['tag:name tag', '名牌']
],
avatar: [
['tag:hat avatar', '帽子'], ['tag:hair avatar', '头发'], ['tag:face avatar', '脸部'],
['tag:coat avatar', '上衣'], ['tag:breast avatar', '胸部'], ['tag:waist avatar', '腰部'],
['tag:pants avatar', '下装'], ['tag:shoes avatar', '鞋'], ['tag:skin avatar', '皮肤'],
['tag:aurora avatar', '光环'], ['tag:weapon avatar', '武器装扮']
],
pet: [
['tag:creature', '宠物'], ['tag:artifact red', '神器·红'],
['tag:artifact blue', '神器·蓝'], ['tag:artifact green', '神器·绿']
],
stackable: [
['seg:消耗品', '消耗品'], ['seg:材料', '材料'], ['seg:特殊材料', '特殊材料'],
['seg:任务品', '任务品'], ['seg:徽章', '徽章'], ['seg:副职业材料', '副职业材料']
]
};
function fillTypeOptions() {
var opts = TYPE_OPTIONS[$('in-group').value] || [];
var h = ['<option value="">不限</option>'];
for (var i = 0; i < opts.length; i++) {
h.push('<option value="' + esc(opts[i][0]) + '">' + esc(opts[i][1]) + '</option>');
}
$('in-tag').innerHTML = h.join('');
}
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 group = $('in-group').value;
var picked = $('in-tag').value;
var minLv = parseInt($('in-lv-min').value, 10);
var maxLv = parseInt($('in-lv-max').value, 10);
if (isNaN(minLv)) { minLv = 0; }
if (isNaN(maxLv)) { maxLv = 0; }
var qs = 'limit=' + size + '&offset=' + ((p - 1) * size) +
'&q=' + encodeURIComponent(q || '');
if (group) { qs += '&group=' + encodeURIComponent(group); }
if (picked) {
if (picked.indexOf('tag:') === 0) {
qs += '&tag=' + encodeURIComponent(picked.substring(4));
} else if (picked.indexOf('seg:') === 0) {
qs += '&segment=' + encodeURIComponent(picked.substring(4));
}
}
if (minLv > 0) { qs += '&min_level=' + minLv; }
if (maxLv > 0) { qs += '&max_level=' + maxLv; }
api('/dnf/gm/items/page?' + qs, '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;
}
/* 增幅属性编号取自 etc/amplifyitem.etc 的 [option mapping table]。 */
var AMP_LABEL = { 1: '物防', 2: '魔防', 3: '物攻', 4: '魔攻' };
function optText(o) {
var parts = [];
if (o.amplify_type > 0) {
parts.push('增幅' + (AMP_LABEL[o.amplify_type] || 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');
// 发完立刻切到背包页并高亮这一行:到底有没有进包,玩家要看得见
switchTab('bag');
loadBag(S.itemId);
setTimeout(function () { loadBag(S.itemId); }, 600);
});
}
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'); }
});
}
/* ---------------------------------------------------------------- 背包 */
/* 背包 / 穿戴的实时快照。GM 发完东西,玩家第一件事就是看它到底进没进包,
所以这里按服务端实际存的行来列,不做任何本地猜测。 */
function loadBag(highlightItemId) {
if (!S.charId) {
$('bag-list').innerHTML = '<div class="empty">先在「角色」页选一个角色</div>';
$('bag-info').innerHTML = '-';
return;
}
api('/local/dnf-gm/inventory/list?character_id=' + encodeURIComponent(S.charId),
'GET', null, function (d) {
if (!d) { return; }
var data = d.data || {};
S.bagRows = data.rows || [];
renderBag(highlightItemId || 0);
});
}
function renderBag(highlightItemId) {
var box = $('bag-list');
var rows = S.bagRows || [];
var bagCount = 0, wearCount = 0, i;
for (i = 0; i < rows.length; i++) {
if (rows[i].collection === 'wear') { wearCount++; } else { bagCount++; }
}
$('bag-info').innerHTML = '背包 ' + bagCount + ' 件 · 穿戴 ' + wearCount + ' 件';
if (!rows.length) {
box.innerHTML = '<div class="empty">背包是空的</div>';
return;
}
// 刚发进去的排在最前面,其余保持服务端顺序
var ordered = [];
if (highlightItemId) {
for (i = 0; i < rows.length; i++) {
if (rows[i].item_id === highlightItemId) { ordered.push(rows[i]); }
}
}
for (i = 0; i < rows.length; i++) {
if (!highlightItemId || rows[i].item_id !== highlightItemId) { ordered.push(rows[i]); }
}
var h = [];
for (i = 0; i < ordered.length; i++) {
var r = ordered[i];
var isNew = highlightItemId && r.item_id === highlightItemId;
h.push('<div class="li' + (isNew ? ' on' : '') + '">' +
'<span class="nm">' + esc(r.item_name || ('物品 ' + r.item_id)) +
(isNew ? ' <em class="new">新</em>' : '') + '</span>' +
'<span class="meta">' + (r.collection === 'wear' ? '穿戴' : '背包') +
' · ' + esc(r.entry_key) + ' · id ' + esc(r.item_id) +
(r.count > 1 ? ' ×' + esc(r.count) : '') + '</span></div>');
}
box.innerHTML = h.join('');
}
/* ---------------------------------------------------------------- 邮件 */
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; }
}
/* 内嵌 IE 控件的输入法常被游戏主窗口占着,focus 时再主动要一次。
ime-mode:active 是 IE 私有属性,别的浏览器会忽略,不影响兼容。 */
function forceIME() {
var els = document.getElementsByTagName('input');
for (var i = 0; i < els.length; i++) {
els[i].onfocus = function () {
try { this.style.imeMode = 'active'; } catch (e) { }
};
}
}
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-bag', function () { loadBag(0); });
bind('btn-mailitem', mailItem);
bind('btn-mail', sendMail);
$('in-page-size').onchange = function () { loadItems(1); };
$('in-group').onchange = function () { fillTypeOptions(); loadItems(1); };
$('in-tag').onchange = function () { loadItems(1); };
/* 等级快捷档:点一下就把区间填好并重新查,省得手敲。 */
var lvBtns = document.getElementsByTagName('button');
for (var b = 0; b < lvBtns.length; b++) {
var spec = lvBtns[b].getAttribute('data-lv');
if (!spec) { continue; }
lvBtns[b].onclick = function () {
var pair = this.getAttribute('data-lv').split('-');
$('in-lv-min').value = pair[0];
$('in-lv-max').value = pair[1];
loadItems(1);
};
}
$('q-char').onkeydown = function (e) { if ((e || window.event).keyCode === 13) { loadChars(); } };
$('q-item').onkeydown = function (e) {
e = e || window.event;
// 229 是输入法组合中的键码,这时候的回车是上屏,不能抢。
if (e.keyCode === 229) { return; }
if (e.keyCode === 13) {
if (e.preventDefault) { e.preventDefault(); }
loadItems(1);
return false;
}
};
fillTypeOptions();
renderPicked();
forceIME();
log('GM 控制台已加载', 'tip');
loadStatus();
loadChars();
setInterval(loadStatus, 15000);
}
if (window.addEventListener) { window.addEventListener('load', init, false); }
else { window.attachEvent('onload', init); }
})();