Files
90CNServer/gmweb/app.js
T
hanfuit 06558415da GM 页改版:加回满疲劳/赞助页,去掉背包与邮件页和重复标题
- 角色页新增「回满疲劳」,走 POST /dnf/gm/characters/{id}/fatigue/refill(需角色下线)
- 新增「赞助」页:支付宝 / 微信收款码占位,图放 gmweb/qr/ 下即显示
- 删除背包页与邮件页(发到邮箱按钮保留在发物品页)
- 去掉页头「GM 控制台」文字(窗口标题栏已画标题,重复),页头只留连接状态
- serve.py 补 jpg/jpeg/gif/webp/bmp/svg 的 MIME
- 控制台宽度 1040 -> 920
2026-11-05 14:15:41 +08:00

516 lines
19 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: [],
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;
}
/* 一键回满疲劳:直接把库里 fatigue 写回上限。
服务端要求角色已下线(在线时写库会被下一个存档覆盖),所以在线角色会报错。 */
function refillFatigue() {
if (!requireChar()) { return; }
api('/dnf/gm/characters/' + S.charId + '/fatigue/refill', 'POST', {}, function (d) {
if (!d) { return; }
log('已回满 ' + S.charName + ' 的疲劳值', 'ok');
loadChars();
});
}
/* ---------------------------------------------------------------- 物品 */
/* 装备类型标签: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');
// 回游戏里看背包即可(客户端会自动刷新),这里不再切页打断操作
});
}
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'); }
});
}
/* ---------------------------------------------------------------- 赞助 */
/* 收款码图片是可选的:放了就显示,没放(404)就把占位提示顶上来。 */
function bindQr(id) {
var img = $(id);
if (!img) { return; }
img.onerror = function () {
var box = this.parentNode;
if (box) { box.className = 'qr-box miss'; }
this.style.display = 'none';
};
img.onload = function () {
var box = this.parentNode;
if (box) { box.className = 'qr-box'; }
this.style.display = '';
};
}
/* ---------------------------------------------------------------- 启动 */
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-mailitem', mailItem);
bind('btn-fatigue', refillFatigue);
$('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();
bindQr('qr-alipay');
bindQr('qr-wechat');
renderPicked();
forceIME();
log('GM 控制台已加载', 'tip');
loadStatus();
loadChars();
setInterval(loadStatus, 15000);
}
if (window.addEventListener) { window.addEventListener('load', init, false); }
else { window.attachEvent('onload', init); }
})();