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