// ============================================================ // 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 = `${icon}${escapeHtml(msg)}`; 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 = ` `; 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 = '赞助二维码'; } else { box.innerHTML = '
赞助二维码即将上线
请先联系作者获取,或将 sponsor.png 放到启动器目录
'; } }).catch(() => { box.innerHTML = '
赞助二维码加载失败
'; }); $('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 = '
暂无账号
'; return; } box.innerHTML = items.map(it => `
${it.is_default ? '' : ``}
`).join(''); } catch (e) { box.innerHTML = `
加载失败:${escapeHtml(e.message)}
`; } } 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('删除账号', `确定删除账号「${escapeHtml(username)}」?
仅从启动器账号列表移除,不会影响游戏内已有角色。`, '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 = ''; return; } sel.innerHTML = CHAR_LIST.map(c => ``).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 = ''; 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 ``; }).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 = '' + 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 ``; }).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 `
${escapeHtml(text)}
`; } // ---------- 控制台 ---------- 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 = `
服务端状态
${running ? '运行中' : '未运行'}
🖥️
数据库
${dbOk ? '已就绪' : '未创建'}
🗄️
Native API
${health.native_available ? '可用' : '不可用'}
🔌
系统信息
服务端根目录:${escapeHtml(status.server_root)}
客户端目录:${escapeHtml(status.client_dir)}
数据库路径:${escapeHtml(status.db || '(未创建)')}
管理员权限:${status.admin ? '已获取' : '未获取'}
赞助二维码:${status.sponsor_exists ? '已配置' : '未配置'}
使用提示
· 服务端运行时:使用 Native API 实时操作(改金币、发道具等)
· 服务端停止时:自动切到 SQLite 直读(可删除角色、删档、无限疲劳)
· 删除角色必须先停止服务端,否则数据库被锁定
`; } catch (e) { content.innerHTML = `
加载失败:${escapeHtml(e.message)}
`; } } // ---------- 角色管理 ---------- async function renderCharacters(content) { content.innerHTML = loadingHtml('正在加载角色列表…'); try { await loadAccounts(); const r = await api('/api/characters'); const chars = (r.data || []); const accountOptions = '' + 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 ``; }).join(''); const renderRows = (list) => list.map(c => `
${escapeHtml((c.name || '?').charAt(0))}
${escapeHtml(c.name || '未命名')}
ID: ${escapeHtml(c.character_id)}
${escapeHtml(c.profession_name || c.job || '0')} Lv.${fmt(c.level)} ${escapeHtml(c.account_id || '—')} `).join(''); content.innerHTML = `
角色列表(${chars.length})
${renderRows(chars)}
角色职业等级账号操作
`; } catch (e) { content.innerHTML = `
加载失败:${escapeHtml(e.message)}
`; } } 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 => `
${escapeHtml((c.name || '?').charAt(0))}
${escapeHtml(c.name || '未命名')}
ID: ${escapeHtml(c.character_id)}
${escapeHtml(c.profession_name || c.job || '0')} Lv.${fmt(c.level)} ${escapeHtml(c.account_id || '—')} `).join('') || '无角色'; } } // 打开角色详情(编辑等级/金币/疲劳) 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 || {}; const fat = await api('/api/character/fatigue?id=' + encodeURIComponent(charId) + '&mode=query'); const fatInfo = fat.data || {}; content.innerHTML = `
角色:${escapeHtml(ch.name || charId)}
Lv.${fmt(ch.level)} 职业 ${escapeHtml(ch.job || '0')} 账号 ${escapeHtml(ch.account_id || '—')} 金币 ${fmt(ch.gold ?? ch.stats?.gold)}
职业 / 转职
正在加载职业信息…
疲劳值(无限疲劳)
疲劳每日 6 点重置回上限;无限疲劳 = 把疲劳和上限都设成超大值
删除角色(危险操作)
将删除该角色的全部数据(角色、装备、背包、邮件、任务、技能等)。
此操作不可恢复,且需先停止服务端!
`; loadCareer(); } catch (e) { content.innerHTML = `
加载失败:${escapeHtml(e.message)}
`; } } // ---------- 转职 / 职业 ---------- let CAREER_OPTIONS = []; async function loadCareer() { const panel = $('careerPanel'); if (!panel) return; panel.innerHTML = '
正在加载职业信息…
'; try { const r = await api('/api/character/career?id=' + encodeURIComponent(currentCharId)); if (!r.ok) { panel.innerHTML = '
' + escapeHtml(r.error || '加载失败') + '
'; 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 ``; }).join(''); panel.innerHTML = `
当前职业:${escapeHtml(d.profession_name || '未知')}
转职会重置不兼容技能并按新职业重建基础技能;角色需处于离线状态。
`; onCareerJobChange(); } catch (e) { panel.innerHTML = '
加载失败:' + escapeHtml(e.message) + '
'; } } 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 => `` ).join('') || ''; 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 => `` ).join('') || ''; } async function applyCareer() { const job = Number($('careerJob').value); const first = Number($('careerFirst').value || 0); const awakening = Number($('careerAwakening').value || 0); gmConfirm('应用转职', '确定要将该角色转职为所选职业吗?
转职会重置不兼容技能并按新职业重建基础技能,请确认角色当前处于离线状态。', '转职', 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 saveFatigue() { const fatigue = $('editFatigue').value; const limit = $('editFatigueLimit').value; const r = await post('/api/character/fatigue', { id: currentCharId, fatigue: parseInt(fatigue), fatigue_limit: parseInt(limit) }); if (r.ok) { gmToast('疲劳值已更新', 'ok'); openCharDetail(currentCharId); } else gmToast('失败:' + (r.error || ''), 'err'); } async function maxFatigue() { const r = await post('/api/character/fatigue', { id: currentCharId, fatigue: 999999, fatigue_limit: 999999 }); if (r.ok) { gmToast('已设置无限疲劳(999999)', 'ok'); openCharDetail(currentCharId); } else gmToast('失败:' + (r.error || ''), 'err'); } async function deleteCurrentChar() { gmConfirm('删除角色', '确定要删除该角色的全部数据吗?
此操作不可恢复,且需先停止服务端。', '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 = `
发放道具
装备高级选项(仅装备生效,发放消耗品/材料时忽略)
品级仅支持「最上级」与「随机」(服务端 GM 接口限制);直接进背包路径的品级/强化等属性建议改走「邮件发放」以确保生效。
物品浏览与发放
正在加载分类…
共 0 条 第 1 / 1 页
ID名称品质类型堆叠操作
正在加载…
`; // 填充角色下拉(联动) 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 = '
分类不可用:' + escapeHtml(r.error) + '
'; return; } ITEM_CATS = r.data || {}; renderItemCats(ITEM_CATS); } catch (e) { box.innerHTML = '
分类加载失败:' + escapeHtml(e.message) + '
'; } } function catBtn(kind, tag, segment, group, label, count, key, extra) { return ``; } // 切换装备 / 道具 顶层分区 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 = '
'; html += catBtn('', '', '', '', '全部道具', stTotal, 'all'); html += '
'; if (st.length) { html += '
按类型分类
' + '
' + st.map(x => catBtn('', '', x.name, '', x.name, x.count, 'seg:' + x.name)).join('') + '
'; } box.innerHTML = html; markActiveCat(); return; } // 装备区:按 ITEM_GROUPS 分类 + 武器子类型 const used = {}; let html = '
'; html += catBtn('', '', '', '', '全部装备', eqTotal, 'all'); html += '
'; 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 += '
' + '
' + g.name + '
' + '
' + cols.map(t => catBtn('', t, '', '', tagLabel(t), byTag[t], 'tag:' + t)).join('') + '
'; // 武器组额外展开子类型(8大职业) if (isWeapon) { html += ''; } html += '
'; }); const rest = eq.filter(x => !used[x.name]); if (rest.length) { html += '
' + '
其他装备
' + '
' + rest.map(x => catBtn('', x.name, '', '', tagLabel(x.name), x.count, 'tag:' + x.name)).join('') + '
'; } box.innerHTML = html; markActiveCat(); // 若当前选中武器,展开子类型 toggleWeaponSubs(ITEM_FILTER.tag === 'weapon'); } function renderWeaponSubs() { let html = '
武器子类型
'; WEAPON_SUBS.forEach(g => { html += '
' + '' + g.job + '' + '
' + g.subs.map(s => { const key = 'wsub:' + s[0]; return ``; }).join('') + '
'; }); 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 = '' + escapeHtml(r.error) + ''; $('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] ? '' + RARITY[it.rarity] + '' : '-'; return ` ${escapeHtml(rid)} ${escapeHtml(it.name || '-')} ${rn} ${escapeHtml(it.kind || '-')} ${fmt(it.stack_limit)} `; }).join('') : '无结果'; $('item-page').textContent = '第 ' + (ITEM_PAGE + 1) + ' / ' + (maxPage + 1) + ' 页'; markActiveCat(); } catch (e) { $('item-result').innerHTML = '搜索异常:' + escapeHtml(e.message) + ''; } } 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 = `
发送系统邮件
需服务端在线(Native 模式)。
清空邮箱
清空选中角色的全部邮件(危险,需二次确认)。
`; 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('清空邮箱', '确定清空该角色的全部邮件?
此操作不可恢复。', '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 = `
查看背包
选择角色后查看背包内容
删除指定背包行
可直接在上方列表中点每行的「删除」按钮,无需手动填 key;服务端在线/离线均可删除。
`; 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 = `
加载失败:${escapeHtml(r.error || '')}
`; return; } if (!rows.length) { box.innerHTML = '
背包为空
'; return; } const trs = rows.map(it => { const key = it.key || it.entry_key || ''; const cid = String(id).replace(/'/g, ''); return ` ${escapeHtml(it.collection || it.container || '背包')} ${escapeHtml(key)} ${escapeHtml(it.item_id ?? '—')} ${escapeHtml(it.item_name || it.name || '-')} ${fmt(it.count ?? 1)} `; }).join(''); box.innerHTML = `
${trs}
容器槽位(key)物品ID名称数量操作
`; } async function deleteInventoryRow(id, key) { if (!id || !key) { gmToast('缺少删除信息', 'err'); return; } gmConfirm('删除背包行', `确定删除背包行 ${escapeHtml(key)}?`, '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('清空背包', '确定清空该角色背包?
钱包槽(金币/复活币)会保留,其余物品将被删除。', '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('删除背包行', `确定删除背包行 ${escapeHtml(key)}?`, '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 = `
挂单列表
上架物品
GM 上架免押金(不扣 10,000 金币),成交收取 5% 手续费。可从下方物品目录搜索选物品直接上架,或从角色背包选物品、手动填物品 ID。
① 从物品目录选物品上架
ID名称品质类型操作
输入关键词搜索物品目录,点「选用」填入下方表单
② 填写上架信息
本服为单一价格模式,一口价自动等于单价
本服不设竞拍,按一口价(=单价)直接成交
`; fillAllCharSelects(); await loadAuctionListings(); await loadAuctionSellerBag(); } catch (e) { content.innerHTML = `
加载失败:${escapeHtml(e.message)}
`; } } 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 = `
加载失败:${escapeHtml(r.error || '')}
`; return; } if (escrowInfo) escrowInfo.textContent = '托管金币:' + fmt(r.escrow_gold || 0); const listings = r.data || []; if (!listings.length) { box.innerHTML = '
当前没有挂单
'; return; } const rows = listings.map(l => { const expired = l.expired ? '已过期' : ''; const priceTxt = l.buyout_price > 0 ? ('一口价 ' + fmt(l.buyout_price)) : ('起拍 ' + fmt(l.start_price)); return ` ${escapeHtml(l.listing_id)} ${escapeHtml(l.item_id)} ${fmt(l.count)} ${escapeHtml(l.seller_name || l.seller_id || '-')} ${fmt(l.unit_price)} ${priceTxt}${l.bid_total > 0 ? ' · 竞价 ' + fmt(l.bid_total) : ''} ${escapeHtml(formatTs(l.expires_at))}${expired} `; }).join(''); box.innerHTML = `
${rows}
挂单ID物品ID数量卖家单价价格到期操作
`; } 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 = '搜索中…'; 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 = '无结果'; return; } box.innerHTML = arr.map(it => { const rid = it.item_id ?? it.id; const nm = (it.name || '').replace(/['"\\<>&]/g, ''); const rn = RARITY[it.rarity] ? '' + RARITY[it.rarity] + '' : '-'; return ` ${escapeHtml(rid)} ${escapeHtml(it.name || '-')} ${rn} ${escapeHtml(it.kind || '-')} `; }).join(''); } catch (e) { box.innerHTML = '搜索异常:' + escapeHtml(e.message) + ''; } } 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 = ''; return; } bagSel.innerHTML = ''; const r = await api('/api/auction/items?id=' + encodeURIComponent(id)); if (!r.ok) { bagSel.innerHTML = ''; return; } const items = (r.data || []).filter(v => Number(v.count) > 0); if (!items.length) { bagSel.innerHTML = ''; return; } bagSel.innerHTML = '' + items.map(v => ``).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('下架挂单', `确定下架挂单 ${escapeHtml(listingId)}(物品 ${escapeHtml(itemId)} ×${fmt(count)})?
下架后物品退回卖家背包,押金 10,000 金币不退。`, '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 = `
任务查询
需服务端在线(Native 模式)。概览展示该角色的进行中/已完成任务;搜索可查询全部 PVF 任务定义。
进行中任务
选择角色后查询
已完成任务
选择角色后查询
搜索结果
输入关键词搜索任务定义
`; fillAllCharSelects(); } catch (e) { content.innerHTML = `
加载失败:${escapeHtml(e.message)}
`; } } 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 '已完成'; if (q.active) return '进行中'; 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 `${escapeHtml(lbl)}`; } 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 ? `[${escapeHtml(q.grade)}] ` : ''; const type = q.type ? `${escapeHtml(q.type)}` : ''; const actions = charId ? ` ${opts.showActions !== false ? ` ` : ''}` : ''; return ` ${qid} ${escapeHtml(name)} ${grade}${type} ${level} ${questStatusTag(q)} ${actions} `; } function questTable(list, opts = {}) { if (!list || !list.length) return '
无数据
'; return `
${list.map(q => questRow(q, opts)).join('')}
ID任务等级状态操作
`; } async function loadQuestOverview() { const charId = questCharId(); const activeBox = $('quest-active'); const doneBox = $('quest-completed'); if (!charId) { gmToast('请先选择角色', 'err'); return; } activeBox.innerHTML = loadingHtml('正在查询进行中任务…'); doneBox.innerHTML = '
—
'; try { const r = await api('/api/quests?id=' + encodeURIComponent(charId)); if (!r.ok) { activeBox.innerHTML = '
' + escapeHtml(r.error || '查询失败') + '
'; 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 = '
查询失败:' + escapeHtml(e.message) + '
'; } } 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 = '
' + escapeHtml(r.error || '搜索失败') + '
'; return; } const list = r.data || []; box.innerHTML = `
共 ${list.length} 条
` + questTable(list, { charId, showActions: true }); } catch (e) { box.innerHTML = '
搜索失败:' + escapeHtml(e.message) + '
'; } } 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 => `${escapeHtml(it.name || ('物品 ' + it.item_id))} ×${fmt(it.count)}`).join(' '); const selection = (d.reward_selection_items || []).map(it => `${escapeHtml(it.name || ('物品 ' + it.item_id))} ×${fmt(it.count)}(可选)`).join(' '); const html = `
任务 #${q.quest_id} · ${escapeHtml(q.name || '未命名')}
${questStatusTag(q)} ${q.grade ? `品级 ${escapeHtml(q.grade)}` : ''} ${q.type ? `${escapeHtml(q.type)}` : ''} 等级 ${fmt(q.level_min)}-${fmt(q.level_max)}
${d.introduction ? `
任务简介
${escapeHtml(d.introduction)}
` : ''} ${d.objective ? `
任务目标
${escapeHtml(d.objective)}
` : ''} ${d.completion ? `
完成说明
${escapeHtml(d.completion)}
` : ''} ${rewards ? `
任务奖励
${rewards}
` : ''} ${selection ? `
可选奖励
${selection}
` : ''}
`; $('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 = `
副本状态
选择角色后查询副本难度/地狱模式解锁进度。
副本解锁
解锁后角色可挑战更高难度副本与深渊地狱模式。
深渊史诗掉率(爆率)
当前爆率: — %
当前药剂倍率: — % 0 = 关闭,按 PVF 表走
爆率设置即时生效,影响后续深渊史诗掉落结算。
疲劳与燃烧
疲劳模式: —
一键回满:
燃烧疲劳: — 开启后每房间消耗 2 点疲劳,通关经验翻倍
疲劳模式与燃烧疲劳即时生效,影响后续副本进图结算。
`; fillAllCharSelects(); loadGameplay(); } catch (e) { content.innerHTML = `
加载失败:${escapeHtml(e.message)}
`; } } 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 = '
' + escapeHtml(r.error || '查询失败') + '
'; return; } const d = r.data || {}; const pct = (num, den) => den > 0 ? Math.round(num / den * 100) : 0; const bar = (p) => `
`; 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 = `
${rows.map(([k, v, b]) => ``).join('')}
项目进度状态条
${k}${v}${b}
`; } catch (e) { box.innerHTML = '
查询失败:' + escapeHtml(e.message) + '
'; } } 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 = `
钱包查询
选择角色后查询金币/点券/代币。
点券(CERA)充值
点券属账号级货币,需选择账号。服务端在线时可用(Native)。
黑钻
选择角色后查询黑钻状态。
黑钻提升疲劳上限、额外翻牌等权益;角色需离线(下次登录生效)。
`; 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 = '
' + escapeHtml(r.error) + '
'; 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 = `
${rows.map(([k, v, d]) => ``).join('')}
项目数值说明
${k}${v}${d}
角色:${escapeHtml(ch.name || '-')}(ID:${escapeHtml(ch.character_id || '-')} · Lv${fmt(ch.level)})
`; } catch (e) { box.innerHTML = '
查询失败:' + escapeHtml(e.message) + '
'; } } 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 = '
' + escapeHtml(r.error || '查询失败') + '
'; return; } const d = r.data || {}; const active = d.active; const status = active ? '已开启' : '未开启'; 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 = `
${rows.map(([k, v]) => ``).join('')}
项目值
${k}${v}
`; } catch (e) { box.innerHTML = '
查询失败:' + escapeHtml(e.message) + '
'; } } async function activateBlackDiamond() { const id = $('bd-char').value; const days = parseInt($('bd-days').value) || 30; if (!id) { gmToast('请选择角色', 'err'); return; } gmConfirm('开启黑钻', '确定要为所选角色开启黑钻 ' + days + ' 天吗?
角色需处于离线状态,下次登录生效。', '开启黑钻', 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 => ``).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 ``; }).join(''); content.innerHTML = `
删除角色
⚠ 需先停止服务端,否则数据库被锁定。删除不可恢复。
删除账号
⚠ 删除账号会连带其下所有角色与数据。需先停止服务端。
`; } catch (e) { content.innerHTML = `
加载失败:${escapeHtml(e.message)}
`; } } 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('删除角色', `确定删除角色「${escapeHtml(name)}」(ID: ${escapeHtml(charId)})的全部数据?
此操作不可恢复。`, '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)}」及其所有角色?
此操作不可恢复。`, '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 = `
数据源设置
当前数据源:${health.source === 'native' ? 'Native API' : 'SQLite'}
服务端状态:${health.server_online ? '在线' : '离线'}
Native API:${health.native_available ? '可用' : '不可用(未找到 adminToken)'}
数据库:${health.db_exists ? escapeHtml(health.db) : '未创建'}
提示:数据源会自动根据服务端状态切换。删除角色/删档/疲劳需 SQLite(服务端停止时)。
`; } catch (e) { content.innerHTML = `
加载失败:${escapeHtml(e.message)}
`; } } 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.saveFatigue = saveFatigue; window.maxFatigue = maxFatigue; 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();