服务端: - gm_tool_wallet.go: GMAdjustAccountCera 落库后遍历在线会话,推送账号级 点券钱包投影(op53),在线角色点券余额即时刷新,无需离线/重登 启动器前端: - app.js: 副本控制改名「魔改中心」;删除角色详情页疲劳值编辑块 (saveFatigue/maxFatigue 及相关输入框);黑钻提示改为「在线回城即时生效」 - index.html/shell.css/shell.js/style.css: 精简 GM 全屏标题栏(仅保留 窗口按钮)、移除 D 字图标及死样式、修正侧边栏底部间距
2290 lines
102 KiB
JavaScript
2290 lines
102 KiB
JavaScript
// ============================================================
|
||
// DNF90 单机模拟器 · 整合启动器 + GM 工具 前端逻辑
|
||
// ============================================================
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
async function api(path, opts) {
|
||
const res = await fetch(path, opts);
|
||
return res.json();
|
||
}
|
||
|
||
async function post(path, body) {
|
||
return api(path, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body || {}),
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// 通用工具
|
||
// ============================================================
|
||
function escapeHtml(s) {
|
||
if (s === null || s === undefined) return '';
|
||
return String(s).replace(/[&<>"']/g, c => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
}[c]));
|
||
}
|
||
|
||
function fmt(n) {
|
||
if (n === null || n === undefined || n === '') return '—';
|
||
const num = Number(n);
|
||
if (isNaN(num)) return String(n);
|
||
return num.toLocaleString('zh-CN');
|
||
}
|
||
|
||
function gmToast(msg, type = 'info', ms = 3200) {
|
||
let wrap = document.querySelector('.gm-toast-wrap');
|
||
if (!wrap) {
|
||
wrap = document.createElement('div');
|
||
wrap.className = 'gm-toast-wrap';
|
||
document.body.appendChild(wrap);
|
||
}
|
||
const el = document.createElement('div');
|
||
el.className = 'gm-toast ' + type;
|
||
const icon = type === 'ok' ? '✓' : type === 'err' ? '✕' : 'ℹ';
|
||
el.innerHTML = `<span style="font-weight:700">${icon}</span><span>${escapeHtml(msg)}</span>`;
|
||
wrap.appendChild(el);
|
||
setTimeout(() => {
|
||
el.style.transition = 'opacity 0.3s, transform 0.3s';
|
||
el.style.opacity = '0';
|
||
el.style.transform = 'translateX(30px)';
|
||
setTimeout(() => el.remove(), 300);
|
||
}, ms);
|
||
}
|
||
|
||
// 确认弹窗(危险操作)
|
||
function gmConfirm(title, message, confirmText, onConfirm) {
|
||
const mask = document.createElement('div');
|
||
mask.className = 'modal-mask';
|
||
mask.innerHTML = `
|
||
<div class="modal" style="width:460px">
|
||
<div class="modal-head"><span>${escapeHtml(title)}</span><button class="modal-close">×</button></div>
|
||
<div class="modal-body" style="text-align:left">
|
||
<div style="font-size:13.5px;color:#b8bcd0;line-height:1.7;margin-bottom:14px">${message}</div>
|
||
<input class="gm-input" id="confirmInput" placeholder="请输入 ${escapeHtml(confirmText)}" style="margin-top:4px">
|
||
</div>
|
||
<div class="modal-foot" style="padding:16px 20px;border-top:1px solid rgba(255,255,255,0.08);display:flex;justify-content:flex-end;gap:10px">
|
||
<button class="gm-btn gm-btn-ghost" id="confirmCancel">取消</button>
|
||
<button class="gm-btn gm-btn-danger" id="confirmOk">确认执行</button>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(mask);
|
||
const close = () => mask.remove();
|
||
mask.querySelector('.modal-close').addEventListener('click', close);
|
||
mask.querySelector('#confirmCancel').addEventListener('click', close);
|
||
mask.addEventListener('click', e => { if (e.target === mask) close(); });
|
||
mask.querySelector('#confirmOk').addEventListener('click', () => {
|
||
const val = mask.querySelector('#confirmInput').value.trim();
|
||
if (val !== confirmText) {
|
||
gmToast('请输入正确的确认词:' + confirmText, 'err');
|
||
return;
|
||
}
|
||
close();
|
||
onConfirm(val);
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// 视图切换
|
||
// ============================================================
|
||
function showLauncher() {
|
||
$('launcherView').classList.remove('hidden');
|
||
$('gmView').classList.add('hidden');
|
||
}
|
||
|
||
// ============================================================
|
||
// 启动器逻辑
|
||
// ============================================================
|
||
let busy = false;
|
||
|
||
function setBusy(b) {
|
||
busy = b;
|
||
['btnStart', 'btnGm', 'btnStop', 'btnSponsor'].forEach(id => {
|
||
const el = $(id);
|
||
if (el) el.disabled = b;
|
||
});
|
||
}
|
||
|
||
function appendLog(text, cls = '') {
|
||
const body = $('logBody');
|
||
const line = document.createElement('div');
|
||
line.className = 'log-line ' + cls;
|
||
line.textContent = text;
|
||
body.appendChild(line);
|
||
body.scrollTop = body.scrollHeight;
|
||
}
|
||
|
||
function classifyLog(text) {
|
||
if (text.includes('错误') || text.includes('失败') || text.includes('FAILED')) return 'error';
|
||
if (text.includes('完成') || text.includes('就绪') || text.includes('成功') || text.includes('已停止')) return 'ok';
|
||
return '';
|
||
}
|
||
|
||
async function pollLogs() {
|
||
try {
|
||
const r = await api('/api/logs');
|
||
if (r.ok && r.logs.length) {
|
||
r.logs.forEach(t => appendLog(t, classifyLog(t)));
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
async function refreshStatus() {
|
||
try {
|
||
const r = await api('/api/status');
|
||
if (!r.ok) return;
|
||
const badge = $('statusBadge');
|
||
const txt = $('statusText');
|
||
|
||
// 完整性检查:Server/ 或 Client/ 目录缺失 → 红字报错
|
||
const incomplete = (r.server_dir_exists === false) || (r.client_dir_exists === false);
|
||
if (incomplete) {
|
||
badge.className = 'status-badge off';
|
||
txt.textContent = '客户端或服务端不完整';
|
||
$('footInfo').textContent = '客户端或服务端不完整,请重新下载游戏';
|
||
$('footInfo').style.color = 'var(--danger)';
|
||
const btn = $('btnStart');
|
||
if (btn) {
|
||
btn.disabled = true;
|
||
btn.title = '客户端或服务端不完整,请重新下载游戏';
|
||
}
|
||
return;
|
||
}
|
||
|
||
$('footInfo').style.color = '';
|
||
if (r.server_running) {
|
||
badge.className = 'status-badge on';
|
||
txt.textContent = '服务端运行中';
|
||
} else {
|
||
badge.className = 'status-badge off';
|
||
txt.textContent = '服务端未运行';
|
||
}
|
||
$('footInfo').textContent = r.admin ? '已获管理员权限' : '注意:需管理员权限才能启动服务端';
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
async function action(name) {
|
||
if (busy) return;
|
||
setBusy(true);
|
||
try {
|
||
const r = await post('/api/action', { action: name });
|
||
if (!r.ok) {
|
||
appendLog('⚠ ' + (r.error || '操作失败'), 'error');
|
||
if (r.error && r.error.includes('管理员')) {
|
||
alert('需要管理员权限!\n请右键「以管理员身份运行」启动器,然后重试。');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
appendLog('⚠ 请求失败:' + e, 'error');
|
||
}
|
||
setTimeout(() => setBusy(false), 800);
|
||
}
|
||
|
||
function openSponsor() {
|
||
const box = $('qrcodeBox');
|
||
fetch('/sponsor.png').then(res => {
|
||
if (res.ok) {
|
||
box.innerHTML = '<img src="/sponsor.png" alt="赞助二维码">';
|
||
} else {
|
||
box.innerHTML = '<div class="qrcode-placeholder">赞助二维码即将上线<br>请先联系作者获取,或将 sponsor.png 放到启动器目录</div>';
|
||
}
|
||
}).catch(() => {
|
||
box.innerHTML = '<div class="qrcode-placeholder">赞助二维码加载失败</div>';
|
||
});
|
||
$('sponsorModal').classList.remove('hidden');
|
||
}
|
||
|
||
function closeSponsor() {
|
||
$('sponsorModal').classList.add('hidden');
|
||
}
|
||
|
||
// ============================================================
|
||
// 账号设置
|
||
// ============================================================
|
||
async function openAccounts() {
|
||
$('accountsModal').classList.remove('hidden');
|
||
await renderAccountsList();
|
||
}
|
||
|
||
function closeAccounts() {
|
||
$('accountsModal').classList.add('hidden');
|
||
}
|
||
|
||
async function renderAccountsList() {
|
||
const box = $('accountsList');
|
||
try {
|
||
const r = await api('/api/accounts-config');
|
||
const items = (r.data || []);
|
||
if (!items.length) {
|
||
box.innerHTML = '<div class="gm-empty">暂无账号</div>';
|
||
return;
|
||
}
|
||
box.innerHTML = items.map(it => `
|
||
<div class="account-row ${it.is_default ? 'default' : ''}">
|
||
<div class="account-info">
|
||
<div class="account-user">${escapeHtml(it.username)}${it.is_default ? ' <span class="gm-tag gm-tag-purple">默认</span>' : ''}</div>
|
||
<div class="account-pass">${'•'.repeat(Math.min(it.password.length, 12))}</div>
|
||
</div>
|
||
<div class="account-actions">
|
||
${it.is_default ? '' : `<button class="gm-btn gm-btn-sm gm-btn-primary" onclick="selectAccount('${escapeHtml(it.username)}')">设为默认</button>`}
|
||
<button class="gm-btn gm-btn-sm gm-btn-ghost" onclick="deleteAccount('${escapeHtml(it.username)}')" ${it.username === 'dnfplayer' ? 'disabled' : ''}>删除</button>
|
||
</div>
|
||
</div>`).join('');
|
||
} catch (e) {
|
||
box.innerHTML = `<div class="gm-empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function addAccount() {
|
||
const u = $('newAccUser').value.trim();
|
||
const p = $('newAccPass').value;
|
||
if (!u || !p) { gmToast('请填写用户名和密码', 'err'); return; }
|
||
const r = await post('/api/accounts-config', { op: 'add', username: u, password: p });
|
||
if (r.ok) {
|
||
gmToast('账号已添加', 'ok');
|
||
$('newAccUser').value = '';
|
||
$('newAccPass').value = '';
|
||
renderAccountsList();
|
||
} else {
|
||
gmToast('添加失败:' + (r.error || ''), 'err');
|
||
}
|
||
}
|
||
|
||
async function selectAccount(username) {
|
||
const r = await post('/api/accounts-config', { op: 'select', username });
|
||
if (r.ok) { gmToast('已设为默认账号', 'ok'); renderAccountsList(); }
|
||
else gmToast('失败:' + (r.error || ''), 'err');
|
||
}
|
||
|
||
async function deleteAccount(username) {
|
||
gmConfirm('删除账号', `确定删除账号「<b>${escapeHtml(username)}</b>」?<br>仅从启动器账号列表移除,不会影响游戏内已有角色。`, 'DELETE', async () => {
|
||
const r = await post('/api/accounts-config', { op: 'delete', username });
|
||
if (r.ok) { gmToast('账号已删除', 'ok'); renderAccountsList(); }
|
||
else gmToast('删除失败:' + (r.error || ''), 'err');
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// GM 工具逻辑
|
||
// ============================================================
|
||
const GM_PAGES = {
|
||
dashboard: ['控制台', '/ 服务端总览'],
|
||
characters: ['角色管理', '/ 查询与编辑角色'],
|
||
items: ['道具发放', '/ 发放道具到背包'],
|
||
mail: ['邮件系统', '/ 系统邮件'],
|
||
inventory: ['背包装备', '/ 查看背包'],
|
||
auction: ['拍卖行', '/ 挂单上下架'],
|
||
quests: ['任务管理', '/ 任务查询'],
|
||
dungeons: ['魔改中心', '/ 副本状态'],
|
||
wallet: ['钱包点券', '/ 钱包与黑钻'],
|
||
wipe: ['删档管理', '/ 删除角色与账号'],
|
||
settings: ['连接设置', '/ 数据源切换'],
|
||
};
|
||
|
||
let gmInited = false;
|
||
let gmCurrentPage = 'dashboard';
|
||
let currentCharId = null; // 当前选中的角色
|
||
|
||
// ============================================================
|
||
// 账号 / 角色 全局联动
|
||
// ============================================================
|
||
let ACCOUNT_LIST = []; // 全局账号缓存
|
||
let CHAR_LIST = []; // 全局角色缓存
|
||
|
||
async function loadAccounts() {
|
||
try {
|
||
const r = await api('/api/accounts');
|
||
ACCOUNT_LIST = Array.isArray(r.data) ? r.data : (Array.isArray(r) ? r : []);
|
||
} catch (e) { ACCOUNT_LIST = []; }
|
||
fillAllAccountSelects();
|
||
return ACCOUNT_LIST;
|
||
}
|
||
|
||
async function loadCharacters(accountId) {
|
||
try {
|
||
let url = '/api/characters';
|
||
if (accountId) url += '?account=' + encodeURIComponent(accountId);
|
||
const r = await api(url);
|
||
CHAR_LIST = Array.isArray(r.data) ? r.data : (Array.isArray(r) ? r : []);
|
||
} catch (e) { CHAR_LIST = []; }
|
||
fillAllCharSelects();
|
||
return CHAR_LIST;
|
||
}
|
||
|
||
// 角色下拉选项(含职业名、在线状态)
|
||
function charLabel(c) {
|
||
const pn = c.profession_name || c.professionName || '';
|
||
const on = c.online ? ' · 在线' : '';
|
||
return `${c.name || '未命名'}(ID:${c.character_id}${pn ? ' · ' + pn : ''}${on})`;
|
||
}
|
||
|
||
// 填充指定角色下拉框
|
||
function fillCharSelect(sel) {
|
||
if (!sel) return;
|
||
const prev = sel.value;
|
||
if (!CHAR_LIST.length) {
|
||
sel.innerHTML = '<option value="">(无角色,请先刷新)</option>';
|
||
return;
|
||
}
|
||
sel.innerHTML = CHAR_LIST.map(c =>
|
||
`<option value="${escapeHtml(c.character_id)}">${escapeHtml(charLabel(c))}</option>`).join('');
|
||
if (prev && CHAR_LIST.some(c => String(c.character_id) === String(prev))) sel.value = prev;
|
||
}
|
||
|
||
// 填充指定账号下拉框
|
||
function fillAccountSelect(sel) {
|
||
if (!sel) return;
|
||
const prev = sel.value;
|
||
if (!ACCOUNT_LIST.length) {
|
||
sel.innerHTML = '<option value="">(无账号)</option>';
|
||
return;
|
||
}
|
||
sel.innerHTML = ACCOUNT_LIST.map(a => {
|
||
const id = a.account_id ?? a.id ?? a.username ?? '';
|
||
const name = a.represent_account_name || a.username || a.name || id;
|
||
return `<option value="${escapeHtml(id)}">${escapeHtml(name)}(${escapeHtml(id)})</option>`;
|
||
}).join('');
|
||
if (prev && ACCOUNT_LIST.some(a => String(a.account_id ?? a.id ?? a.username) === String(prev))) sel.value = prev;
|
||
}
|
||
|
||
// 填充所有账号下拉框(data-account-select 标记)
|
||
function fillAllAccountSelects() {
|
||
document.querySelectorAll('select[data-account-select]').forEach(fillAccountSelect);
|
||
document.querySelectorAll('select[data-account-filter]').forEach(sel => {
|
||
const prev = sel.value;
|
||
sel.innerHTML = '<option value="">全部账号</option>' + ACCOUNT_LIST.map(a => {
|
||
const id = a.account_id ?? a.id ?? a.username ?? '';
|
||
const name = a.represent_account_name || a.username || a.name || id;
|
||
return `<option value="${escapeHtml(id)}">${escapeHtml(name)}(${escapeHtml(id)})</option>`;
|
||
}).join('');
|
||
if (prev) sel.value = prev;
|
||
});
|
||
}
|
||
|
||
// 填充所有角色下拉框(data-char-select 标记)
|
||
function fillAllCharSelects() {
|
||
document.querySelectorAll('select[data-char-select]').forEach(fillCharSelect);
|
||
}
|
||
|
||
// ============================================================
|
||
// 道具目录 / 分类(Native,服务端在线)
|
||
// ============================================================
|
||
const RARITY = { 0: '普通', 1: '高级', 2: '稀有', 3: '神器', 4: '史诗', 5: '传说', 6: '神话' };
|
||
|
||
const ITEM_TAG_LABELS = {
|
||
weapon: '武器', "support weapon": '辅助武器',
|
||
coat: '上衣', shoulder: '肩甲', pants: '下装', shoes: '鞋', waist: '腰带',
|
||
amulet: '项链', wrist: '手镯', ring: '戒指', support: '辅助装备',
|
||
"magic stone": '魔法石', earring: '耳环',
|
||
"title name": '称号', "name tag": '名称装饰',
|
||
creature: '宠物', "artifact red": '红色神器', "artifact blue": '蓝色神器', "artifact green": '绿色神器',
|
||
"hat avatar": '帽子', "hair avatar": '头发', "face avatar": '脸部', "coat avatar": '上衣装扮',
|
||
"breast avatar": '胸部', "waist avatar": '腰部', "pants avatar": '裤子', "shoes avatar": '鞋子',
|
||
"skin avatar": '皮肤', "aurora avatar": '光环', "weapon avatar": '武器装扮',
|
||
};
|
||
|
||
const ITEM_GROUPS = [
|
||
{ name: '武器', tags: ['weapon', 'support weapon'] },
|
||
{ name: '防具', tags: ['coat', 'shoulder', 'pants', 'shoes', 'waist'] },
|
||
{ name: '首饰', tags: ['amulet', 'wrist', 'ring', 'support', 'magic stone', 'earring'] },
|
||
{ name: '称号', tags: ['title name', 'name tag'] },
|
||
{ name: '宠物与神器', tags: ['creature', 'artifact red', 'artifact blue', 'artifact green'] },
|
||
{ name: '时装', tags: ['hat avatar', 'hair avatar', 'face avatar', 'coat avatar',
|
||
'breast avatar', 'waist avatar', 'pants avatar', 'shoes avatar',
|
||
'skin avatar', 'aurora avatar', 'weapon avatar'] },
|
||
];
|
||
|
||
function tagLabel(tag) { return ITEM_TAG_LABELS[tag] || tag; }
|
||
|
||
// 武器子类型(PVF 路径,服务端 q 参数可匹配 pvf_path)
|
||
const WEAPON_SUBS = [
|
||
{ job: '鬼剑士', subs: [['katana', '太刀'], ['ssword', '短剑'], ['hsword', '巨剑'], ['club', '钝器'], ['beamsword', '光剑']] },
|
||
{ job: '格斗家', subs: [['tonfa', '东方棍'], ['knuckle', '手套'], ['gauntlet', '臂铠'], ['claw', '爪'], ['boxglove', '拳套']] },
|
||
{ job: '神枪手', subs: [['revolver', '左轮枪'], ['automatic', '自动手枪'], ['hcannon', '手炮'], ['bowgun', '弩'], ['musket', '燧发枪']] },
|
||
{ job: '魔法师', subs: [['rod', '魔杖'], ['staff', '法杖'], ['spear', '长矛'], ['pole', '长棍'], ['broom', '扫把']] },
|
||
{ job: '圣职者', subs: [['scythe', '镰刀'], ['cross', '十字架'], ['rosary', '念珠'], ['axe', '战斧'], ['totem', '图腾']] },
|
||
{ job: '暗夜使者', subs: [['dagger', '匕首'], ['twinsword', '双剑'], ['wand', '手杖'], ['chakraweapon', '苦无']] },
|
||
{ job: '魔枪士', subs: [['pike', '长枪'], ['halberd', '战戟'], ['beamspear', '光枪'], ['javelin', '暗矛']] },
|
||
{ job: '枪剑士', subs: [['lblade', '长刀'], ['sblade', '小太刀'], ['mswd', '阔刀'], ['coreswd', '能量剑']] },
|
||
];
|
||
const WEAPON_SUB_LABELS = {};
|
||
WEAPON_SUBS.forEach(g => g.subs.forEach(x => { WEAPON_SUB_LABELS[x[0]] = x[1]; }));
|
||
|
||
// 道具浏览状态
|
||
let ITEM_CATS = null;
|
||
let ITEM_KIND = 'equipment'; // 顶层分区:equipment / stackable
|
||
let ITEM_FILTER = { tag: '', segment: '', group: '', label: '全部装备' };
|
||
let ITEM_SUB = '';
|
||
let ITEM_QUERY = '';
|
||
let ITEM_RARITY = -1;
|
||
let ITEM_PAGE = 0;
|
||
let ITEM_TOTAL = 0;
|
||
const ITEM_PAGE_SIZE = 30;
|
||
|
||
|
||
function initGm() {
|
||
gmInited = true;
|
||
document.querySelectorAll('.gm-nav-item').forEach(item => {
|
||
item.addEventListener('click', () => renderGmPage(item.dataset.page));
|
||
});
|
||
$('gmBtnRefresh').addEventListener('click', () => {
|
||
// 刷新时同步刷新账号/角色缓存
|
||
loadAccounts().then(() => loadCharacters());
|
||
renderGmPage(gmCurrentPage);
|
||
});
|
||
// 「返回启动器」:单窗口模式下回到启动器首页并恢复窗口
|
||
$('btnBackLauncher').addEventListener('click', () => {
|
||
if (typeof window.shellExitGm === 'function') {
|
||
window.shellExitGm();
|
||
} else {
|
||
showLauncher();
|
||
}
|
||
});
|
||
// 预加载账号/角色,供所有页面联动
|
||
loadAccounts().then(() => loadCharacters());
|
||
refreshGmHealth();
|
||
}
|
||
|
||
async function refreshGmHealth() {
|
||
try {
|
||
const r = await api('/api/gm/health');
|
||
if (!r.ok) return;
|
||
const dot = $('gmConnDot');
|
||
const txt = $('gmConnText');
|
||
if (r.server_online && r.native_available) {
|
||
dot.className = 'conn-dot on';
|
||
txt.textContent = '服务端在线 · Native';
|
||
} else if (r.db_exists) {
|
||
dot.className = 'conn-dot on';
|
||
txt.textContent = 'SQLite 直读';
|
||
} else {
|
||
dot.className = 'conn-dot off';
|
||
txt.textContent = '未发现数据库';
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function renderGmPage(page) {
|
||
gmCurrentPage = page;
|
||
const [title, crumb] = GM_PAGES[page] || [page, ''];
|
||
$('gmPageTitle').textContent = title;
|
||
$('gmPageCrumb').textContent = crumb;
|
||
document.querySelectorAll('.gm-nav-item').forEach(n => n.classList.toggle('active', n.dataset.page === page));
|
||
const content = $('gmContent');
|
||
const renderers = {
|
||
dashboard: renderDashboard,
|
||
characters: renderCharacters,
|
||
items: renderItems,
|
||
mail: renderMail,
|
||
inventory: renderInventory,
|
||
auction: renderAuction,
|
||
quests: renderQuests,
|
||
dungeons: renderDungeons,
|
||
wallet: renderWallet,
|
||
wipe: renderWipe,
|
||
settings: renderSettings,
|
||
};
|
||
(renderers[page] || renderDashboard)(content);
|
||
}
|
||
|
||
function loadingHtml(text = '加载中…') {
|
||
return `<div class="gm-loading"><div class="gm-spinner"></div><div>${escapeHtml(text)}</div></div>`;
|
||
}
|
||
|
||
// ---------- 控制台 ----------
|
||
async function renderDashboard(content) {
|
||
content.innerHTML = loadingHtml('正在获取服务端状态…');
|
||
try {
|
||
const status = await api('/api/status');
|
||
const health = await api('/api/gm/health');
|
||
const running = status.server_running;
|
||
const dbOk = status.db_exists;
|
||
content.innerHTML = `
|
||
<div class="gm-grid gm-grid-3">
|
||
<div class="gm-stat ${running ? 'accent' : ''}">
|
||
<div class="label">服务端状态</div>
|
||
<div class="value">${running ? '运行中' : '未运行'}</div>
|
||
<div class="ico-bg">🖥️</div>
|
||
</div>
|
||
<div class="gm-stat ${dbOk ? 'accent' : ''}">
|
||
<div class="label">数据库</div>
|
||
<div class="value">${dbOk ? '已就绪' : '未创建'}</div>
|
||
<div class="ico-bg">🗄️</div>
|
||
</div>
|
||
<div class="gm-stat ${health.native_available ? 'accent' : ''}">
|
||
<div class="label">Native API</div>
|
||
<div class="value">${health.native_available ? '可用' : '不可用'}</div>
|
||
<div class="ico-bg">🔌</div>
|
||
</div>
|
||
</div>
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>系统信息</div>
|
||
<div style="font-size:13px;color:#b8bcd0;line-height:2">
|
||
<div>服务端根目录:<span class="gm-num-cyan">${escapeHtml(status.server_root)}</span></div>
|
||
<div>客户端目录:<span class="gm-num-cyan">${escapeHtml(status.client_dir)}</span></div>
|
||
<div>数据库路径:<span class="gm-num-cyan">${escapeHtml(status.db || '(未创建)')}</span></div>
|
||
<div>管理员权限:${status.admin ? '<span class="gm-tag gm-tag-green">已获取</span>' : '<span class="gm-tag gm-tag-red">未获取</span>'}</div>
|
||
<div>赞助二维码:${status.sponsor_exists ? '<span class="gm-tag gm-tag-green">已配置</span>' : '<span class="gm-tag gm-tag-gray">未配置</span>'}</div>
|
||
</div>
|
||
</div>
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>使用提示</div>
|
||
<div style="font-size:13px;color:#b8bcd0;line-height:1.9">
|
||
<div>· 服务端<b>运行时</b>:使用 Native API 实时操作(改金币、发道具等)</div>
|
||
<div>· 服务端<b>停止时</b>:自动切到 SQLite 直读(可删除角色、删档、无限疲劳)</div>
|
||
<div>· <b>删除角色</b>必须先停止服务端,否则数据库被锁定</div>
|
||
</div>
|
||
</div>`;
|
||
} catch (e) {
|
||
content.innerHTML = `<div class="gm-empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
// ---------- 角色管理 ----------
|
||
async function renderCharacters(content) {
|
||
content.innerHTML = loadingHtml('正在加载角色列表…');
|
||
try {
|
||
await loadAccounts();
|
||
const r = await api('/api/characters');
|
||
const chars = (r.data || []);
|
||
const accountOptions = '<option value="">全部账号</option>' + ACCOUNT_LIST.map(a => {
|
||
const id = a.account_id ?? a.id ?? a.username ?? '';
|
||
const name = a.represent_account_name || a.username || a.name || id;
|
||
return `<option value="${escapeHtml(id)}">${escapeHtml(name)}(${escapeHtml(id)})</option>`;
|
||
}).join('');
|
||
|
||
const renderRows = (list) => list.map(c => `
|
||
<tr>
|
||
<td><div style="display:flex;align-items:center;gap:10px">
|
||
<div class="gm-avatar">${escapeHtml((c.name || '?').charAt(0))}</div>
|
||
<div>
|
||
<div style="font-weight:600;color:#f2f3f8">${escapeHtml(c.name || '未命名')}</div>
|
||
<div style="font-size:11px;color:#6d7188">ID: ${escapeHtml(c.character_id)}</div>
|
||
</div>
|
||
</div></td>
|
||
<td>${escapeHtml(c.profession_name || c.job || '0')}</td>
|
||
<td><span class="gm-tag gm-tag-purple">Lv.${fmt(c.level)}</span></td>
|
||
<td>${escapeHtml(c.account_id || '—')}</td>
|
||
<td><button class="gm-btn gm-btn-sm gm-btn-primary" onclick="openCharDetail('${escapeHtml(c.character_id)}')">详情/编辑</button></td>
|
||
</tr>`).join('');
|
||
|
||
content.innerHTML = `
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>角色列表(${chars.length})</div>
|
||
<div class="gm-search-row" style="margin-bottom:12px">
|
||
<div class="gm-field" style="flex:1;max-width:320px">
|
||
<label>按账号筛选</label>
|
||
<select class="gm-input" id="charFilterAccount" onchange="onCharFilterAccount(this.value)">${accountOptions}</select>
|
||
</div>
|
||
</div>
|
||
<div class="gm-table-wrap">
|
||
<table class="gm-table">
|
||
<thead><tr><th>角色</th><th>职业</th><th>等级</th><th>账号</th><th>操作</th></tr></thead>
|
||
<tbody id="charTableBody">${renderRows(chars)}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>`;
|
||
} catch (e) {
|
||
content.innerHTML = `<div class="gm-empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function onCharFilterAccount(accountId) {
|
||
const r = await api('/api/characters' + (accountId ? '?account=' + encodeURIComponent(accountId) : ''));
|
||
const chars = (r.data || []);
|
||
const body = $('charTableBody');
|
||
if (body) {
|
||
body.innerHTML = chars.map(c => `
|
||
<tr>
|
||
<td><div style="display:flex;align-items:center;gap:10px">
|
||
<div class="gm-avatar">${escapeHtml((c.name || '?').charAt(0))}</div>
|
||
<div>
|
||
<div style="font-weight:600;color:#f2f3f8">${escapeHtml(c.name || '未命名')}</div>
|
||
<div style="font-size:11px;color:#6d7188">ID: ${escapeHtml(c.character_id)}</div>
|
||
</div>
|
||
</div></td>
|
||
<td>${escapeHtml(c.profession_name || c.job || '0')}</td>
|
||
<td><span class="gm-tag gm-tag-purple">Lv.${fmt(c.level)}</span></td>
|
||
<td>${escapeHtml(c.account_id || '—')}</td>
|
||
<td><button class="gm-btn gm-btn-sm gm-btn-primary" onclick="openCharDetail('${escapeHtml(c.character_id)}')">详情/编辑</button></td>
|
||
</tr>`).join('') || '<tr><td colspan="5" class="gm-empty">无角色</td></tr>';
|
||
}
|
||
}
|
||
|
||
// 打开角色详情(编辑等级/金币/疲劳)
|
||
async function openCharDetail(charId) {
|
||
currentCharId = charId;
|
||
const content = $('gmContent');
|
||
content.innerHTML = loadingHtml('正在加载角色详情…');
|
||
try {
|
||
const r = await api('/api/character?id=' + encodeURIComponent(charId));
|
||
const ch = r.data || {};
|
||
content.innerHTML = `
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>角色:${escapeHtml(ch.name || charId)}</div>
|
||
<div style="font-size:13px;color:#b8bcd0;margin-bottom:16px;line-height:1.9">
|
||
<span class="gm-tag gm-tag-purple">Lv.${fmt(ch.level)}</span>
|
||
<span class="gm-tag gm-tag-gray">职业 ${escapeHtml(ch.job || '0')}</span>
|
||
<span class="gm-tag gm-tag-gray">账号 ${escapeHtml(ch.account_id || '—')}</span>
|
||
<span class="gm-tag gm-tag-gold">金币 ${fmt(ch.gold ?? ch.stats?.gold)}</span>
|
||
</div>
|
||
|
||
<div class="gm-form-row" style="margin-bottom:18px">
|
||
<div class="gm-field">
|
||
<label>等级</label>
|
||
<div style="display:flex;gap:8px">
|
||
<input class="gm-input" id="editLevel" type="number" value="${fmt(ch.level)}">
|
||
<button class="gm-btn gm-btn-sm gm-btn-primary" onclick="saveLevel()">设置</button>
|
||
</div>
|
||
</div>
|
||
<div class="gm-field">
|
||
<label>金币</label>
|
||
<div style="display:flex;gap:8px">
|
||
<input class="gm-input" id="editGold" type="number" value="${fmt(ch.gold ?? ch.stats?.gold ?? 0)}">
|
||
<button class="gm-btn gm-btn-sm gm-btn-primary" onclick="saveGold()">设置</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="gm-card-title" style="margin-top:8px"><span class="dot"></span>职业 / 转职</div>
|
||
<div id="careerPanel" style="margin-bottom:10px"><div class="gm-hint">正在加载职业信息…</div></div>
|
||
</div>
|
||
|
||
<div class="gm-card gm-danger-zone">
|
||
<div class="gm-card-title"><span class="dot"></span>删除角色(危险操作)</div>
|
||
<div style="font-size:13px;color:#b8bcd0;margin-bottom:12px;line-height:1.8">
|
||
将删除该角色的全部数据(角色、装备、背包、邮件、任务、技能等)。<br>
|
||
<span style="color:#ff5d6c">此操作不可恢复,且需先停止服务端!</span>
|
||
</div>
|
||
<button class="gm-btn gm-btn-danger" onclick="deleteCurrentChar()">🗑️ 删除该角色</button>
|
||
</div>`;
|
||
loadCareer();
|
||
} catch (e) {
|
||
content.innerHTML = `<div class="gm-empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
// ---------- 转职 / 职业 ----------
|
||
let CAREER_OPTIONS = [];
|
||
|
||
async function loadCareer() {
|
||
const panel = $('careerPanel');
|
||
if (!panel) return;
|
||
panel.innerHTML = '<div class="gm-hint">正在加载职业信息…</div>';
|
||
try {
|
||
const r = await api('/api/character/career?id=' + encodeURIComponent(currentCharId));
|
||
if (!r.ok) { panel.innerHTML = '<div class="gm-hint">' + escapeHtml(r.error || '加载失败') + '</div>'; return; }
|
||
const d = r.data || {};
|
||
CAREER_OPTIONS = d.options || [];
|
||
const curJob = Number(d.job ?? 0);
|
||
const curFirst = Number(d.first_grow ?? d.first ?? 0);
|
||
const curAwakening = Number(d.awakening ?? 0);
|
||
|
||
const jobOpts = CAREER_OPTIONS.map(o => {
|
||
const sel = Number(o.job) === curJob ? 'selected' : '';
|
||
return `<option value="${o.job}" ${sel}>${escapeHtml(o.name || ('职业 ' + o.job))}</option>`;
|
||
}).join('');
|
||
|
||
panel.innerHTML = `
|
||
<div style="font-size:13px;color:#b8bcd0;margin-bottom:8px">
|
||
当前职业:<span class="gm-tag gm-tag-purple">${escapeHtml(d.profession_name || '未知')}</span>
|
||
</div>
|
||
<div class="gm-form-row" style="margin-bottom:10px">
|
||
<div class="gm-field">
|
||
<label>基础职业</label>
|
||
<select class="gm-input" id="careerJob" onchange="onCareerJobChange()">${jobOpts || '<option value="">(无可选职业)</option>'}</select>
|
||
</div>
|
||
<div class="gm-field">
|
||
<label>转职分支</label>
|
||
<select class="gm-input" id="careerFirst" onchange="onCareerFirstChange()"></select>
|
||
</div>
|
||
<div class="gm-field">
|
||
<label>觉醒阶段</label>
|
||
<select class="gm-input" id="careerAwakening"></select>
|
||
</div>
|
||
</div>
|
||
<div class="gm-toolbar">
|
||
<button class="gm-btn gm-btn-sm gm-btn-primary" onclick="applyCareer()">🔄 应用转职</button>
|
||
<span class="gm-hint">转职会重置不兼容技能并按新职业重建基础技能;角色需处于离线状态。</span>
|
||
</div>`;
|
||
onCareerJobChange();
|
||
} catch (e) {
|
||
panel.innerHTML = '<div class="gm-hint">加载失败:' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
function onCareerJobChange() {
|
||
const job = Number($('careerJob').value);
|
||
const opt = CAREER_OPTIONS.find(o => Number(o.job) === job);
|
||
const firstSel = $('careerFirst');
|
||
const branches = (opt && opt.branches) || [];
|
||
firstSel.innerHTML = branches.map(b =>
|
||
`<option value="${b.value}">${escapeHtml(b.name || ('分支 ' + b.value))}</option>`
|
||
).join('') || '<option value="0">(无分支)</option>';
|
||
onCareerFirstChange();
|
||
}
|
||
|
||
function onCareerFirstChange() {
|
||
const job = Number($('careerJob').value);
|
||
const first = Number($('careerFirst').value);
|
||
const opt = CAREER_OPTIONS.find(o => Number(o.job) === job);
|
||
const branch = opt && (opt.branches || []).find(b => Number(b.value) === first);
|
||
const awkSel = $('careerAwakening');
|
||
const awakenings = (branch && branch.awakenings) || [];
|
||
awkSel.innerHTML = awakenings.map(a =>
|
||
`<option value="${a.value}">${escapeHtml(a.name || ('觉醒 ' + a.value))}</option>`
|
||
).join('') || '<option value="0">(无觉醒)</option>';
|
||
}
|
||
|
||
async function applyCareer() {
|
||
const job = Number($('careerJob').value);
|
||
const first = Number($('careerFirst').value || 0);
|
||
const awakening = Number($('careerAwakening').value || 0);
|
||
gmConfirm('应用转职', '确定要将该角色转职为所选职业吗?<br>转职会<b style="color:#ffd66b">重置不兼容技能</b>并按新职业重建基础技能,请确认角色当前处于<b style="color:#ff5d6c">离线状态</b>。', '转职', async () => {
|
||
const r = await post('/api/character/career', { id: currentCharId, job, first, awakening });
|
||
if (r.ok) { gmToast('转职成功', 'ok'); loadCareer(); }
|
||
else gmToast('转职失败:' + (r.error || ''), 'err');
|
||
});
|
||
}
|
||
|
||
async function saveLevel() {
|
||
const level = $('editLevel').value;
|
||
const r = await post('/api/character/level', { id: currentCharId, level: parseInt(level) });
|
||
if (r.ok) gmToast('等级已更新', 'ok'); else gmToast('失败:' + (r.error || ''), 'err');
|
||
}
|
||
|
||
async function saveGold() {
|
||
const amount = $('editGold').value;
|
||
const r = await post('/api/character/gold', { id: currentCharId, amount: parseInt(amount) });
|
||
if (r.ok) gmToast('金币已更新', 'ok'); else gmToast('失败:' + (r.error || ''), 'err');
|
||
}
|
||
|
||
async function deleteCurrentChar() {
|
||
gmConfirm('删除角色', '确定要删除该角色的全部数据吗?<br>此操作<b style="color:#ff5d6c">不可恢复</b>,且需先停止服务端。', 'DELETE', async () => {
|
||
const r = await post('/api/character/delete', { id: currentCharId, confirm: 'DELETE' });
|
||
if (r.ok) {
|
||
gmToast('角色已删除(' + (r.data?.deleted || 0) + ' 条记录)', 'ok');
|
||
renderGmPage('characters');
|
||
} else {
|
||
gmToast('删除失败:' + (r.error || ''), 'err');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------- 道具发放 ----------
|
||
async function renderItems(content) {
|
||
content.innerHTML = `
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>发放道具</div>
|
||
<div class="gm-form-row" style="margin-bottom:10px">
|
||
<div class="gm-field">
|
||
<label>目标角色(联动账号角色列表)</label>
|
||
<select class="gm-input" id="item-char" data-char-select onchange="updateGrantTip()"></select>
|
||
</div>
|
||
<div class="gm-field">
|
||
<label>发放方式</label>
|
||
<select class="gm-input" id="grant-mode" onchange="updateGrantTip()">
|
||
<option value="bag">直接进背包</option>
|
||
<option value="mail">邮件发放</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="gm-form-row" style="margin-bottom:10px">
|
||
<div class="gm-field">
|
||
<label>道具 ID</label>
|
||
<input class="gm-input" id="item-id" type="number" placeholder="从下方列表「选用」自动填入">
|
||
</div>
|
||
<div class="gm-field">
|
||
<label>数量</label>
|
||
<input class="gm-input" id="item-count" type="number" value="1">
|
||
</div>
|
||
</div>
|
||
<div id="grant-mail-fields" style="display:none;margin-bottom:10px">
|
||
<div class="gm-form-row">
|
||
<div class="gm-field"><label>邮件标题</label><input class="gm-input" id="mail-title" placeholder="标题"></div>
|
||
<div class="gm-field"><label>邮件正文</label><input class="gm-input" id="mail-body" placeholder="正文"></div>
|
||
</div>
|
||
</div>
|
||
<div class="gm-subtitle" style="margin:6px 0 8px;font-size:12.5px;color:var(--text-dim)">装备高级选项(仅装备生效,发放消耗品/材料时忽略)</div>
|
||
<div class="gm-form-row" style="margin-bottom:10px">
|
||
<div class="gm-field"><label>强化 (0-31)</label><input class="gm-input" id="opt-reinforce" type="number" min="0" max="31" value="0"></div>
|
||
<div class="gm-field"><label>增幅 (0-4)</label><input class="gm-input" id="opt-amplify" type="number" min="0" max="4" value="0"></div>
|
||
<div class="gm-field"><label>锻造 (0-8)</label><input class="gm-input" id="opt-forging" type="number" min="0" max="8" value="0"></div>
|
||
</div>
|
||
<div class="gm-form-row" style="margin-bottom:10px">
|
||
<div class="gm-field">
|
||
<label>品级</label>
|
||
<select class="gm-input" id="opt-quality">
|
||
<option value="">默认</option>
|
||
<option value="random">随机</option>
|
||
<option value="top">最上级</option>
|
||
</select>
|
||
</div>
|
||
<div class="gm-field"><label>附魔宝珠 ID</label><input class="gm-input" id="opt-enchant" type="number" placeholder="附魔宝珠 item_id(可空)"></div>
|
||
</div>
|
||
<div class="gm-hint" style="margin-bottom:10px">品级仅支持「最上级」与「随机」(服务端 GM 接口限制);直接进背包路径的品级/强化等属性建议改走「邮件发放」以确保生效。</div>
|
||
<button class="gm-btn gm-btn-primary" onclick="grantItem()">🎁 发放</button>
|
||
<div class="gm-hint" id="grant-tip" style="margin-top:10px"></div>
|
||
</div>
|
||
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>物品浏览与发放</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<input class="gm-input" id="item-query" placeholder="关键词 / 名称 / ID(可空,浏览分类)" style="flex:1" onkeydown="if(event.key==='Enter')searchItems()">
|
||
<select class="gm-input" id="item-rarity" style="width:110px">
|
||
<option value="-1">全部品质</option>
|
||
<option value="0">普通</option>
|
||
<option value="1">高级</option>
|
||
<option value="2">稀有</option>
|
||
<option value="3">神器</option>
|
||
<option value="4">史诗</option>
|
||
<option value="5">传说</option>
|
||
<option value="6">神话</option>
|
||
</select>
|
||
<button class="gm-btn gm-btn-primary" onclick="searchItems()">搜索</button>
|
||
</div>
|
||
|
||
<!-- 装备 / 道具 顶层分区 -->
|
||
<div class="gm-item-tabs" id="item-kind-tabs">
|
||
<button class="gm-item-tab active" data-kind="equipment" onclick="switchItemKind('equipment')">🛡 装备</button>
|
||
<button class="gm-item-tab" data-kind="stackable" onclick="switchItemKind('stackable')">🎒 道具</button>
|
||
</div>
|
||
|
||
<!-- 装备分类(武器/防具/首饰/称号/宠物神器/时装 + 武器子类型) -->
|
||
<div id="item-cats" style="margin-bottom:12px"><div class="gm-hint">正在加载分类…</div></div>
|
||
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||
<span class="gm-hint" id="item-total">共 0 条</span>
|
||
<span style="display:flex;gap:8px">
|
||
<button class="gm-btn gm-btn-sm" onclick="itemPage('prev')">‹ 上一页</button>
|
||
<span class="gm-hint" id="item-page" style="align-self:center">第 1 / 1 页</span>
|
||
<button class="gm-btn gm-btn-sm" onclick="itemPage('next')">下一页 ›</button>
|
||
</span>
|
||
</div>
|
||
<div class="gm-table-wrap" style="max-height:380px">
|
||
<table class="gm-table">
|
||
<thead><tr><th>ID</th><th>名称</th><th>品质</th><th>类型</th><th>堆叠</th><th>操作</th></tr></thead>
|
||
<tbody id="item-result"><tr><td colspan="6" class="gm-empty">正在加载…</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>`;
|
||
|
||
// 填充角色下拉(联动)
|
||
fillCharSelect($('item-char'));
|
||
updateGrantTip();
|
||
// 重置到「装备」分区,加载分类 + 第一页
|
||
ITEM_KIND = 'equipment';
|
||
loadItemCategories();
|
||
renderItemPage();
|
||
}
|
||
|
||
function updateGrantTip() {
|
||
const tip = $('grant-tip');
|
||
const mode = $('grant-mode');
|
||
const mailFields = $('grant-mail-fields');
|
||
if (mode && mailFields) mailFields.style.display = (mode.value === 'mail') ? 'block' : 'none';
|
||
if (!tip) return;
|
||
const charId = $('item-char').value;
|
||
const ch = CHAR_LIST.find(c => String(c.character_id) === String(charId));
|
||
if (mode.value === 'mail') {
|
||
tip.textContent = '邮件发放:物品进入角色邮箱,游戏内立即收到新邮件提醒,打开邮箱领取即可(无需小退)。';
|
||
} else {
|
||
tip.textContent = '直接进背包:服务端只写仓库数据、不推送背包刷新,' +
|
||
(ch && ch.online ? '该角色当前在线,需小退(返回角色选择再进入)才能看到。' : '物品已写入背包,下次上线可见。');
|
||
}
|
||
}
|
||
|
||
// ---- 分类 ----
|
||
async function loadItemCategories(force) {
|
||
const box = $('item-cats');
|
||
if (!box) return;
|
||
if (ITEM_CATS && !force) { renderItemCats(ITEM_CATS); return; }
|
||
try {
|
||
const r = await api('/api/items/categories');
|
||
if (r.error) {
|
||
box.innerHTML = '<div class="gm-hint">分类不可用:' + escapeHtml(r.error) + '</div>';
|
||
return;
|
||
}
|
||
ITEM_CATS = r.data || {};
|
||
renderItemCats(ITEM_CATS);
|
||
} catch (e) {
|
||
box.innerHTML = '<div class="gm-hint">分类加载失败:' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
function catBtn(kind, tag, segment, group, label, count, key, extra) {
|
||
return `<button class="gm-btn gm-btn-sm" data-cat="${escapeHtml(key)}" onclick="browseCat('${escapeHtml(kind)}','${escapeHtml(tag)}','${escapeHtml(segment)}','${escapeHtml(group)}','${escapeHtml(label)}','${escapeHtml(extra || '')}')">${escapeHtml(label)} <span style="opacity:.6">${count}</span></button>`;
|
||
}
|
||
|
||
// 切换装备 / 道具 顶层分区
|
||
async function switchItemKind(kind) {
|
||
ITEM_KIND = kind;
|
||
// 高亮 Tab
|
||
document.querySelectorAll('#item-kind-tabs .gm-item-tab').forEach(b => {
|
||
b.classList.toggle('active', b.getAttribute('data-kind') === kind);
|
||
});
|
||
// 重置筛选
|
||
ITEM_QUERY = '';
|
||
ITEM_RARITY = -1;
|
||
ITEM_SUB = '';
|
||
ITEM_PAGE = 0;
|
||
if ($('item-query')) $('item-query').value = '';
|
||
if ($('item-rarity')) $('item-rarity').value = '-1';
|
||
ITEM_FILTER = { tag: '', segment: '', group: '', label: (kind === 'equipment' ? '全部装备' : '全部道具') };
|
||
renderItemCats(ITEM_CATS);
|
||
await renderItemPage();
|
||
}
|
||
|
||
function renderItemCats(cats) {
|
||
const box = $('item-cats');
|
||
if (!box) return;
|
||
if (!cats) { box.innerHTML = ''; return; }
|
||
const eq = (cats.equipment || []).filter(x => x.count > 0);
|
||
const st = (cats.stackable || []).filter(x => x.count > 0);
|
||
const byTag = {};
|
||
eq.forEach(x => { byTag[x.name] = x.count; });
|
||
const eqTotal = eq.reduce((s, x) => s + x.count, 0);
|
||
const stTotal = st.reduce((s, x) => s + x.count, 0);
|
||
|
||
if (ITEM_KIND === 'stackable') {
|
||
// 道具区:segment 分类
|
||
let html = '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">';
|
||
html += catBtn('', '', '', '', '全部道具', stTotal, 'all');
|
||
html += '</div>';
|
||
if (st.length) {
|
||
html += '<div class="gm-hint" style="margin-bottom:6px">按类型分类</div>' +
|
||
'<div style="display:flex;flex-wrap:wrap;gap:6px">' +
|
||
st.map(x => catBtn('', '', x.name, '', x.name, x.count, 'seg:' + x.name)).join('') +
|
||
'</div>';
|
||
}
|
||
box.innerHTML = html;
|
||
markActiveCat();
|
||
return;
|
||
}
|
||
|
||
// 装备区:按 ITEM_GROUPS 分类 + 武器子类型
|
||
const used = {};
|
||
let html = '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">';
|
||
html += catBtn('', '', '', '', '全部装备', eqTotal, 'all');
|
||
html += '</div>';
|
||
|
||
ITEM_GROUPS.forEach(g => {
|
||
const cols = g.tags.filter(t => byTag[t]);
|
||
if (!cols.length) return;
|
||
cols.forEach(t => { used[t] = 1; });
|
||
const isWeapon = (g.name === '武器');
|
||
html += '<div style="margin-bottom:10px">' +
|
||
'<div class="gm-hint" style="margin-bottom:6px">' + g.name + '</div>' +
|
||
'<div style="display:flex;flex-wrap:wrap;gap:6px">' +
|
||
cols.map(t => catBtn('', t, '', '', tagLabel(t), byTag[t], 'tag:' + t)).join('') +
|
||
'</div>';
|
||
// 武器组额外展开子类型(8大职业)
|
||
if (isWeapon) {
|
||
html += '<div id="weapon-sub-box" style="margin-top:6px;display:none">' +
|
||
renderWeaponSubs() + '</div>';
|
||
}
|
||
html += '</div>';
|
||
});
|
||
|
||
const rest = eq.filter(x => !used[x.name]);
|
||
if (rest.length) {
|
||
html += '<div style="margin-bottom:10px">' +
|
||
'<div class="gm-hint" style="margin-bottom:6px">其他装备</div>' +
|
||
'<div style="display:flex;flex-wrap:wrap;gap:6px">' +
|
||
rest.map(x => catBtn('', x.name, '', '', tagLabel(x.name), x.count, 'tag:' + x.name)).join('') +
|
||
'</div></div>';
|
||
}
|
||
box.innerHTML = html;
|
||
markActiveCat();
|
||
// 若当前选中武器,展开子类型
|
||
toggleWeaponSubs(ITEM_FILTER.tag === 'weapon');
|
||
}
|
||
|
||
function renderWeaponSubs() {
|
||
let html = '<div class="gm-hint" style="margin-bottom:4px">武器子类型</div>';
|
||
WEAPON_SUBS.forEach(g => {
|
||
html += '<div style="margin:4px 0">' +
|
||
'<span class="gm-hint" style="margin-right:6px;font-size:12px">' + g.job + '</span>' +
|
||
'<div style="display:inline-flex;flex-wrap:wrap;gap:5px">' +
|
||
g.subs.map(s => {
|
||
const key = 'wsub:' + s[0];
|
||
return `<button class="gm-btn gm-btn-xs" data-cat="${key}" data-wsub="1" onclick="browseWeaponSub('${escapeHtml(s[0])}','${escapeHtml(s[1])}')">${escapeHtml(s[1])}</button>`;
|
||
}).join('') +
|
||
'</div></div>';
|
||
});
|
||
return html;
|
||
}
|
||
|
||
function toggleWeaponSubs(show) {
|
||
const box = $('weapon-sub-box');
|
||
if (box) box.style.display = show ? 'block' : 'none';
|
||
}
|
||
|
||
async function browseWeaponSub(sub, label) {
|
||
ITEM_FILTER = { tag: 'weapon', segment: '', group: '', label: '武器 · ' + label };
|
||
ITEM_SUB = sub;
|
||
ITEM_QUERY = '/weapon/' + sub + '/'; // 通过 PVF path 匹配武器子类型
|
||
ITEM_RARITY = parseInt($('item-rarity').value);
|
||
ITEM_PAGE = 0;
|
||
await renderItemPage();
|
||
}
|
||
|
||
function markActiveCat() {
|
||
const f = ITEM_FILTER;
|
||
let key = ITEM_SUB ? 'wsub:' + ITEM_SUB
|
||
: f.tag ? 'tag:' + f.tag
|
||
: f.segment ? 'seg:' + f.segment
|
||
: 'all';
|
||
document.querySelectorAll('#item-cats [data-cat]').forEach(b => {
|
||
const on = b.getAttribute('data-cat') === key;
|
||
b.style.borderColor = on ? 'var(--primary)' : 'transparent';
|
||
b.style.background = on ? 'rgba(124,92,255,0.18)' : 'rgba(255,255,255,0.05)';
|
||
});
|
||
}
|
||
|
||
async function browseCat(kind, tag, segment, group, label, extra) {
|
||
ITEM_FILTER = { tag, segment, group, label };
|
||
ITEM_SUB = '';
|
||
ITEM_QUERY = ($('item-query') && $('item-query').value) ? $('item-query').value.trim() : '';
|
||
ITEM_RARITY = parseInt($('item-rarity').value);
|
||
ITEM_PAGE = 0;
|
||
toggleWeaponSubs(tag === 'weapon');
|
||
await renderItemPage();
|
||
}
|
||
|
||
async function searchItems() {
|
||
ITEM_QUERY = $('item-query').value.trim();
|
||
ITEM_SUB = '';
|
||
// 搜索时清空细分筛选,保留装备/道具分区
|
||
ITEM_FILTER = { tag: '', segment: '', group: '', label: (ITEM_KIND === 'equipment' ? '装备搜索' : '道具搜索') };
|
||
ITEM_RARITY = parseInt($('item-rarity').value);
|
||
ITEM_PAGE = 0;
|
||
toggleWeaponSubs(false);
|
||
await renderItemPage();
|
||
}
|
||
|
||
async function itemPage(dir) {
|
||
const maxPage = Math.max(0, Math.ceil(ITEM_TOTAL / ITEM_PAGE_SIZE) - 1);
|
||
let np = ITEM_PAGE + (dir === 'next' ? 1 : -1);
|
||
if (np < 0) np = 0;
|
||
if (np > maxPage) np = maxPage;
|
||
if (np === ITEM_PAGE) return;
|
||
ITEM_PAGE = np;
|
||
await renderItemPage();
|
||
}
|
||
|
||
async function renderItemPage() {
|
||
const offset = ITEM_PAGE * ITEM_PAGE_SIZE;
|
||
const f = ITEM_FILTER;
|
||
try {
|
||
const r = await post('/api/item/search', {
|
||
query: ITEM_QUERY, rarity: ITEM_RARITY, limit: ITEM_PAGE_SIZE, offset,
|
||
kind: ITEM_KIND, tag: f.tag, segment: f.segment, group: f.group
|
||
});
|
||
if (r.error) {
|
||
$('item-result').innerHTML = '<tr><td colspan="6" class="gm-empty">' + escapeHtml(r.error) + '</td></tr>';
|
||
$('item-total').textContent = '共 0 条';
|
||
return;
|
||
}
|
||
const arr = r.items || [];
|
||
ITEM_TOTAL = r.total || arr.length;
|
||
const maxPage = Math.max(0, Math.ceil(ITEM_TOTAL / ITEM_PAGE_SIZE) - 1);
|
||
$('item-total').textContent = (f.label ? f.label + ' · ' : '') + '共 ' + ITEM_TOTAL + ' 条';
|
||
$('item-result').innerHTML = arr.length
|
||
? arr.map(it => {
|
||
const rid = it.item_id ?? it.id;
|
||
const nm = (it.name || '').replace(/['"\\<>&]/g, '');
|
||
const rn = RARITY[it.rarity] ? '<span class="gm-tag gm-tag-purple">' + RARITY[it.rarity] + '</span>' : '-';
|
||
return `<tr>
|
||
<td>${escapeHtml(rid)}</td>
|
||
<td>${escapeHtml(it.name || '-')}</td>
|
||
<td>${rn}</td>
|
||
<td class="gm-hint">${escapeHtml(it.kind || '-')}</td>
|
||
<td>${fmt(it.stack_limit)}</td>
|
||
<td style="white-space:nowrap">
|
||
<button class="gm-btn gm-btn-sm gm-btn-primary" onclick="useItem('${escapeHtml(rid)}','${nm}')">选用</button>
|
||
<button class="gm-btn gm-btn-sm" onclick="quickGrant('${escapeHtml(rid)}','${nm}')">发放×1</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('')
|
||
: '<tr><td colspan="6" class="gm-empty">无结果</td></tr>';
|
||
$('item-page').textContent = '第 ' + (ITEM_PAGE + 1) + ' / ' + (maxPage + 1) + ' 页';
|
||
markActiveCat();
|
||
} catch (e) {
|
||
$('item-result').innerHTML = '<tr><td colspan="6" class="gm-empty">搜索异常:' + escapeHtml(e.message) + '</td></tr>';
|
||
}
|
||
}
|
||
|
||
function useItem(id, name) {
|
||
$('item-id').value = id;
|
||
$('item-count').value = '1';
|
||
gmToast('已填入道具 ' + id + (name ? '(' + name + ')' : '') + ',设置数量后点「发放」', 'info');
|
||
}
|
||
|
||
async function quickGrant(itemId, name) {
|
||
const charId = $('item-char').value;
|
||
if (!charId) { gmToast('请先选择角色', 'err'); return; }
|
||
const label = name || String(itemId);
|
||
const mode = $('grant-mode').value;
|
||
const opts = {
|
||
reinforce: parseInt($('opt-reinforce').value || 0),
|
||
amplify_type: parseInt($('opt-amplify').value || 0),
|
||
forging: parseInt($('opt-forging').value || 0),
|
||
equipment_quality: $('opt-quality').value || '',
|
||
enchant_bead_item_id: parseInt($('opt-enchant').value || 0)
|
||
};
|
||
const body = { id: charId, item_id: parseInt(itemId), count: 1, ...opts };
|
||
try {
|
||
const r = mode === 'mail'
|
||
? await post('/api/item/mail', body)
|
||
: await post('/api/item/grant-options', body);
|
||
if (r.ok) gmToast('已' + (mode === 'mail' ? '邮寄' : '发放') + '「' + label + '」×1', 'ok');
|
||
else gmToast('发放失败:' + (r.error || ''), 'err');
|
||
} catch (e) { gmToast('发放异常:' + e.message, 'err'); }
|
||
}
|
||
|
||
async function grantItem() {
|
||
const id = $('item-char').value;
|
||
const itemId = $('item-id').value.trim();
|
||
const count = $('item-count').value.trim();
|
||
if (!id) { gmToast('请先选择角色', 'err'); return; }
|
||
if (!itemId) { gmToast('请填写道具 ID(或从列表选用)', 'err'); return; }
|
||
const mode = $('grant-mode').value;
|
||
// 装备高级选项(服务端对非装备会忽略/报错,仅装备生效)
|
||
const opts = {
|
||
reinforce: parseInt($('opt-reinforce').value || 0),
|
||
amplify_type: parseInt($('opt-amplify').value || 0),
|
||
forging: parseInt($('opt-forging').value || 0),
|
||
equipment_quality: $('opt-quality').value || '',
|
||
enchant_bead_item_id: parseInt($('opt-enchant').value || 0)
|
||
};
|
||
const body = { id, item_id: parseInt(itemId), count: parseInt(count || 1), ...opts };
|
||
if (mode === 'mail') {
|
||
body.title = $('mail-title').value || '';
|
||
body.body = $('mail-body').value || '';
|
||
}
|
||
const path = mode === 'mail' ? '/api/item/mail' : '/api/item/grant-options';
|
||
const r = await post(path, body);
|
||
if (r.ok) gmToast(mode === 'mail' ? '已邮件发放道具 ' + itemId + ' ×' + count : '已发放道具 ' + itemId + ' ×' + count, 'ok');
|
||
else gmToast('发放失败:' + (r.error || ''), 'err');
|
||
}
|
||
|
||
async function doGrant() {
|
||
await grantItem();
|
||
}
|
||
|
||
// ---------- 邮件 ----------
|
||
async function renderMail(content) {
|
||
content.innerHTML = `
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>发送系统邮件</div>
|
||
<div class="gm-form-row" style="margin-bottom:10px">
|
||
<div class="gm-field">
|
||
<label>收件角色(联动)</label>
|
||
<select class="gm-input" id="mail-char" data-char-select></select>
|
||
</div>
|
||
<div class="gm-field">
|
||
<label>标题</label>
|
||
<input class="gm-input" id="sysmail-title" placeholder="邮件标题">
|
||
</div>
|
||
</div>
|
||
<div class="gm-field" style="margin-bottom:10px">
|
||
<label>正文</label>
|
||
<textarea class="gm-input" id="sysmail-body" rows="3" placeholder="邮件正文"></textarea>
|
||
</div>
|
||
<button class="gm-btn gm-btn-primary" onclick="sendSystemMail()">✉️ 发送邮件</button>
|
||
<div class="gm-hint" style="margin-top:8px">需服务端在线(Native 模式)。</div>
|
||
</div>
|
||
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>清空邮箱</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<select class="gm-input" id="mail-clear-char" data-char-select style="flex:1"></select>
|
||
<button class="gm-btn gm-btn-danger" onclick="clearMailbox()">清空邮箱</button>
|
||
</div>
|
||
<div class="gm-hint">清空选中角色的全部邮件(危险,需二次确认)。</div>
|
||
</div>`;
|
||
fillCharSelect($('mail-char'));
|
||
fillCharSelect($('mail-clear-char'));
|
||
}
|
||
|
||
async function sendSystemMail() {
|
||
const id = $('mail-char').value;
|
||
if (!id) { gmToast('请选择角色', 'err'); return; }
|
||
const r = await post('/api/mail/send', {
|
||
character_id: id,
|
||
title: $('sysmail-title').value || '',
|
||
body: $('sysmail-body').value || ''
|
||
});
|
||
if (r.ok) gmToast('邮件已发送', 'ok'); else gmToast('发送失败:' + (r.error || ''), 'err');
|
||
}
|
||
|
||
async function clearMailbox() {
|
||
const id = $('mail-clear-char').value;
|
||
if (!id) { gmToast('请选择角色', 'err'); return; }
|
||
gmConfirm('清空邮箱', '确定清空该角色的全部邮件?<br>此操作<b style="color:#ff5d6c">不可恢复</b>。', 'DELETE', async () => {
|
||
const r = await post('/api/mailbox/clear', { id });
|
||
if (r.ok) gmToast('邮箱已清空', 'ok'); else gmToast('清空失败:' + (r.error || ''), 'err');
|
||
});
|
||
}
|
||
|
||
// ---------- 背包 ----------
|
||
async function renderInventory(content) {
|
||
content.innerHTML = `
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>查看背包</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<select class="gm-input" id="inv-char" data-char-select style="flex:1"></select>
|
||
<button class="gm-btn gm-btn-primary" onclick="loadInventory()">查看</button>
|
||
<button class="gm-btn gm-btn-danger" onclick="clearInventory()">清空背包</button>
|
||
</div>
|
||
<div id="inv-list"><div class="gm-hint">选择角色后查看背包内容</div></div>
|
||
</div>
|
||
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>删除指定背包行</div>
|
||
<div class="gm-form-row" style="margin-bottom:10px">
|
||
<div class="gm-field">
|
||
<label>角色(联动)</label>
|
||
<select class="gm-input" id="inv-del-char" data-char-select></select>
|
||
</div>
|
||
<div class="gm-field">
|
||
<label>容器</label>
|
||
<select class="gm-input" id="inv-del-container">
|
||
<option value="inventory">主背包</option>
|
||
<option value="warehouse">仓库</option>
|
||
<option value="account">账号金库</option>
|
||
</select>
|
||
</div>
|
||
<div class="gm-field">
|
||
<label>槽位 key(见上方「槽位」列)</label>
|
||
<input class="gm-input" id="inv-del-key" placeholder="entry_key">
|
||
</div>
|
||
</div>
|
||
<button class="gm-btn gm-btn-danger" onclick="deleteInventoryItem()">删除该行</button>
|
||
<div class="gm-hint" style="margin-top:8px">可直接在上方列表中点每行的「删除」按钮,无需手动填 key;服务端在线/离线均可删除。</div>
|
||
</div>`;
|
||
fillCharSelect($('inv-char'));
|
||
fillCharSelect($('inv-del-char'));
|
||
}
|
||
|
||
async function loadInventory() {
|
||
const id = $('inv-char').value;
|
||
if (!id) { gmToast('请选择角色', 'err'); return; }
|
||
const box = $('inv-list');
|
||
box.innerHTML = loadingHtml('加载背包…');
|
||
const r = await api('/api/inventory/list?id=' + encodeURIComponent(id));
|
||
const rows = Array.isArray(r.data) ? r.data : [];
|
||
if (!r.ok) {
|
||
box.innerHTML = `<div class="gm-empty">加载失败:${escapeHtml(r.error || '')}</div>`;
|
||
return;
|
||
}
|
||
if (!rows.length) { box.innerHTML = '<div class="gm-empty">背包为空</div>'; return; }
|
||
const trs = rows.map(it => {
|
||
const key = it.key || it.entry_key || '';
|
||
const cid = String(id).replace(/'/g, '');
|
||
return `<tr>
|
||
<td>${escapeHtml(it.collection || it.container || '背包')}</td>
|
||
<td>${escapeHtml(key)}</td>
|
||
<td>${escapeHtml(it.item_id ?? '—')}</td>
|
||
<td>${escapeHtml(it.item_name || it.name || '-')}</td>
|
||
<td>${fmt(it.count ?? 1)}</td>
|
||
<td><button class="gm-btn gm-btn-danger gm-btn-sm" onclick="deleteInventoryRow('${escapeHtml(cid)}','${escapeHtml(key)}')">删除</button></td>
|
||
</tr>`;
|
||
}).join('');
|
||
box.innerHTML = `<div class="gm-table-wrap"><table class="gm-table">
|
||
<thead><tr><th>容器</th><th>槽位(key)</th><th>物品ID</th><th>名称</th><th>数量</th><th>操作</th></tr></thead>
|
||
<tbody>${trs}</tbody></table></div>`;
|
||
}
|
||
|
||
async function deleteInventoryRow(id, key) {
|
||
if (!id || !key) { gmToast('缺少删除信息', 'err'); return; }
|
||
gmConfirm('删除背包行', `确定删除背包行 <b>${escapeHtml(key)}</b>?`, 'DELETE', async () => {
|
||
const r = await post('/api/inventory/delete', { id, key });
|
||
if (r.ok) { gmToast('已删除 ' + key, 'ok'); loadInventory(); }
|
||
else gmToast('删除失败:' + (r.error || ''), 'err');
|
||
});
|
||
}
|
||
|
||
async function clearInventory() {
|
||
const id = $('inv-char').value;
|
||
if (!id) { gmToast('请选择角色', 'err'); return; }
|
||
gmConfirm('清空背包', '确定清空该角色背包?<br>钱包槽(金币/复活币)会保留,其余物品将被删除。', 'DELETE', async () => {
|
||
const r = await post('/api/inventory/clear', { id, keep_wallet: true });
|
||
if (r.ok) { gmToast('背包已清空(保留钱包槽)', 'ok'); loadInventory(); }
|
||
else gmToast('清空失败:' + (r.error || ''), 'err');
|
||
});
|
||
}
|
||
|
||
async function deleteInventoryItem() {
|
||
const id = $('inv-del-char').value;
|
||
const key = $('inv-del-key').value.trim();
|
||
if (!id || !key) { gmToast('请选择角色并填写槽位 key', 'err'); return; }
|
||
const container = $('inv-del-container').value;
|
||
gmConfirm('删除背包行', `确定删除背包行 <b>${escapeHtml(key)}</b>?`, 'DELETE', async () => {
|
||
const r = await post('/api/inventory/delete', { id, container, key });
|
||
if (r.ok) { gmToast('已删除 ' + key, 'ok'); loadInventory(); }
|
||
else gmToast('删除失败:' + (r.error || ''), 'err');
|
||
});
|
||
}
|
||
|
||
// ---------- 拍卖行 ----------
|
||
async function renderAuction(content) {
|
||
content.innerHTML = loadingHtml('正在加载拍卖行…');
|
||
try {
|
||
await loadAccounts();
|
||
await loadCharacters();
|
||
content.innerHTML = `
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>挂单列表</div>
|
||
<div class="gm-search-row" style="margin-bottom:12px">
|
||
<button class="gm-btn gm-btn-primary" onclick="loadAuctionListings()">刷新挂单</button>
|
||
<span class="gm-hint" id="auction-escrow-info" style="margin-left:10px"></span>
|
||
</div>
|
||
<div id="auction-list"></div>
|
||
</div>
|
||
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>上架物品</div>
|
||
<div class="gm-hint" style="margin-bottom:12px">GM 上架<b>免押金</b>(不扣 10,000 金币),成交收取 <b>5%</b> 手续费。可从下方<b>物品目录</b>搜索选物品直接上架,或从角色背包选物品、手动填物品 ID。</div>
|
||
|
||
<div class="gm-subtitle" style="margin:4px 0 8px;font-size:12.5px;color:var(--text-dim)">① 从物品目录选物品上架</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<input class="gm-input" id="auction-item-query" placeholder="关键词 / 名称 / ID(搜索物品目录)" style="flex:1" onkeydown="if(event.key==='Enter')searchAuctionItems()">
|
||
<button class="gm-btn gm-btn-primary" onclick="searchAuctionItems()">搜索物品</button>
|
||
</div>
|
||
<div class="gm-table-wrap" style="max-height:220px;margin-bottom:10px">
|
||
<table class="gm-table">
|
||
<thead><tr><th>ID</th><th>名称</th><th>品质</th><th>类型</th><th>操作</th></tr></thead>
|
||
<tbody id="auction-item-result"><tr><td colspan="5" class="gm-empty">输入关键词搜索物品目录,点「选用」填入下方表单</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="gm-subtitle" style="margin:4px 0 8px;font-size:12.5px;color:var(--text-dim)">② 填写上架信息</div>
|
||
<div class="gm-form-grid">
|
||
<div class="gm-field">
|
||
<label>卖家角色</label>
|
||
<select class="gm-input" id="auction-seller" data-char-select onchange="loadAuctionSellerBag()"></select>
|
||
</div>
|
||
<div class="gm-field">
|
||
<label>背包物品(选择后自动填物品ID/数量)</label>
|
||
<select class="gm-input" id="auction-bag-item" onchange="onAuctionBagItemPick()"></select>
|
||
</div>
|
||
<div class="gm-field">
|
||
<label>物品 ID</label>
|
||
<input class="gm-input" id="auction-item-id" type="number" placeholder="物品 ID">
|
||
</div>
|
||
<div class="gm-field">
|
||
<label>数量</label>
|
||
<input class="gm-input" id="auction-count" type="number" value="1" min="1">
|
||
</div>
|
||
<div class="gm-field">
|
||
<label>单价(金币 / 每件)</label>
|
||
<input class="gm-input" id="auction-unit-price" type="number" min="1" placeholder="单价">
|
||
</div>
|
||
<div class="gm-field">
|
||
<label>一口价</label>
|
||
<div class="gm-hint" style="padding-top:8px">本服为单一价格模式,一口价自动等于单价</div>
|
||
</div>
|
||
<div class="gm-field">
|
||
<label>起拍价</label>
|
||
<div class="gm-hint" style="padding-top:8px">本服不设竞拍,按一口价(=单价)直接成交</div>
|
||
</div>
|
||
<div class="gm-field">
|
||
<label>挂单时长(小时)</label>
|
||
<input class="gm-input" id="auction-duration" type="number" value="24" min="1">
|
||
</div>
|
||
</div>
|
||
<label class="gm-checkbox" style="margin-bottom:12px">
|
||
<input type="checkbox" id="auction-grant-missing">
|
||
<span>直接上架(自动补足):背包物品不足时自动凭空发放差额再上架</span>
|
||
</label>
|
||
<button class="gm-btn gm-btn-primary" onclick="auctionList()">上架</button>
|
||
</div>`;
|
||
fillAllCharSelects();
|
||
await loadAuctionListings();
|
||
await loadAuctionSellerBag();
|
||
} catch (e) {
|
||
content.innerHTML = `<div class="gm-empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function loadAuctionListings() {
|
||
const box = $('auction-list');
|
||
const escrowInfo = $('auction-escrow-info');
|
||
if (box) box.innerHTML = loadingHtml('加载挂单…');
|
||
const r = await api('/api/auction/listings');
|
||
if (!r.ok) {
|
||
if (box) box.innerHTML = `<div class="gm-empty">加载失败:${escapeHtml(r.error || '')}</div>`;
|
||
return;
|
||
}
|
||
if (escrowInfo) escrowInfo.textContent = '托管金币:' + fmt(r.escrow_gold || 0);
|
||
const listings = r.data || [];
|
||
if (!listings.length) {
|
||
box.innerHTML = '<div class="gm-empty">当前没有挂单</div>';
|
||
return;
|
||
}
|
||
const rows = listings.map(l => {
|
||
const expired = l.expired ? '<span class="gm-tag gm-tag-red">已过期</span>' : '';
|
||
const priceTxt = l.buyout_price > 0 ? ('一口价 ' + fmt(l.buyout_price)) : ('起拍 ' + fmt(l.start_price));
|
||
return `<tr>
|
||
<td>${escapeHtml(l.listing_id)}</td>
|
||
<td>${escapeHtml(l.item_id)}</td>
|
||
<td>${fmt(l.count)}</td>
|
||
<td>${escapeHtml(l.seller_name || l.seller_id || '-')}</td>
|
||
<td>${fmt(l.unit_price)}</td>
|
||
<td>${priceTxt}${l.bid_total > 0 ? ' · 竞价 ' + fmt(l.bid_total) : ''}</td>
|
||
<td>${escapeHtml(formatTs(l.expires_at))}${expired}</td>
|
||
<td><button class="gm-btn gm-btn-danger gm-btn-sm" onclick="auctionCancel('${escapeHtml(l.listing_id)}','${escapeHtml(l.item_id)}',${l.count})">下架</button></td>
|
||
</tr>`;
|
||
}).join('');
|
||
box.innerHTML = `<div class="gm-table-wrap"><table class="gm-table">
|
||
<thead><tr><th>挂单ID</th><th>物品ID</th><th>数量</th><th>卖家</th><th>单价</th><th>价格</th><th>到期</th><th>操作</th></tr></thead>
|
||
<tbody>${rows}</tbody></table></div>`;
|
||
}
|
||
|
||
function formatTs(ts) {
|
||
if (!ts) return '-';
|
||
const d = new Date(Number(ts) * 1000);
|
||
if (isNaN(d.getTime())) return '-';
|
||
const p = (n) => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||
}
|
||
|
||
async function searchAuctionItems() {
|
||
const box = $('auction-item-result');
|
||
const q = $('auction-item-query').value.trim();
|
||
if (!q) { gmToast('请输入物品关键词或 ID', 'info'); return; }
|
||
box.innerHTML = '<tr><td colspan="5" class="gm-empty">搜索中…</td></tr>';
|
||
try {
|
||
const r = await post('/api/item/search', { query: q, rarity: -1, limit: 50, offset: 0, kind: '', tag: '', segment: '', group: '' });
|
||
const arr = r.items || [];
|
||
if (!arr.length) { box.innerHTML = '<tr><td colspan="5" class="gm-empty">无结果</td></tr>'; return; }
|
||
box.innerHTML = arr.map(it => {
|
||
const rid = it.item_id ?? it.id;
|
||
const nm = (it.name || '').replace(/['"\\<>&]/g, '');
|
||
const rn = RARITY[it.rarity] ? '<span class="gm-tag gm-tag-purple">' + RARITY[it.rarity] + '</span>' : '-';
|
||
return `<tr>
|
||
<td>${escapeHtml(rid)}</td>
|
||
<td>${escapeHtml(it.name || '-')}</td>
|
||
<td>${rn}</td>
|
||
<td class="gm-hint">${escapeHtml(it.kind || '-')}</td>
|
||
<td><button class="gm-btn gm-btn-sm gm-btn-primary" onclick="auctionPickItem('${escapeHtml(rid)}','${nm}')">选用</button></td>
|
||
</tr>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
box.innerHTML = '<tr><td colspan="5" class="gm-empty">搜索异常:' + escapeHtml(e.message) + '</td></tr>';
|
||
}
|
||
}
|
||
|
||
function auctionPickItem(itemId, name) {
|
||
$('auction-item-id').value = itemId;
|
||
$('auction-count').value = '1';
|
||
gmToast('已选中物品 ' + itemId + (name ? '(' + name + ')' : '') + ',填写价格后点「上架」', 'info');
|
||
}
|
||
|
||
async function loadAuctionSellerBag() {
|
||
const sel = $('auction-seller');
|
||
const bagSel = $('auction-bag-item');
|
||
if (!sel || !bagSel) return;
|
||
const id = sel.value;
|
||
if (!id) { bagSel.innerHTML = '<option value="">(请先选角色)</option>'; return; }
|
||
bagSel.innerHTML = '<option value="">加载中…</option>';
|
||
const r = await api('/api/auction/items?id=' + encodeURIComponent(id));
|
||
if (!r.ok) { bagSel.innerHTML = '<option value="">加载失败</option>'; return; }
|
||
const items = (r.data || []).filter(v => Number(v.count) > 0);
|
||
if (!items.length) { bagSel.innerHTML = '<option value="">(背包无物品)</option>'; return; }
|
||
bagSel.innerHTML = '<option value="">— 选择物品 —</option>' + items.map(v =>
|
||
`<option value="${escapeHtml(v.key)}" data-id="${escapeHtml(v.item_id)}" data-count="${escapeHtml(v.count)}">物品 ${escapeHtml(v.item_id)} ×${fmt(v.count)}(槽 ${escapeHtml(v.key)})</option>`).join('');
|
||
}
|
||
|
||
function onAuctionBagItemPick() {
|
||
const sel = $('auction-bag-item');
|
||
const opt = sel.selectedOptions[0];
|
||
if (!opt || !opt.value) return;
|
||
$('auction-item-id').value = opt.dataset.id || '';
|
||
$('auction-count').value = opt.dataset.count || 1;
|
||
}
|
||
|
||
async function auctionList() {
|
||
const seller = $('auction-seller').value;
|
||
const itemId = $('auction-item-id').value;
|
||
const count = parseInt($('auction-count').value || '0', 10);
|
||
const unitPrice = parseInt($('auction-unit-price').value || '0', 10);
|
||
if (!seller) { gmToast('请选择卖家角色', 'err'); return; }
|
||
if (!itemId) { gmToast('请填写物品 ID', 'err'); return; }
|
||
if (!count || count <= 0) { gmToast('数量必须大于 0', 'err'); return; }
|
||
if (!unitPrice || unitPrice <= 0) { gmToast('单价必须大于 0', 'err'); return; }
|
||
// 本服拍卖行只支持「一口价 = 单价」的单一价格模式(服务端 unit_price 即唯一定价,
|
||
// 客户端只展示这一个价)。这里强制对齐:一口价=单价、起拍价=-1(buyout-only),
|
||
// 避免提交后被服务端英文校验(buyout_price must equal unit_price)拒绝。
|
||
const body = {
|
||
seller_id: seller,
|
||
item_id: parseInt(itemId, 10),
|
||
count,
|
||
unit_price: unitPrice,
|
||
start_price: -1,
|
||
buyout_price: unitPrice,
|
||
duration_hours: parseInt($('auction-duration').value || '24', 10),
|
||
grant_if_missing: !!$('auction-grant-missing').checked,
|
||
};
|
||
const r = await post('/api/auction/list', body);
|
||
if (r.ok) {
|
||
gmToast('已上架,挂单 ID ' + (r.data && r.data.listing_id), 'ok');
|
||
loadAuctionListings();
|
||
loadAuctionSellerBag();
|
||
} else {
|
||
gmToast('上架失败:' + (r.error || ''), 'err');
|
||
}
|
||
}
|
||
|
||
function auctionCancel(listingId, itemId, count) {
|
||
gmConfirm('下架挂单', `确定下架挂单 <b>${escapeHtml(listingId)}</b>(物品 ${escapeHtml(itemId)} ×${fmt(count)})?<br>下架后物品退回卖家背包,<b>押金 10,000 金币不退</b>。`, 'CANCEL', async () => {
|
||
const r = await post('/api/auction/cancel', { listing_id: listingId, confirm: 'CANCEL' });
|
||
if (r.ok) {
|
||
gmToast('已下架,物品已退回卖家', 'ok');
|
||
loadAuctionListings();
|
||
} else {
|
||
gmToast('下架失败:' + (r.error || ''), 'err');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------- 任务 ----------
|
||
async function renderQuests(content) {
|
||
content.innerHTML = loadingHtml('正在加载任务查询…');
|
||
try {
|
||
await loadAccounts();
|
||
await loadCharacters();
|
||
content.innerHTML = `
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>任务查询</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<select class="gm-input" id="quest-char" data-char-select style="flex:1"></select>
|
||
<button class="gm-btn gm-btn-primary" onclick="loadQuestOverview()">查询概览</button>
|
||
</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<input class="gm-input" id="quest-query" placeholder="按名称/ID/路径关键词搜索任务(可空,搜全部)" style="flex:1"
|
||
onkeydown="if(event.key==='Enter')searchQuests()">
|
||
<button class="gm-btn gm-btn-primary" onclick="searchQuests()">搜索</button>
|
||
</div>
|
||
<div class="gm-hint">需服务端在线(Native 模式)。概览展示该角色的进行中/已完成任务;搜索可查询全部 PVF 任务定义。</div>
|
||
</div>
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>进行中任务</div>
|
||
<div id="quest-active"><div class="gm-hint">选择角色后查询</div></div>
|
||
</div>
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>已完成任务</div>
|
||
<div id="quest-completed"><div class="gm-hint">选择角色后查询</div></div>
|
||
</div>
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>搜索结果</div>
|
||
<div id="quest-search-result"><div class="gm-hint">输入关键词搜索任务定义</div></div>
|
||
</div>`;
|
||
fillAllCharSelects();
|
||
} catch (e) {
|
||
content.innerHTML = `<div class="gm-empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function questCharId() {
|
||
return $('quest-char') ? $('quest-char').value : '';
|
||
}
|
||
|
||
const QUEST_STATUS_LABELS = {
|
||
'not-accepted': '未接受', 'accepted': '进行中', 'active': '进行中',
|
||
'ready': '可提交', 'completed': '已完成', 'complete': '已完成',
|
||
'given-up': '已放弃', 'failed': '已失败',
|
||
};
|
||
|
||
function questStatusTag(q) {
|
||
if (q.completed) return '<span class="gm-tag gm-tag-green">已完成</span>';
|
||
if (q.active) return '<span class="gm-tag gm-tag-purple">进行中</span>';
|
||
const lbl = QUEST_STATUS_LABELS[q.status] || q.status || '未接受';
|
||
const cls = (q.status === 'ready' || q.status === 'complete' || q.status === 'completed') ? 'gm-tag-green' : 'gm-tag-gray';
|
||
return `<span class="gm-tag ${cls}">${escapeHtml(lbl)}</span>`;
|
||
}
|
||
|
||
function questRow(q, opts = {}) {
|
||
const charId = opts.charId || questCharId();
|
||
const qid = q.quest_id;
|
||
const name = q.name || ('任务 #' + qid);
|
||
const level = (q.level_min != null && q.level_max != null)
|
||
? `${fmt(q.level_min)}-${fmt(q.level_max)}` : (q.level_min != null ? fmt(q.level_min) : '—');
|
||
const grade = q.grade ? `<span class="gm-hint">[${escapeHtml(q.grade)}]</span> ` : '';
|
||
const type = q.type ? `<span class="gm-hint">${escapeHtml(q.type)}</span>` : '';
|
||
const actions = charId ? `
|
||
<button class="gm-btn gm-btn-sm" onclick="questDetail('${escapeHtml(charId)}', ${qid})">详情</button>
|
||
${opts.showActions !== false ? `
|
||
<button class="gm-btn gm-btn-sm gm-btn-primary" onclick="questAction('${escapeHtml(charId)}', ${qid}, 'complete')">完成</button>
|
||
<button class="gm-btn gm-btn-sm gm-btn-ghost" onclick="questAction('${escapeHtml(charId)}', ${qid}, 'accept')">接受</button>
|
||
` : ''}` : '';
|
||
return `
|
||
<tr>
|
||
<td class="gm-hint">${qid}</td>
|
||
<td style="font-weight:600">${escapeHtml(name)} ${grade}${type}</td>
|
||
<td>${level}</td>
|
||
<td>${questStatusTag(q)}</td>
|
||
<td>${actions}</td>
|
||
</tr>`;
|
||
}
|
||
|
||
function questTable(list, opts = {}) {
|
||
if (!list || !list.length) return '<div class="gm-empty">无数据</div>';
|
||
return `<div class="gm-table-wrap" style="max-height:360px"><table class="gm-table">
|
||
<thead><tr><th style="width:80px">ID</th><th>任务</th><th style="width:80px">等级</th><th style="width:90px">状态</th><th style="width:210px">操作</th></tr></thead>
|
||
<tbody>${list.map(q => questRow(q, opts)).join('')}</tbody>
|
||
</table></div>`;
|
||
}
|
||
|
||
async function loadQuestOverview() {
|
||
const charId = questCharId();
|
||
const activeBox = $('quest-active');
|
||
const doneBox = $('quest-completed');
|
||
if (!charId) { gmToast('请先选择角色', 'err'); return; }
|
||
activeBox.innerHTML = loadingHtml('正在查询进行中任务…');
|
||
doneBox.innerHTML = '<div class="gm-hint">—</div>';
|
||
try {
|
||
const r = await api('/api/quests?id=' + encodeURIComponent(charId));
|
||
if (!r.ok) {
|
||
activeBox.innerHTML = '<div class="gm-empty">' + escapeHtml(r.error || '查询失败') + '</div>';
|
||
doneBox.innerHTML = '';
|
||
return;
|
||
}
|
||
const d = r.data || {};
|
||
const opts = { charId, showActions: true };
|
||
activeBox.innerHTML = questTable(d.active || [], opts);
|
||
doneBox.innerHTML = questTable(d.completed || [], opts);
|
||
} catch (e) {
|
||
activeBox.innerHTML = '<div class="gm-empty">查询失败:' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
async function searchQuests() {
|
||
const charId = questCharId();
|
||
const query = $('quest-query') ? $('quest-query').value.trim() : '';
|
||
const box = $('quest-search-result');
|
||
if (!charId) { gmToast('请先选择角色', 'err'); return; }
|
||
box.innerHTML = loadingHtml('正在搜索任务…');
|
||
try {
|
||
const r = await api('/api/quests/search?id=' + encodeURIComponent(charId) + '&q=' + encodeURIComponent(query));
|
||
if (!r.ok) { box.innerHTML = '<div class="gm-empty">' + escapeHtml(r.error || '搜索失败') + '</div>'; return; }
|
||
const list = r.data || [];
|
||
box.innerHTML = `<div class="gm-hint" style="margin-bottom:8px">共 ${list.length} 条</div>` + questTable(list, { charId, showActions: true });
|
||
} catch (e) {
|
||
box.innerHTML = '<div class="gm-empty">搜索失败:' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
async function questDetail(charId, questId) {
|
||
try {
|
||
const r = await api('/api/quests/detail?id=' + encodeURIComponent(charId) + '&quest_id=' + questId);
|
||
if (!r.ok) { gmToast('查询失败:' + (r.error || ''), 'err'); return; }
|
||
const d = r.data || {};
|
||
const q = d.quest || {};
|
||
const rewards = (d.reward_items || []).map(it =>
|
||
`<span class="gm-tag gm-tag-gold">${escapeHtml(it.name || ('物品 ' + it.item_id))} ×${fmt(it.count)}</span>`).join(' ');
|
||
const selection = (d.reward_selection_items || []).map(it =>
|
||
`<span class="gm-tag gm-tag-purple">${escapeHtml(it.name || ('物品 ' + it.item_id))} ×${fmt(it.count)}(可选)</span>`).join(' ');
|
||
const html = `
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>任务 #${q.quest_id} · ${escapeHtml(q.name || '未命名')}</div>
|
||
<div style="font-size:13px;color:#b8bcd0;line-height:1.9">
|
||
${questStatusTag(q)}
|
||
${q.grade ? `<span class="gm-tag gm-tag-gray">品级 ${escapeHtml(q.grade)}</span>` : ''}
|
||
${q.type ? `<span class="gm-tag gm-tag-gray">${escapeHtml(q.type)}</span>` : ''}
|
||
<span class="gm-tag gm-tag-gray">等级 ${fmt(q.level_min)}-${fmt(q.level_max)}</span>
|
||
</div>
|
||
${d.introduction ? `<div class="gm-subtitle" style="margin-top:14px;font-size:12.5px;color:var(--text-dim)">任务简介</div><div class="gm-hint" style="line-height:1.7">${escapeHtml(d.introduction)}</div>` : ''}
|
||
${d.objective ? `<div class="gm-subtitle" style="margin-top:14px;font-size:12.5px;color:var(--text-dim)">任务目标</div><div class="gm-hint" style="line-height:1.7">${escapeHtml(d.objective)}</div>` : ''}
|
||
${d.completion ? `<div class="gm-subtitle" style="margin-top:14px;font-size:12.5px;color:var(--text-dim)">完成说明</div><div class="gm-hint" style="line-height:1.7">${escapeHtml(d.completion)}</div>` : ''}
|
||
${rewards ? `<div class="gm-subtitle" style="margin-top:14px;font-size:12.5px;color:var(--text-dim)">任务奖励</div><div>${rewards}</div>` : ''}
|
||
${selection ? `<div class="gm-subtitle" style="margin-top:14px;font-size:12.5px;color:var(--text-dim)">可选奖励</div><div>${selection}</div>` : ''}
|
||
<div class="gm-toolbar" style="margin-top:16px">
|
||
<button class="gm-btn gm-btn-sm gm-btn-primary" onclick="questAction('${escapeHtml(charId)}', ${q.quest_id}, 'complete')">完成</button>
|
||
<button class="gm-btn gm-btn-sm" onclick="questAction('${escapeHtml(charId)}', ${q.quest_id}, 'ready')">标记可提交</button>
|
||
<button class="gm-btn gm-btn-sm gm-btn-ghost" onclick="questAction('${escapeHtml(charId)}', ${q.quest_id}, 'give-up')">放弃</button>
|
||
<button class="gm-btn gm-btn-sm" onclick="renderGmPage('quests')">返回列表</button>
|
||
</div>
|
||
</div>`;
|
||
$('gmContent').innerHTML = html;
|
||
} catch (e) {
|
||
gmToast('查询失败:' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
async function questAction(charId, questId, action) {
|
||
const labels = { complete: '完成', ready: '标记可提交', accept: '接受', 'give-up': '放弃', uncomplete: '取消完成' };
|
||
const label = labels[action] || action;
|
||
const r = await post('/api/quests/action', { id: charId, quest_id: questId, action });
|
||
if (r.ok) {
|
||
gmToast('任务' + label + '成功', 'ok');
|
||
// 详情页执行后返回列表页并刷新;列表页直接刷新概览
|
||
if ($('quest-active')) loadQuestOverview();
|
||
else renderGmPage('quests');
|
||
} else {
|
||
gmToast('操作失败:' + (r.error || ''), 'err');
|
||
}
|
||
}
|
||
|
||
// ---------- 副本 ----------
|
||
async function renderDungeons(content) {
|
||
content.innerHTML = loadingHtml('正在加载副本控制…');
|
||
try {
|
||
await loadCharacters();
|
||
content.innerHTML = `
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>副本状态</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<select class="gm-input" id="dungeon-char" data-char-select style="flex:1"></select>
|
||
<button class="gm-btn gm-btn-primary" onclick="loadDungeons()">查询</button>
|
||
</div>
|
||
<div id="dungeon-info"><div class="gm-hint">选择角色后查询副本难度/地狱模式解锁进度。</div></div>
|
||
</div>
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>副本解锁</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<select class="gm-input" id="dungeon-unlock-char" data-char-select style="flex:1"></select>
|
||
</div>
|
||
<div class="gm-toolbar">
|
||
<button class="gm-btn gm-btn-sm gm-btn-primary" onclick="unlockDungeons('difficulties')">🔓 解锁所有难度</button>
|
||
<button class="gm-btn gm-btn-sm" onclick="unlockDungeons('hell')">🔥 解锁地狱模式</button>
|
||
<span class="gm-hint">解锁后角色可挑战更高难度副本与深渊地狱模式。</span>
|
||
</div>
|
||
</div>
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>深渊史诗掉率(爆率)</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<span class="gm-hint">当前爆率:</span>
|
||
<span class="gm-tag gm-tag-purple" id="hell-current">—</span>
|
||
<input class="gm-input" id="hell-input" type="number" min="0" max="15000" step="1" placeholder="百分比" style="width:140px">
|
||
<span class="gm-hint">%</span>
|
||
<button class="gm-btn gm-btn-primary" onclick="applyHellRate()">应用</button>
|
||
<span class="gm-hint" id="hell-range"></span>
|
||
</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<span class="gm-hint">当前药剂倍率:</span>
|
||
<span class="gm-tag gm-tag-purple" id="potion-current">—</span>
|
||
<input class="gm-input" id="potion-input" type="number" min="0" step="10" placeholder="百分比" style="width:140px">
|
||
<span class="gm-hint">%</span>
|
||
<button class="gm-btn gm-btn-primary" onclick="applyPotionMultiplier()">应用</button>
|
||
<span class="gm-hint">0 = 关闭,按 PVF 表走</span>
|
||
</div>
|
||
<div class="gm-hint">爆率设置即时生效,影响后续深渊史诗掉落结算。</div>
|
||
</div>
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>疲劳与燃烧</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<span class="gm-hint">疲劳模式:</span>
|
||
<span class="gm-tag gm-tag-purple" id="fatigue-current">—</span>
|
||
<select class="gm-input" id="fatigue-mode" style="width:180px">
|
||
<option value="none">正常(扣疲劳)</option>
|
||
<option value="free">无限疲劳(不扣)</option>
|
||
<option value="lock">锁死高值(显示大数字)</option>
|
||
</select>
|
||
<button class="gm-btn gm-btn-primary" onclick="applyFatigueMode()">应用</button>
|
||
</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<span class="gm-hint">一键回满:</span>
|
||
<select class="gm-input" id="fatigue-refill-char" data-char-select style="flex:1"></select>
|
||
<button class="gm-btn gm-btn-sm" onclick="refillFatigue()">回满疲劳</button>
|
||
</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<span class="gm-hint">燃烧疲劳:</span>
|
||
<span class="gm-tag gm-tag-purple" id="burning-current">—</span>
|
||
<button class="gm-btn gm-btn-primary" id="burning-toggle" onclick="toggleBurning()">开启</button>
|
||
<span class="gm-hint">开启后每房间消耗 2 点疲劳,通关经验翻倍</span>
|
||
</div>
|
||
<div class="gm-hint">疲劳模式与燃烧疲劳即时生效,影响后续副本进图结算。</div>
|
||
</div>`;
|
||
fillAllCharSelects();
|
||
loadGameplay();
|
||
} catch (e) {
|
||
content.innerHTML = `<div class="gm-empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function dungeonCharId() {
|
||
const sel = $('dungeon-char') || $('dungeon-unlock-char');
|
||
return sel ? sel.value : '';
|
||
}
|
||
|
||
async function loadDungeons() {
|
||
const charId = dungeonCharId();
|
||
const box = $('dungeon-info');
|
||
if (!charId) { gmToast('请选择角色', 'err'); return; }
|
||
box.innerHTML = loadingHtml('查询副本状态…');
|
||
try {
|
||
const r = await api('/api/character/dungeons?id=' + encodeURIComponent(charId));
|
||
if (!r.ok) { box.innerHTML = '<div class="gm-empty">' + escapeHtml(r.error || '查询失败') + '</div>'; return; }
|
||
const d = r.data || {};
|
||
const pct = (num, den) => den > 0 ? Math.round(num / den * 100) : 0;
|
||
const bar = (p) => `<div style="height:8px;background:rgba(255,255,255,.08);border-radius:4px;overflow:hidden;margin-top:6px"><div style="height:100%;width:${Math.min(p,100)}%;background:linear-gradient(90deg,#7c5cff,#b79bff);border-radius:4px"></div></div>`;
|
||
const rows = [
|
||
['难度副本', `${d.difficulty_dungeons_unlocked || 0} / ${d.difficulty_dungeons || 0}`, bar(pct(d.difficulty_dungeons_unlocked, d.difficulty_dungeons))],
|
||
['地狱区域', `${d.hell_areas || 0}`, ''],
|
||
['地狱许可任务', `${d.hell_permission_quests_cleared || 0} / ${d.hell_permission_quests || 0}`, bar(pct(d.hell_permission_quests_cleared, d.hell_permission_quests))],
|
||
];
|
||
box.innerHTML = `<div class="gm-table-wrap"><table class="gm-table">
|
||
<thead><tr><th style="width:160px">项目</th><th style="width:160px">进度</th><th>状态条</th></tr></thead>
|
||
<tbody>${rows.map(([k, v, b]) => `<tr><td>${k}</td><td style="font-weight:600">${v}</td><td>${b}</td></tr>`).join('')}</tbody>
|
||
</table></div>`;
|
||
} catch (e) {
|
||
box.innerHTML = '<div class="gm-empty">查询失败:' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
async function unlockDungeons(kind) {
|
||
const charId = dungeonCharId();
|
||
if (!charId) { gmToast('请选择角色', 'err'); return; }
|
||
const label = kind === 'hell' ? '解锁地狱模式' : '解锁所有难度';
|
||
gmConfirm(label, '确定要对所选角色执行「' + label + '」吗?', label, async () => {
|
||
const r = await post('/api/dungeon/unlock', { id: charId, kind });
|
||
if (r.ok) { gmToast(label + '成功', 'ok'); loadDungeons(); }
|
||
else gmToast('操作失败:' + (r.error || ''), 'err');
|
||
});
|
||
}
|
||
|
||
async function loadGameplay() {
|
||
try {
|
||
const r = await api('/api/gameplay');
|
||
if (!r.ok) { setGameplayError(r.error); return; }
|
||
const g = r.data || {};
|
||
const cur = $('hell-current');
|
||
if (cur) cur.textContent = (g.hell_epic_drop_rate_percent ?? '—') + '%';
|
||
const range = $('hell-range');
|
||
if (range && g.minimum_percent != null && g.maximum_percent != null) {
|
||
range.textContent = '(范围 ' + g.minimum_percent + '~' + g.maximum_percent + '%)';
|
||
}
|
||
const pc = $('potion-current');
|
||
if (pc) pc.textContent = (g.epic_drop_rate_potion_test_multiplier_percent ?? '—') + '%';
|
||
const fatigueModeLabels = { none: '正常', free: '无限疲劳', lock: '锁死高值' };
|
||
const fc = $('fatigue-current');
|
||
if (fc) fc.textContent = fatigueModeLabels[g.fatigue_mode] || (g.fatigue_mode ?? '—');
|
||
const fm = $('fatigue-mode');
|
||
if (fm && g.fatigue_mode) fm.value = g.fatigue_mode;
|
||
const bc = $('burning-current');
|
||
if (bc) bc.textContent = g.burning_enabled ? '已开启' : '已关闭';
|
||
const bt = $('burning-toggle');
|
||
if (bt) bt.textContent = g.burning_enabled ? '关闭' : '开启';
|
||
} catch (e) {
|
||
setGameplayError(e.message);
|
||
}
|
||
}
|
||
|
||
function setGameplayError(msg) {
|
||
const cur = $('hell-current');
|
||
if (cur) cur.textContent = '加载失败';
|
||
const range = $('hell-range');
|
||
if (range) range.textContent = escapeHtml(msg || '');
|
||
}
|
||
|
||
async function applyHellRate() {
|
||
const v = parseInt($('hell-input').value);
|
||
if (isNaN(v)) { gmToast('请输入百分比数值', 'err'); return; }
|
||
const r = await post('/api/gameplay/hell-drop-rate', { percent: v });
|
||
if (r.ok) { gmToast('深渊史诗掉率已设为 ' + v + '%', 'ok'); loadGameplay(); }
|
||
else gmToast('设置失败:' + (r.error || ''), 'err');
|
||
}
|
||
|
||
async function applyPotionMultiplier() {
|
||
const v = parseInt($('potion-input').value);
|
||
if (isNaN(v)) { gmToast('请输入百分比数值', 'err'); return; }
|
||
const r = await post('/api/gameplay/epic-potion-multiplier', { percent: v });
|
||
if (r.ok) { gmToast('药剂倍率已设为 ' + v + '%', 'ok'); loadGameplay(); }
|
||
else gmToast('设置失败:' + (r.error || ''), 'err');
|
||
}
|
||
|
||
async function applyFatigueMode() {
|
||
const mode = $('fatigue-mode').value;
|
||
const labels = { none: '正常', free: '无限疲劳', lock: '锁死高值' };
|
||
const r = await post('/api/gameplay/fatigue-mode', { mode });
|
||
if (r.ok) { gmToast('疲劳模式已设为「' + (labels[mode] || mode) + '」', 'ok'); loadGameplay(); }
|
||
else gmToast('设置失败:' + (r.error || ''), 'err');
|
||
}
|
||
|
||
async function refillFatigue() {
|
||
const charId = $('fatigue-refill-char').value;
|
||
if (!charId) { gmToast('请选择角色', 'err'); return; }
|
||
const r = await post('/api/character/fatigue/refill', { id: charId });
|
||
if (r.ok) { gmToast('疲劳已回满', 'ok'); }
|
||
else gmToast('回满失败:' + (r.error || ''), 'err');
|
||
}
|
||
|
||
async function toggleBurning() {
|
||
const bt = $('burning-toggle');
|
||
const currentlyOn = bt && bt.textContent === '关闭';
|
||
const r = await post('/api/gameplay/burning', { enabled: !currentlyOn });
|
||
if (r.ok) { gmToast('燃烧疲劳已' + (currentlyOn ? '关闭' : '开启'), 'ok'); loadGameplay(); }
|
||
else gmToast('设置失败:' + (r.error || ''), 'err');
|
||
}
|
||
|
||
// ---------- 钱包 ----------
|
||
async function renderWallet(content) {
|
||
content.innerHTML = `
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>钱包查询</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<select class="gm-input" id="wallet-char" data-char-select style="flex:1"></select>
|
||
<button class="gm-btn gm-btn-primary" onclick="queryWallet()">查询</button>
|
||
</div>
|
||
<div id="wallet-info"><div class="gm-hint">选择角色后查询金币/点券/代币。</div></div>
|
||
</div>
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>点券(CERA)充值</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<select class="gm-input" id="cera-account" data-account-select style="flex:1"></select>
|
||
<input class="gm-input" id="cera-amount" type="number" placeholder="数量(增量)" style="width:140px">
|
||
<button class="gm-btn gm-btn-primary" onclick="setCera()">充值</button>
|
||
</div>
|
||
<div class="gm-hint">点券属账号级货币,需选择账号。服务端在线时可用(Native)。</div>
|
||
</div>
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>黑钻</div>
|
||
<div class="gm-search-row" style="margin-bottom:10px">
|
||
<select class="gm-input" id="bd-char" data-char-select style="flex:1"></select>
|
||
<button class="gm-btn gm-btn-primary" onclick="queryBlackDiamond()">查询</button>
|
||
</div>
|
||
<div id="bd-info"><div class="gm-hint">选择角色后查询黑钻状态。</div></div>
|
||
<div class="gm-form-row" style="margin-bottom:10px">
|
||
<div class="gm-field">
|
||
<label>开启天数(默认 30 天)</label>
|
||
<input class="gm-input" id="bd-days" type="number" value="30" min="1" style="width:140px">
|
||
</div>
|
||
</div>
|
||
<button class="gm-btn gm-btn-primary" onclick="activateBlackDiamond()">💎 开启黑钻</button>
|
||
<div class="gm-hint" style="margin-top:8px">黑钻提升疲劳上限、额外翻牌等权益;在线角色回城后即时生效,无需离线。</div>
|
||
</div>`;
|
||
fillCharSelect($('wallet-char'));
|
||
fillAccountSelect($('cera-account'));
|
||
fillCharSelect($('bd-char'));
|
||
}
|
||
|
||
async function queryWallet() {
|
||
const id = $('wallet-char').value;
|
||
if (!id) { gmToast('请选择角色', 'err'); return; }
|
||
const box = $('wallet-info');
|
||
box.innerHTML = loadingHtml('查询钱包…');
|
||
try {
|
||
const r = await post('/api/wallet/query', { character_id: id });
|
||
if (r.error) { box.innerHTML = '<div class="gm-empty">' + escapeHtml(r.error) + '</div>'; return; }
|
||
const ch = r.character || {};
|
||
const fmtN = (n) => (typeof n === 'number' ? n.toLocaleString('zh-CN') : (n ?? '-'));
|
||
const rows = [
|
||
['金币', fmtN(r.gold), '游戏内流通货币'],
|
||
['点券 CERA', fmtN(r.cera), '账号点券'],
|
||
['代币券', fmtN(r.token_cera), '代币券'],
|
||
['欢乐代币券', fmtN(r.happy_token_cera), '欢乐代币券'],
|
||
];
|
||
box.innerHTML = `<div class="gm-table-wrap"><table class="gm-table">
|
||
<thead><tr><th>项目</th><th>数值</th><th>说明</th></tr></thead>
|
||
<tbody>${rows.map(([k, v, d]) => `<tr><td>${k}</td><td style="font-weight:600">${v}</td><td class="gm-hint">${d}</td></tr>`).join('')}</tbody>
|
||
</table></div>
|
||
<div class="gm-hint" style="margin-top:8px">角色:${escapeHtml(ch.name || '-')}(ID:${escapeHtml(ch.character_id || '-')} · Lv${fmt(ch.level)})</div>`;
|
||
} catch (e) {
|
||
box.innerHTML = '<div class="gm-empty">查询失败:' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
async function setCera() {
|
||
const accountId = $('cera-account').value;
|
||
const amount = $('cera-amount').value.trim();
|
||
if (!accountId) { gmToast('请选择账号', 'err'); return; }
|
||
if (!amount) { gmToast('请填写充值数量', 'err'); return; }
|
||
const r = await post('/api/cera', { account_id: accountId, amount: parseInt(amount) });
|
||
if (r.ok) gmToast('点券已充值 ' + amount, 'ok'); else gmToast('充值失败:' + (r.error || ''), 'err');
|
||
}
|
||
|
||
// ---------- 黑钻 ----------
|
||
async function queryBlackDiamond() {
|
||
const id = $('bd-char').value;
|
||
const box = $('bd-info');
|
||
if (!id) { gmToast('请选择角色', 'err'); return; }
|
||
box.innerHTML = loadingHtml('查询黑钻状态…');
|
||
try {
|
||
const r = await api('/api/character/black-diamond?id=' + encodeURIComponent(id));
|
||
if (!r.ok) { box.innerHTML = '<div class="gm-empty">' + escapeHtml(r.error || '查询失败') + '</div>'; return; }
|
||
const d = r.data || {};
|
||
const active = d.active;
|
||
const status = active
|
||
? '<span class="gm-tag gm-tag-green">已开启</span>'
|
||
: '<span class="gm-tag gm-tag-gray">未开启</span>';
|
||
const expireText = (d.expires_at && d.expires_at > 0)
|
||
? new Date(d.expires_at * 1000).toLocaleString('zh-CN')
|
||
: (d.expire_at || d.expires_at || d.expire_time || '—');
|
||
const rows = [
|
||
['状态', status],
|
||
['剩余天数', fmt(d.remaining_days ?? d.days_remaining ?? d.days)],
|
||
['到期时间', expireText],
|
||
['疲劳上限', fmt(d.fatigue_limit) + (d.black_diamond_fatigue_limit ? '(黑钻 ' + fmt(d.black_diamond_fatigue_limit) + ')' : '')],
|
||
];
|
||
box.innerHTML = `<div class="gm-table-wrap"><table class="gm-table">
|
||
<thead><tr><th style="width:140px">项目</th><th>值</th></tr></thead>
|
||
<tbody>${rows.map(([k, v]) => `<tr><td>${k}</td><td>${v}</td></tr>`).join('')}</tbody>
|
||
</table></div>`;
|
||
} catch (e) {
|
||
box.innerHTML = '<div class="gm-empty">查询失败:' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
async function activateBlackDiamond() {
|
||
const id = $('bd-char').value;
|
||
const days = parseInt($('bd-days').value) || 30;
|
||
if (!id) { gmToast('请选择角色', 'err'); return; }
|
||
gmConfirm('开启黑钻', '确定要为所选角色开启黑钻 ' + days + ' 天吗?<br>在线角色回城后即时生效。', '开启黑钻', async () => {
|
||
const r = await post('/api/black-diamond/activate', { id, days });
|
||
if (r.ok) { gmToast('黑钻已开启 ' + days + ' 天', 'ok'); queryBlackDiamond(); }
|
||
else gmToast('开启失败:' + (r.error || ''), 'err');
|
||
});
|
||
}
|
||
|
||
// ---------- 删档管理 ----------
|
||
async function renderWipe(content) {
|
||
content.innerHTML = loadingHtml('正在加载账号与角色…');
|
||
try {
|
||
await loadAccounts();
|
||
const charsR = await api('/api/characters');
|
||
const chars = charsR.data || [];
|
||
const accounts = ACCOUNT_LIST;
|
||
|
||
const charOptions = chars.map(c =>
|
||
`<option value="${escapeHtml(c.character_id)}">${escapeHtml(c.name || '未命名')}(ID:${escapeHtml(c.character_id)}${c.account_id ? ' · 账号' + escapeHtml(c.account_id) : ''})</option>`).join('');
|
||
|
||
const accOptions = accounts.map(a => {
|
||
const id = a.account_id ?? a.id ?? a.username ?? '';
|
||
const name = a.represent_account_name || a.username || a.name || id;
|
||
return `<option value="${escapeHtml(id)}">${escapeHtml(name)}(${escapeHtml(id)})</option>`;
|
||
}).join('');
|
||
|
||
content.innerHTML = `
|
||
<div class="gm-card gm-danger-zone">
|
||
<div class="gm-card-title"><span class="dot"></span>删除角色</div>
|
||
<div style="font-size:12.5px;color:#ff5d6c;margin-bottom:12px">⚠ 需先停止服务端,否则数据库被锁定。删除不可恢复。</div>
|
||
<div class="gm-search-row" style="margin-bottom:12px">
|
||
<select class="gm-input" id="wipe-char" style="flex:1">${charOptions || '<option value="">(无角色)</option>'}</select>
|
||
<button class="gm-btn gm-btn-danger" onclick="wipeCharSelected()">删除角色</button>
|
||
</div>
|
||
</div>
|
||
<div class="gm-card gm-danger-zone">
|
||
<div class="gm-card-title"><span class="dot"></span>删除账号</div>
|
||
<div style="font-size:12.5px;color:#ff5d6c;margin-bottom:12px">⚠ 删除账号会连带其下所有角色与数据。需先停止服务端。</div>
|
||
<div class="gm-search-row" style="margin-bottom:12px">
|
||
<select class="gm-input" id="wipe-account" style="flex:1">${accOptions || '<option value="">(无账号)</option>'}</select>
|
||
<button class="gm-btn gm-btn-danger" onclick="wipeAccountSelected()">删除账号</button>
|
||
</div>
|
||
</div>`;
|
||
} catch (e) {
|
||
content.innerHTML = `<div class="gm-empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function wipeCharSelected() {
|
||
const sel = $('wipe-char');
|
||
const charId = sel.value;
|
||
const name = sel.options[sel.selectedIndex].text.split('(')[0];
|
||
if (!charId) { gmToast('请选择角色', 'err'); return; }
|
||
await wipeChar(charId, name);
|
||
}
|
||
|
||
async function wipeAccountSelected() {
|
||
const sel = $('wipe-account');
|
||
const accountId = sel.value;
|
||
if (!accountId) { gmToast('请选择账号', 'err'); return; }
|
||
await wipeAccount(accountId);
|
||
}
|
||
|
||
async function wipeChar(charId, name) {
|
||
gmConfirm('删除角色', `确定删除角色「<b>${escapeHtml(name)}</b>」(ID: ${escapeHtml(charId)})的全部数据?<br>此操作<b style="color:#ff5d6c">不可恢复</b>。`, 'DELETE', async () => {
|
||
const r = await post('/api/character/delete', { id: charId, confirm: 'DELETE' });
|
||
if (r.ok) {
|
||
gmToast('角色已删除', 'ok');
|
||
await loadCharacters();
|
||
renderGmPage('wipe');
|
||
}
|
||
else gmToast('删除失败:' + (r.error || ''), 'err');
|
||
});
|
||
}
|
||
|
||
async function wipeAccount(accountId) {
|
||
gmConfirm('删除账号', `确定删除账号「${escapeHtml(accountId)}」及其所有角色?<br>此操作<b style="color:#ff5d6c">不可恢复</b>。`, 'DELETE', async () => {
|
||
const r = await post('/api/account/delete', { id: accountId, confirm: 'DELETE' });
|
||
if (r.ok) {
|
||
gmToast('账号已删除', 'ok');
|
||
await loadAccounts();
|
||
await loadCharacters();
|
||
renderGmPage('wipe');
|
||
}
|
||
else gmToast('删除失败:' + (r.error || ''), 'err');
|
||
});
|
||
}
|
||
|
||
// ---------- 连接设置 ----------
|
||
async function renderSettings(content) {
|
||
try {
|
||
const health = await api('/api/gm/health');
|
||
content.innerHTML = `
|
||
<div class="gm-card">
|
||
<div class="gm-card-title"><span class="dot"></span>数据源设置</div>
|
||
<div style="font-size:13px;color:#b8bcd0;line-height:1.9;margin-bottom:16px">
|
||
<div>当前数据源:<span class="gm-tag gm-tag-purple">${health.source === 'native' ? 'Native API' : 'SQLite'}</span></div>
|
||
<div>服务端状态:${health.server_online ? '<span class="gm-tag gm-tag-green">在线</span>' : '<span class="gm-tag gm-tag-red">离线</span>'}</div>
|
||
<div>Native API:${health.native_available ? '<span class="gm-tag gm-tag-green">可用</span>' : '<span class="gm-tag gm-tag-gray">不可用(未找到 adminToken)</span>'}</div>
|
||
<div>数据库:${health.db_exists ? escapeHtml(health.db) : '未创建'}</div>
|
||
</div>
|
||
<div class="gm-toolbar">
|
||
<button class="gm-btn gm-btn-sm" onclick="setSource('sqlite')">切换到 SQLite</button>
|
||
<button class="gm-btn gm-btn-sm" onclick="setSource('native')">切换到 Native</button>
|
||
</div>
|
||
<div class="gm-hint" style="margin-top:12px">
|
||
提示:数据源会自动根据服务端状态切换。删除角色/删档/疲劳需 SQLite(服务端停止时)。
|
||
</div>
|
||
</div>`;
|
||
} catch (e) {
|
||
content.innerHTML = `<div class="gm-empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function setSource(src) {
|
||
const r = await post('/api/source', { source: src });
|
||
if (r.ok) { gmToast('已切换到 ' + src, 'ok'); renderGmPage('settings'); }
|
||
else gmToast('切换失败:' + (r.error || ''), 'err');
|
||
}
|
||
|
||
// ============================================================
|
||
// 无边框窗口控制(标题栏拖拽 + 最小化/关闭)
|
||
// ============================================================
|
||
function winApi() {
|
||
return window.pywebview && window.pywebview.api;
|
||
}
|
||
|
||
function winMinimize() {
|
||
const a = winApi();
|
||
if (a) a.minimize();
|
||
}
|
||
|
||
function winClose() {
|
||
const a = winApi();
|
||
if (a) a.close();
|
||
else window.close();
|
||
}
|
||
|
||
function initTitlebar() {
|
||
const bar = $('titlebar');
|
||
const minBtn = $('btnWinMin');
|
||
const closeBtn = $('btnWinClose');
|
||
if (minBtn) minBtn.addEventListener('click', winMinimize);
|
||
if (closeBtn) closeBtn.addEventListener('click', winClose);
|
||
|
||
// 拖拽窗口:在标题栏上按住移动
|
||
let dragging = false;
|
||
let startX = 0, startY = 0;
|
||
bar.addEventListener('mousedown', (e) => {
|
||
if (e.target.closest('.tb-btn')) return; // 点按钮不触发拖拽
|
||
dragging = true;
|
||
startX = e.screenX;
|
||
startY = e.screenY;
|
||
bar.style.cursor = 'grabbing';
|
||
});
|
||
window.addEventListener('mousemove', (e) => {
|
||
if (!dragging) return;
|
||
const a = winApi();
|
||
if (!a || !a.move) return;
|
||
const dx = e.screenX - startX;
|
||
const dy = e.screenY - startY;
|
||
// 通过 js_api 获取当前位置并移动;pywebview 无直接 move 则忽略
|
||
if (a.getPosition) {
|
||
a.getPosition().then(([x, y]) => a.move(x + dx, y + dy));
|
||
startX = e.screenX;
|
||
startY = e.screenY;
|
||
}
|
||
});
|
||
window.addEventListener('mouseup', () => {
|
||
dragging = false;
|
||
bar.style.cursor = '';
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// 初始化
|
||
// ============================================================
|
||
function init() {
|
||
initTitlebar();
|
||
|
||
// 启动器按钮
|
||
$('btnStart').addEventListener('click', () => {
|
||
appendLog('▶ 开始游戏…');
|
||
action('start');
|
||
});
|
||
$('btnGm').addEventListener('click', () => {
|
||
// GM 工具现为单窗口内嵌页面,通过 shell 导航切换
|
||
if (typeof window.shellGo === 'function') {
|
||
window.shellGo('gm');
|
||
}
|
||
});
|
||
$('btnStop').addEventListener('click', () => {
|
||
appendLog('■ 关闭服务…');
|
||
action('stop');
|
||
});
|
||
$('btnSponsor').addEventListener('click', openSponsor);
|
||
$('btnCloseSponsor').addEventListener('click', closeSponsor);
|
||
$('btnClearLog').addEventListener('click', () => { $('logBody').innerHTML = ''; });
|
||
$('sponsorModal').addEventListener('click', (e) => {
|
||
if (e.target === $('sponsorModal')) closeSponsor();
|
||
});
|
||
|
||
// 账号设置
|
||
$('btnWinSetting').addEventListener('click', openAccounts);
|
||
$('btnCloseAccounts').addEventListener('click', closeAccounts);
|
||
$('btnAddAccount').addEventListener('click', addAccount);
|
||
$('accountsModal').addEventListener('click', (e) => {
|
||
if (e.target === $('accountsModal')) closeAccounts();
|
||
});
|
||
|
||
// 一键换装
|
||
$('btnWinLoadout').addEventListener('click', () => {
|
||
appendLog('🔄 一键换装…');
|
||
action('loadout');
|
||
});
|
||
|
||
refreshStatus();
|
||
setInterval(refreshStatus, 3000);
|
||
setInterval(pollLogs, 800);
|
||
}
|
||
|
||
// 暴露给内联 onclick
|
||
window.openCharDetail = openCharDetail;
|
||
window.saveLevel = saveLevel;
|
||
window.saveGold = saveGold;
|
||
window.deleteCurrentChar = deleteCurrentChar;
|
||
window.doGrant = doGrant;
|
||
window.loadInventory = loadInventory;
|
||
window.wipeChar = wipeChar;
|
||
window.wipeAccount = wipeAccount;
|
||
window.setSource = setSource;
|
||
window.selectAccount = selectAccount;
|
||
window.deleteAccount = deleteAccount;
|
||
// 账号/角色联动 + 道具/邮件/背包
|
||
window.onCharFilterAccount = onCharFilterAccount;
|
||
window.searchItems = searchItems;
|
||
window.itemPage = itemPage;
|
||
window.browseCat = browseCat;
|
||
window.switchItemKind = switchItemKind;
|
||
window.browseWeaponSub = browseWeaponSub;
|
||
window.useItem = useItem;
|
||
window.quickGrant = quickGrant;
|
||
window.grantItem = grantItem;
|
||
window.updateGrantTip = updateGrantTip;
|
||
window.sendSystemMail = sendSystemMail;
|
||
window.clearMailbox = clearMailbox;
|
||
window.clearInventory = clearInventory;
|
||
window.deleteInventoryItem = deleteInventoryItem;
|
||
window.deleteInventoryRow = deleteInventoryRow;
|
||
window.wipeCharSelected = wipeCharSelected;
|
||
window.wipeAccountSelected = wipeAccountSelected;
|
||
window.queryWallet = queryWallet;
|
||
window.setCera = setCera;
|
||
|
||
init();
|