Files
90CNServer/gmtool/static/js/pages.js
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

845 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// DNF90 GM 工具 — 页面模块
// ============================================================
const Pages = {};
// 当前选中的角色(跨页面共享)
let STATE = {
currentCharacter: null, // {character_id, name, ...}
};
function setCurrentCharacter(c) {
STATE.currentCharacter = c;
const hint = document.getElementById('selectedCharHint');
if (hint) {
hint.innerHTML = c
? `当前角色:<span style="color:#b79bff;font-weight:700">${escapeHtml(c.name)}</span> (ID ${c.character_id})`
: '未选择角色';
}
}
// ============================================================ 控制台
Pages.dashboard = async (el) => {
el.innerHTML = loadingHtml('正在获取服务端状态…');
try {
const [status, gameplay, channels] = await Promise.all([
API.get('/api/status').catch(() => null),
API.get('/api/gameplay').catch(() => null),
API.get('/api/channels').catch(() => null),
]);
CONN.connected = !!status;
updateConnDot();
const s = status || {};
el.innerHTML = `
<div class="grid grid-4">
<div class="stat-card accent">
<div class="label">在线角色</div>
<div class="value">${s.online_characters ?? 0}</div>
<div class="ico-bg">👥</div>
</div>
<div class="stat-card">
<div class="label">等级上限</div>
<div class="value">${s.level_cap ?? '—'}</div>
<div class="ico-bg">⬆️</div>
</div>
<div class="stat-card">
<div class="label">频道总数</div>
<div class="value">${channels?.total_channels ?? '—'}</div>
<div class="ico-bg">📡</div>
</div>
<div class="stat-card">
<div class="label">仓库状态</div>
<div class="value" style="font-size:20px">${s.repository_ready ? '✅ 就绪' : '⚠️ 异常'}</div>
<div class="ico-bg">🗄️</div>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="card-title"><span class="dot"></span>服务端健康</div>
<div class="grid grid-2">
${kv('仓库仓储', s.repository_ready ? '就绪' : '不可用', s.repository_ready)}
${kv('道具目录', s.item_catalog_ready ? '就绪' : '不可用', s.item_catalog_ready)}
${kv('服务端地址', CONN.base_url || '未知', true)}
${kv('等级上限', s.level_cap ?? '—', true)}
</div>
${s.error ? `<div class="hint" style="color:var(--red)">⚠ ${escapeHtml(s.error)}</div>` : ''}
</div>
<div class="card" style="margin-top:16px">
<div class="card-title"><span class="dot"></span>玩法配置</div>
${gameplay ? `
<div class="grid grid-2">
${kv('深渊史诗掉率', (gameplay.hell_drop_rate ?? 0) + '%', true)}
${kv('史诗药剂测试倍率', (gameplay.epic_potion_test_multiplier ?? 0) + '%', true)}
</div>` : '<div class="empty">暂无玩法配置数据</div>'}
</div>
<div class="card" style="margin-top:16px">
<div class="card-title"><span class="dot"></span>快速入口</div>
<div class="toolbar">
<button class="btn btn-primary" onclick="Pages.goto('characters')">👥 角色管理</button>
<button class="btn" onclick="Pages.goto('items')">🎁 道具发放</button>
<button class="btn" onclick="Pages.goto('mail')">✉️ 邮件系统</button>
<button class="btn" onclick="Pages.goto('quests')">📜 任务管理</button>
<button class="btn" onclick="Pages.goto('dungeons')">⚔️ 副本控制</button>
<button class="btn" onclick="Pages.goto('wallet')">💰 钱包点券</button>
</div>
</div>
`;
} catch (e) {
el.innerHTML = connectPanel();
}
};
function kv(label, value, ok) {
const color = ok ? 'var(--text-0)' : 'var(--red)';
return `<div style="padding:10px 0;border-bottom:1px solid var(--border)">
<span style="color:var(--text-2);font-size:13px">${escapeHtml(label)}</span>
<span style="float:right;color:${color};font-weight:600;font-size:13.5px">${escapeHtml(value)}</span>
</div>`;
}
function connectPanel() {
return `
<div class="card" style="max-width:640px;margin:40px auto">
<div class="card-title"><span class="dot"></span>连接服务端</div>
<p style="color:var(--text-2);font-size:13.5px;margin-bottom:18px;line-height:1.6">
无法连接到本地 DNF90 服务端。请确认服务端已启动,或手动填写连接信息。
</p>
<div class="conn-panel">
<div class="field" style="flex:1;min-width:120px">
<label>主机</label>
<input class="input" id="connHost" value="127.0.0.1">
</div>
<div class="field" style="width:110px">
<label>端口</label>
<input class="input" id="connPort" value="18111">
</div>
<div class="field" style="flex:1;min-width:160px">
<label>Admin Token</label>
<input class="input" id="connToken" placeholder="local-admin-token">
</div>
</div>
<div class="toolbar" style="margin-top:16px;margin-bottom:0">
<button class="btn btn-primary" onclick="doConnect()">连接</button>
<button class="btn" onclick="doDiscover()">🔍 自动发现</button>
</div>
</div>`;
}
async function doConnect() {
const host = document.getElementById('connHost').value.trim() || '127.0.0.1';
const port = parseInt(document.getElementById('connPort').value) || 18111;
const token = document.getElementById('connToken').value.trim();
try {
await API.post('/api/connect', { host, port, token });
toast('连接成功', 'ok');
CONN.connected = true;
updateConnDot();
renderCurrentPage();
} catch (e) {
toast(e.message, 'err');
}
}
async function doDiscover() {
toast('正在自动发现服务端…', 'info');
try {
const r = await API.get('/api/discover');
toast('发现服务端:' + r.base_url, 'ok');
CONN.connected = true;
updateConnDot();
renderCurrentPage();
} catch (e) {
toast(e.message, 'err');
}
}
// ============================================================ 角色管理
Pages.characters = async (el) => {
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>查询角色</div>
<div class="search-row">
<select class="select" id="charSearchType" style="width:130px">
<option value="account">按账号</option>
<option value="name">按角色名</option>
<option value="username">按登录用户名</option>
</select>
<input class="input" id="charSearch" placeholder="输入账号 / 角色名 / 用户名,留空查全部">
<button class="btn btn-primary" id="btnCharSearch">🔍 查询</button>
</div>
</div>
<div id="charList" class="card" style="margin-top:16px"></div>
`;
const doSearch = async () => {
const type = document.getElementById('charSearchType').value;
const q = document.getElementById('charSearch').value.trim();
const listEl = document.getElementById('charList');
listEl.innerHTML = loadingHtml('查询角色中…');
try {
const params = {};
if (q) {
if (type === 'account') params.account_id = q;
else if (type === 'name') params.character_name = q;
else params.username = q;
}
const qs = new URLSearchParams(params).toString();
const chars = await API.get('/api/characters' + (qs ? '?' + qs : ''));
renderCharList(listEl, chars);
} catch (e) {
listEl.innerHTML = `<div class="empty">查询失败:${escapeHtml(e.message)}</div>`;
}
};
document.getElementById('btnCharSearch').addEventListener('click', doSearch);
document.getElementById('charSearch').addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
// 默认加载
doSearch();
};
function renderCharList(el, chars) {
const list = Array.isArray(chars) ? chars : (chars?.data || []);
if (!list.length) {
el.innerHTML = '<div class="empty">没有找到角色</div>';
return;
}
el.innerHTML = `
<div class="card-title"><span class="dot"></span>角色列表 (${list.length})</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>角色</th><th>ID</th><th>账号ID</th><th>等级</th><th>金币</th><th>点券</th><th>状态</th><th>操作</th>
</tr></thead>
<tbody>
${list.map(c => `
<tr>
<td><div style="display:flex;align-items:center;gap:10px">
<div class="avatar">${escapeHtml((c.name || '?')[0])}</div>
<span style="font-weight:600">${escapeHtml(c.name)}</span>
</div></td>
<td>${c.character_id}</td>
<td style="color:var(--text-2)">${escapeHtml(c.account_id || '—')}</td>
<td><span class="tag tag-purple">Lv.${c.level ?? 0}</span></td>
<td class="num-gold">${fmt(c.gold)}</td>
<td class="num-cyan">${fmt(c.cera)}</td>
<td>${c.online ? '<span class="tag tag-green">在线</span>' : '<span class="tag tag-gray">离线</span>'}</td>
<td><div style="display:flex;gap:6px">
<button class="btn btn-xs btn-primary" onclick="openCharDetail('${c.character_id}')">详情</button>
<button class="btn btn-xs" onclick="openCharEdit('${c.character_id}','${escapeHtml(c.name||'')}',${c.level||0},${c.gold||0})">编辑</button>
</div></td>
</tr>`).join('')}
</tbody>
</table>
</div>`;
}
async function openCharDetail(id) {
openModal({ title: '角色详情', wide: true, body: loadingHtml('加载角色详情…') });
const bodyEl = document.querySelector('.modal:last-of-type .modal-body');
try {
const [detail, inv] = await Promise.all([
API.get(`/api/characters/${id}/detail`),
API.get(`/api/characters/${id}/inventory`).catch(() => null),
]);
const c = detail.character || detail;
bodyEl.innerHTML = `
<div class="grid grid-4" style="margin-bottom:18px">
${statMini('等级', c.level)}
${statMini('金币', fmt(c.gold))}
${statMini('职业', c.profession_name || c.job || '—')}
${statMini('状态', c.online ? '在线' : '离线')}
</div>
<div class="form-row">
${kv('角色ID', c.character_id)}
${kv('账号ID', c.account_id || '—')}
${kv('角色名', c.name)}
${kv('成长类型', c.grow_type ?? '—')}
</div>
<pre style="margin-top:16px;background:var(--bg-1);padding:16px;border-radius:10px;overflow:auto;font-size:12px;color:var(--text-2);max-height:300px">${escapeHtml(JSON.stringify(detail, null, 2))}</pre>`;
} catch (e) {
bodyEl.innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
}
}
function statMini(label, val) {
return `<div class="stat-card" style="padding:12px 16px">
<div class="label">${escapeHtml(label)}</div>
<div class="value" style="font-size:20px">${escapeHtml(String(val ?? '—'))}</div>
</div>`;
}
async function openCharEdit(id, name, level, gold) {
openModal({
title: `编辑角色 · ${name}`,
body: `
<div class="form-row">
<div class="field">
<label>等级</label>
<input class="input" id="editLevel" type="number" value="${level}" min="1" max="110">
</div>
<div class="field">
<label>金币</label>
<input class="input" id="editGold" type="number" value="${gold}">
</div>
<div class="field">
<label>技能点加成 (SP)</label>
<input class="input" id="editSp" type="number" value="0">
</div>
<div class="field">
<label>技能点加成 (TP)</label>
<input class="input" id="editTp" type="number" value="0">
</div>
</div>
<div class="hint">修改后角色需重新上线才完全生效。</div>`,
foot: `<button class="btn" data-close>取消</button>
<button class="btn btn-primary" onclick="saveCharEdit('${id}')">保存</button>`,
});
}
async function saveCharEdit(id) {
const level = parseInt(document.getElementById('editLevel').value);
const gold = parseInt(document.getElementById('editGold').value);
const sp = parseInt(document.getElementById('editSp').value);
const tp = parseInt(document.getElementById('editTp').value);
try {
if (level > 0) await API.post(`/api/characters/${id}/level`, { level });
if (!isNaN(gold)) await API.post(`/api/characters/${id}/gold`, { value: gold });
if (sp || tp) await API.post(`/api/characters/${id}/skill-points`, { bonus_sp: sp, bonus_tp: tp });
toast('修改成功', 'ok');
document.querySelector('.modal:last-of-type').remove();
renderCurrentPage();
} catch (e) {
toast(e.message, 'err');
}
}
// ============================================================ 道具发放
Pages.items = async (el) => {
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>道具搜索</div>
<div class="search-row">
<input class="input" id="itemSearch" placeholder="输入道具名称或 ID 搜索">
<button class="btn btn-primary" id="btnItemSearch">🔍 搜索</button>
</div>
<div id="itemSelected" class="hint" style="margin-top:10px"></div>
</div>
<div id="itemList" class="card" style="margin-top:16px"></div>
`;
let selectedItem = null;
const selEl = document.getElementById('itemSelected');
function renderSelected() {
if (!selectedItem) { selEl.innerHTML = '未选择道具'; return; }
selEl.innerHTML = `
<span style="color:var(--text-1)">已选择:</span>
<span class="${rarityColor(selectedItem.rarity)}" style="font-weight:700">
${escapeHtml(selectedItem.name || selectedItem.item_id)}
</span>
<span class="tag tag-gray" style="margin-left:8px">ID ${selectedItem.item_id}</span>`;
}
const doSearch = async () => {
const q = document.getElementById('itemSearch').value.trim();
const listEl = document.getElementById('itemList');
listEl.innerHTML = loadingHtml('搜索道具中…');
try {
const items = await API.get('/api/items/search?q=' + encodeURIComponent(q) + '&limit=50');
const list = Array.isArray(items) ? items : (items?.items || items?.data || []);
if (!list.length) { listEl.innerHTML = '<div class="empty">没有找到道具</div>'; return; }
listEl.innerHTML = `
<div class="card-title"><span class="dot"></span>搜索结果 (${list.length})</div>
<div class="table-wrap"><table>
<thead><tr><th>道具</th><th>ID</th><th>稀有度</th><th>类型</th><th>等级</th><th>操作</th></tr></thead>
<tbody>${list.map(it => `
<tr>
<td><span class="${rarityColor(it.rarity)}" style="font-weight:600">${escapeHtml(it.name || it.item_id)}</span></td>
<td>${it.item_id}</td>
<td><span class="${rarityColor(it.rarity)}">${rarityName(it.rarity)}</span></td>
<td style="color:var(--text-2)">${escapeHtml(it.kind || it.type || '—')}</td>
<td>${it.level ?? '—'}</td>
<td><button class="btn btn-xs btn-primary" onclick="selectItem('${it.item_id}')">选择</button></td>
</tr>`).join('')}
</tbody>
</table></div>`;
} catch (e) {
listEl.innerHTML = `<div class="empty">搜索失败:${escapeHtml(e.message)}</div>`;
}
};
window.selectItem = (id) => {
// 简单方式:重新查一次详情
API.get(`/api/items/${id}/detail`).then(detail => {
selectedItem = detail.item || detail || { item_id: id, name: '道具#' + id, rarity: 0 };
renderSelected();
toast('已选择道具', 'ok');
}).catch(() => {
selectedItem = { item_id: id, name: '道具#' + id, rarity: 0 };
renderSelected();
});
};
document.getElementById('btnItemSearch').addEventListener('click', doSearch);
document.getElementById('itemSearch').addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
};
// ============================================================ 邮件系统
Pages.mail = async (el) => {
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>发送邮件</div>
<div class="form-row">
<div class="field">
<label>收件角色名</label>
<input class="input" id="mailName" placeholder="角色名">
</div>
<div class="field">
<label>邮件标题</label>
<input class="input" id="mailTitle" placeholder="标题">
</div>
<div class="field full">
<label>邮件正文</label>
<textarea class="textarea" id="mailBody" placeholder="正文内容"></textarea>
</div>
<div class="field">
<label>附带金币</label>
<input class="input" id="mailGold" type="number" value="0">
</div>
<div class="field">
<label>附件道具ID(可选)</label>
<input class="input" id="mailItemId" type="number" placeholder="道具ID">
</div>
<div class="field">
<label>道具数量</label>
<input class="input" id="mailItemCount" type="number" value="1">
</div>
<div class="field">
<label>有效期(天)</label>
<input class="input" id="mailExpire" type="number" value="7">
</div>
</div>
<div class="toolbar" style="margin-top:16px;margin-bottom:0">
<button class="btn btn-primary" onclick="sendSystemMail()">📨 发送系统邮件</button>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="card-title"><span class="dot"></span>指定角色发件</div>
<div class="search-row">
<input class="input" id="mailCharId" placeholder="角色ID">
<button class="btn btn-primary" onclick="sendCharMail()">发送到角色</button>
</div>
<div class="hint">指定角色发件使用上方填写的标题、正文、道具与有效期。</div>
</div>
`;
};
async function sendSystemMail() {
const payload = {
character_name: document.getElementById('mailName').value.trim(),
title: document.getElementById('mailTitle').value.trim(),
body: document.getElementById('mailBody').value.trim(),
gold: parseInt(document.getElementById('mailGold').value) || 0,
};
const itemId = parseInt(document.getElementById('mailItemId').value);
if (itemId) {
payload.attachments = [{
item_id: itemId,
count: parseInt(document.getElementById('mailItemCount').value) || 1,
bind: false,
}];
}
if (!payload.character_name) { toast('请输入收件角色名', 'err'); return; }
try {
await API.post('/api/system-mail', payload);
toast('邮件已发送', 'ok');
} catch (e) { toast(e.message, 'err'); }
}
async function sendCharMail() {
const cid = document.getElementById('mailCharId').value.trim();
if (!cid) { toast('请输入角色ID', 'err'); return; }
const payload = {
item_id: parseInt(document.getElementById('mailItemId').value) || 0,
count: parseInt(document.getElementById('mailItemCount').value) || 1,
title: document.getElementById('mailTitle').value.trim(),
body: document.getElementById('mailBody').value.trim(),
expire_days: parseInt(document.getElementById('mailExpire').value) || 7,
};
try {
await API.post(`/api/characters/${cid}/mail`, payload);
toast('邮件已发送', 'ok');
} catch (e) { toast(e.message, 'err'); }
}
// ============================================================ 背包装备
Pages.inventory = async (el) => {
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>查看背包</div>
<div class="search-row">
<input class="input" id="invCharId" placeholder="输入角色ID">
<button class="btn btn-primary" id="btnInvLoad">查看</button>
<button class="btn btn-danger" id="btnInvClear">清空背包</button>
</div>
</div>
<div id="invList" class="card" style="margin-top:16px"></div>
`;
document.getElementById('btnInvLoad').addEventListener('click', loadInventory);
document.getElementById('btnInvClear').addEventListener('click', () => {
const cid = document.getElementById('invCharId').value.trim();
if (!cid) { toast('请输入角色ID', 'err'); return; }
if (!confirm('确定要清空该角色背包吗?此操作不可逆!')) return;
API.post(`/api/characters/${cid}/inventory/clear`, { container: 'inventory' })
.then(() => { toast('背包已清空', 'ok'); loadInventory(); })
.catch(e => toast(e.message, 'err'));
});
};
async function loadInventory() {
const cid = document.getElementById('invCharId').value.trim();
if (!cid) { toast('请输入角色ID', 'err'); return; }
const listEl = document.getElementById('invList');
listEl.innerHTML = loadingHtml('加载背包…');
try {
const inv = await API.get(`/api/characters/${cid}/inventory`);
const items = inv.items || inv.data || [];
listEl.innerHTML = `
<div class="card-title"><span class="dot"></span>背包内容 (${items.length})</div>
<div class="table-wrap"><table>
<thead><tr><th>道具</th><th>ID</th><th>数量</th><th>稀有度</th><th>操作</th></tr></thead>
<tbody>${items.map(it => `
<tr>
<td style="font-weight:600">${escapeHtml(it.name || it.item_name || it.item_id)}</td>
<td>${it.item_id}</td>
<td>${it.count ?? 1}</td>
<td><span class="${rarityColor(it.rarity)}">${rarityName(it.rarity)}</span></td>
<td><button class="btn btn-xs btn-danger" onclick="delInvItem('${cid}','${escapeHtml(it.key||it.item_id)}')">删除</button></td>
</tr>`).join('')}
</tbody>
</table></div>`;
} catch (e) {
listEl.innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
}
}
async function delInvItem(cid, key) {
if (!confirm('确定删除该道具?')) return;
try {
await API.post(`/api/characters/${cid}/inventory/delete`, { container: 'inventory', key, count: 1 });
toast('已删除', 'ok');
loadInventory();
} catch (e) { toast(e.message, 'err'); }
}
// ============================================================ 任务管理
Pages.quests = async (el) => {
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>任务查询</div>
<div class="search-row">
<input class="input" id="questCharId" placeholder="角色ID">
<input class="input" id="questSearch" placeholder="任务名/ID(可选)">
<button class="btn btn-primary" id="btnQuestLoad">查询</button>
</div>
</div>
<div id="questList" class="card" style="margin-top:16px"></div>
`;
document.getElementById('btnQuestLoad').addEventListener('click', loadQuests);
};
async function loadQuests() {
const cid = document.getElementById('questCharId').value.trim();
if (!cid) { toast('请输入角色ID', 'err'); return; }
const q = document.getElementById('questSearch').value.trim();
const listEl = document.getElementById('questList');
listEl.innerHTML = loadingHtml('加载任务…');
try {
let quests;
if (q) {
quests = await API.get(`/api/characters/${cid}/quests/search?q=${encodeURIComponent(q)}&limit=100`);
} else {
quests = await API.get(`/api/characters/${cid}/quests`);
}
const active = quests.active || [];
const completed = quests.completed || [];
listEl.innerHTML = `
<div class="card-title"><span class="dot"></span>进行中任务 (${active.length})</div>
<div class="table-wrap"><table>
<thead><tr><th>任务</th><th>ID</th><th>类型</th><th>等级</th><th>操作</th></tr></thead>
<tbody>${active.map(q => `
<tr>
<td style="font-weight:600">${escapeHtml(q.name)}</td>
<td>${q.quest_id}</td>
<td style="color:var(--text-2)">${escapeHtml(q.type || '—')}</td>
<td>${q.level_min ?? '—'}</td>
<td><div style="display:flex;gap:5px">
<button class="btn btn-xs btn-primary" onclick="questAct('${cid}',${q.quest_id},'complete')">完成</button>
<button class="btn btn-xs" onclick="questAct('${cid}',${q.quest_id},'give-up')">放弃</button>
</div></td>
</tr>`).join('')}
</tbody>
</table></div>
${completed.length ? `
<div class="card-title" style="margin-top:20px"><span class="dot"></span>已完成任务 (${completed.length})</div>
<div class="table-wrap"><table>
<thead><tr><th>任务</th><th>ID</th><th>类型</th><th>操作</th></tr></thead>
<tbody>${completed.map(q => `
<tr>
<td>${escapeHtml(q.name)}</td>
<td>${q.quest_id}</td>
<td style="color:var(--text-2)">${escapeHtml(q.type || '—')}</td>
<td><button class="btn btn-xs" onclick="questAct('${cid}',${q.quest_id},'uncomplete')">撤销完成</button></td>
</tr>`).join('')}
</tbody>
</table></div>` : ''}`;
} catch (e) {
listEl.innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
}
}
async function questAct(cid, qid, action) {
const labels = { complete: '完成', 'give-up': '放弃', uncomplete: '撤销完成', accept: '接受', ready: '就绪' };
if (!confirm(`确定要「${labels[action]}」该任务吗?`)) return;
try {
await API.post(`/api/characters/${cid}/quests/${qid}/${action}`);
toast('操作成功', 'ok');
loadQuests();
} catch (e) { toast(e.message, 'err'); }
}
// ============================================================ 副本控制
Pages.dungeons = async (el) => {
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>副本控制</div>
<div class="search-row">
<input class="input" id="dungeonCharId" placeholder="角色ID">
<button class="btn btn-primary" id="btnDungeonLoad">查看副本状态</button>
<button class="btn" id="btnDungeonUnlockDiff">解锁全部难度</button>
<button class="btn" id="btnDungeonUnlockHell">解锁深渊模式</button>
<button class="btn btn-danger" id="btnDungeonReturn">安全回城</button>
</div>
</div>
<div id="dungeonInfo" class="card" style="margin-top:16px"></div>
`;
const cid = () => document.getElementById('dungeonCharId').value.trim();
const load = async () => {
if (!cid()) { toast('请输入角色ID', 'err'); return; }
const infoEl = document.getElementById('dungeonInfo');
infoEl.innerHTML = loadingHtml('加载副本状态…');
try {
const [dungeons, control] = await Promise.all([
API.get(`/api/characters/${cid()}/dungeons`),
API.get(`/api/characters/${cid()}/dungeon-control`).catch(() => null),
]);
const st = control?.state || {};
infoEl.innerHTML = `
<div class="card-title"><span class="dot"></span>副本状态</div>
<div class="grid grid-3">
${statMini('是否在线', st.online ? '在线' : '离线')}
${statMini('是否在副本', st.in_dungeon ? '是' : '否')}
${statMini('运行状态', st.run_status || '—')}
</div>
${st.in_dungeon ? `
<div class="form-row" style="margin-top:16px">
${kv('副本ID', st.dungeon_id ?? '—')}
${kv('地图ID', st.map_id ?? '—')}
${kv('当前房间', st.current_room ?? '—')}
${kv('已访问房间', st.visited_room_count ?? '—')}
${kv('已清怪数', st.defeated_object_count ?? '—')}
${kv('可回城', st.can_return_to_town ? '是' : '否')}
</div>` : ''}
<pre style="margin-top:16px;background:var(--bg-1);padding:16px;border-radius:10px;overflow:auto;font-size:12px;color:var(--text-2);max-height:260px">${escapeHtml(JSON.stringify(dungeons, null, 2))}</pre>`;
} catch (e) {
infoEl.innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
}
};
document.getElementById('btnDungeonLoad').addEventListener('click', load);
document.getElementById('btnDungeonUnlockDiff').addEventListener('click', async () => {
if (!cid()) return toast('请输入角色ID', 'err');
try { await API.post(`/api/characters/${cid()}/dungeons/unlock-difficulties`); toast('已解锁全部难度', 'ok'); } catch (e) { toast(e.message, 'err'); }
});
document.getElementById('btnDungeonUnlockHell').addEventListener('click', async () => {
if (!cid()) return toast('请输入角色ID', 'err');
try { await API.post(`/api/characters/${cid()}/dungeons/unlock-hell`); toast('已解锁深渊模式', 'ok'); } catch (e) { toast(e.message, 'err'); }
});
document.getElementById('btnDungeonReturn').addEventListener('click', async () => {
if (!cid()) return toast('请输入角色ID', 'err');
if (!confirm('确定要将该角色安全送回城镇吗?')) return;
try { await API.post(`/api/characters/${cid()}/dungeon-return`); toast('已请求回城', 'ok'); load(); } catch (e) { toast(e.message, 'err'); }
});
};
// ============================================================ 钱包点券
Pages.wallet = async (el) => {
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>钱包点券</div>
<div class="search-row">
<input class="input" id="walletCharId" placeholder="角色ID">
<button class="btn btn-primary" id="btnWalletQuery">查询钱包</button>
<button class="btn" onclick="bdStatus()">黑钻状态</button>
<button class="btn btn-primary" onclick="bdActivate()">开通黑钻</button>
</div>
</div>
<div id="walletInfo" class="card" style="margin-top:16px"></div>
`;
document.getElementById('btnWalletQuery').addEventListener('click', queryWallet);
};
async function queryWallet() {
const cid = document.getElementById('walletCharId').value.trim();
if (!cid) { toast('请输入角色ID', 'err'); return; }
const infoEl = document.getElementById('walletInfo');
infoEl.innerHTML = loadingHtml('查询钱包…');
try {
const wallet = await API.post(`/api/characters/${cid}/wallet/query`);
infoEl.innerHTML = `
<div class="card-title"><span class="dot"></span>钱包信息</div>
<pre style="background:var(--bg-1);padding:16px;border-radius:10px;overflow:auto;font-size:13px;color:var(--text-1)">${escapeHtml(JSON.stringify(wallet, null, 2))}</pre>`;
} catch (e) {
infoEl.innerHTML = `<div class="empty">查询失败:${escapeHtml(e.message)}</div>`;
}
}
async function bdStatus() {
const cid = document.getElementById('walletCharId').value.trim();
try {
const r = await API.get('/api/black-diamond' + (cid ? '?id=' + cid : ''));
const infoEl = document.getElementById('walletInfo');
infoEl.innerHTML = `<div class="card-title"><span class="dot"></span>黑钻状态</div>
<pre style="background:var(--bg-1);padding:16px;border-radius:10px;overflow:auto;font-size:13px;color:var(--text-1)">${escapeHtml(JSON.stringify(r, null, 2))}</pre>`;
} catch (e) { toast(e.message, 'err'); }
}
async function bdActivate() {
const cid = document.getElementById('walletCharId').value.trim();
if (!cid) { toast('请输入角色ID', 'err'); return; }
const days = prompt('开通黑钻天数(默认30天)', '30');
if (days === null) return;
try {
await API.post('/api/black-diamond/activate', { character_id: cid, days: parseInt(days) || 30 });
toast('黑钻已开通', 'ok');
bdStatus();
} catch (e) { toast(e.message, 'err'); }
}
// ============================================================ 玩法配置
Pages.gameplay = async (el) => {
el.innerHTML = `<div class="card"><div class="card-title"><span class="dot"></span>玩法配置</div></div>`;
const card = el.querySelector('.card');
try {
const gp = await API.get('/api/gameplay');
card.innerHTML = `
<div class="card-title"><span class="dot"></span>玩法配置</div>
<div class="form-row">
<div class="field">
<label>深渊史诗掉率(%)</label>
<input class="input" id="gpHellRate" type="number" value="${gp.hell_drop_rate ?? 0}">
</div>
<div class="field">
<label>史诗药剂测试倍率(%)</label>
<input class="input" id="gpPotion" type="number" value="${gp.epic_potion_test_multiplier ?? 0}">
</div>
</div>
<div class="toolbar" style="margin-top:16px;margin-bottom:0">
<button class="btn btn-primary" onclick="saveGameplay()">保存配置</button>
</div>`;
} catch (e) {
card.innerHTML = `<div class="card-title"><span class="dot"></span>玩法配置</div>
<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
}
};
async function saveGameplay() {
const hellRate = parseInt(document.getElementById('gpHellRate').value) || 0;
const potion = parseInt(document.getElementById('gpPotion').value) || 0;
try {
await API.post('/api/gameplay/hell-drop-rate', { percent: hellRate });
await API.post('/api/gameplay/epic-potion-multiplier', { percent: potion });
toast('配置已保存', 'ok');
} catch (e) { toast(e.message, 'err'); }
}
// ============================================================ 频道管理
Pages.channels = async (el) => {
el.innerHTML = loadingHtml('加载频道…');
try {
const channels = await API.get('/api/channels');
const list = channels.channels || [];
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>频道总览</div>
<div class="grid grid-4">
${statMini('频道总数', channels.total_channels)}
${statMini('已启用', channels.enabled_channels)}
${statMini('游戏端口', channels.game_ports)}
${statMini('单机频道', (channels.single_player_ids || []).join(', ') || '—')}
</div>
<div class="toolbar" style="margin-top:16px;margin-bottom:0">
<button class="btn btn-primary" onclick="reloadChannels()">🔄 热重载频道</button>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="card-title"><span class="dot"></span>频道列表</div>
<div class="table-wrap"><table>
<thead><tr><th>ID</th><th>名称</th><th>分组</th><th>端口</th><th>类型</th><th>状态</th></tr></thead>
<tbody>${list.map(c => `
<tr>
<td>${c.id}</td>
<td style="font-weight:600">${escapeHtml(c.name || '—')}</td>
<td style="color:var(--text-2)">${escapeHtml(c.group || '—')}</td>
<td>${c.port ?? '—'}</td>
<td>${c.type === 0 ? '普通' : '特殊'}</td>
<td>${c.enabled ? '<span class="tag tag-green">启用</span>' : '<span class="tag tag-gray">禁用</span>'}</td>
</tr>`).join('')}
</tbody>
</table></div>
</div>`;
} catch (e) {
el.innerHTML = `<div class="card"><div class="empty">加载失败:${escapeHtml(e.message)}</div></div>`;
}
};
async function reloadChannels() {
try {
await API.post('/api/channels/reload');
toast('频道已热重载', 'ok');
renderCurrentPage();
} catch (e) { toast(e.message, 'err'); }
}
// ============================================================ 连接设置
Pages.settings = async (el) => {
el.innerHTML = `
<div class="card" style="max-width:640px">
<div class="card-title"><span class="dot"></span>连接设置</div>
<div class="conn-panel">
<div class="field" style="flex:1;min-width:120px">
<label>主机</label>
<input class="input" id="connHost" value="127.0.0.1">
</div>
<div class="field" style="width:110px">
<label>端口</label>
<input class="input" id="connPort" value="18111">
</div>
<div class="field" style="flex:1;min-width:160px">
<label>Admin Token</label>
<input class="input" id="connToken" placeholder="local-admin-token">
</div>
</div>
<div class="toolbar" style="margin-top:16px;margin-bottom:0">
<button class="btn btn-primary" onclick="doConnect()">连接</button>
<button class="btn" onclick="doDiscover()">🔍 自动发现</button>
</div>
<div class="hint" style="margin-top:14px">当前后端地址:${CONN.base_url || '未连接'}</div>
</div>`;
};
Pages.goto = (page) => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.page === page));
renderPage(page);
};