Files
90CNServer/launcher/app.js
T
hanfuit d31a34e9aa GM 工具:点券实时刷新 + 界面排版与文案优化
服务端:
- gm_tool_wallet.go: GMAdjustAccountCera 落库后遍历在线会话,推送账号级
  点券钱包投影(op53),在线角色点券余额即时刷新,无需离线/重登

启动器前端:
- app.js: 副本控制改名「魔改中心」;删除角色详情页疲劳值编辑块
  (saveFatigue/maxFatigue 及相关输入框);黑钻提示改为「在线回城即时生效」
- index.html/shell.css/shell.js/style.css: 精简 GM 全屏标题栏(仅保留
  窗口按钮)、移除 D 字图标及死样式、修正侧边栏底部间距
2026-11-04 15:09:15 +08:00

2290 lines
102 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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();