/* 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, '>'); } 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' : ''); } try { if (location.hash !== '#' + name) location.hash = '#' + name; } catch (e) { } } function bindTabs() { var tabs = document.getElementsByClassName('tab'); for (var i = 0; i < tabs.length; i++) { tabs[i].onclick = function () { switchTab(this.getAttribute('data-tab')); }; } // 支持用 #item / #char / #about / #home 直接打开某一页 var h = (location.hash || '').replace('#', ''); if (h && document.getElementById('pane-' + h)) switchTab(h); window.onhashchange = function () { var n = (location.hash || '').replace('#', ''); if (n && document.getElementById('pane-' + n)) switchTab(n); }; } /* ---------------------------------------------------------------- 状态页 */ 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 = '
没有角色
'; return; } var h = []; for (var i = 0; i < S.chars.length; i++) { var c = S.chars[i]; h.push('
' + esc(c.name) + 'Lv.' + esc(c.level) + ' · 金币 ' + esc(c.gold) + (c.online ? ' · 在线' : '') + '
'); } 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) + ' (id=' + esc(S.charId) + ')'; 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 = '
没有匹配的物品
'; return; } var h = []; for (var i = 0; i < S.items.length; i++) { var it = S.items[i]; h.push('
' + '' + esc(it.name) + '' + 'id ' + esc(it.item_id) + ' · ' + esc(kindText(it)) + (canUpgrade(it) ? '' : '') + '
'); } 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) + ' (id ' + esc(S.itemId) + ') · ' + 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 = ['']; for (var i = 0; i < opts.length; i++) { h.push(''); } $('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; } function showMiss() { var box = img.parentNode; if (box) { box.className = 'qr-box miss'; } img.style.display = 'none'; } function showImg() { var box = img.parentNode; if (box) { box.className = 'qr-box'; } img.style.display = ''; } img.onerror = showMiss; img.onload = showImg; /* 图片是 HTML 里带的 src,可能在脚本执行前就已经 404 了,onerror 来不及触发 */ if (img.complete && img.naturalWidth === 0) { showMiss(); } } /* ---------------------------------------------------------------- 启动 */ 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'); bindQr('qr-gzh'); bindQr('qr-qq'); renderPicked(); forceIME(); log('GM 控制台已加载', 'tip'); loadStatus(); loadChars(); setInterval(loadStatus, 15000); } if (window.addEventListener) { window.addEventListener('load', init, false); } else { window.attachEvent('onload', init); } })();