Files
90CNServer/launcher/shell.js
T
hanfuit f2debaf9bf 启动器:接入 Gitea Releases 自动检查更新 + 更新日志页
- server.py: 新增 /api/update/check、/api/update/changelog 接口,
  用标准库 urllib 请求自托管仓库 Lingbao/90CNServer 的 Releases,
  带 5 分钟缓存与 8 秒超时;本地版本号写死 v2026.11.04
- shell.js: 版本卡片改为动态读取远程最新 tag,发现新版本绿色可点击
  跳转;新增 loadChangelog 渲染发布记录
- index.html/shell.css: 更新日志页改为动态容器,补充卡片样式
2026-11-04 19:16:05 +08:00

1048 lines
50 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.
/* 90轮回启动器 · 单窗口页面与工具整合 */
(function () {
const $ = (id) => document.getElementById(id);
const pages = ['start','gm','loadout','companion','servertool','plugins','settings','changelog'];
let shellGmInit = false;
let loConfig = null, loPage = 0, loScheme = -1;
let loCooldownUntil = 0, loCooldownCharacter = '', loCooldownTimer = null;
let stFeatures = {}, stActivity = {}, stTab = 'global';
let stGameplay = {};
let stBatchPickup = true;
let hotkeyCapture = false;
let stRaidTab = 'anton';
let stFeatureTab = 'instance.json';
let stRawText = '';
const crumb = {start:'开始 / 本地服务端',gm:'GM工具',loadout:'工具 / 一键换装',companion:'工具 / 支援兵',servertool:'工具 / 服务端工具',plugins:'插件',settings:'设置',changelog:'更新日志'};
function shellGo(page) {
if (!pages.includes(page)) page = 'start';
document.querySelectorAll('.shell-page').forEach(el => el.classList.toggle('active', el.id === 'page-' + page));
document.querySelectorAll('.shell-nav-item').forEach(el => el.classList.toggle('active', el.dataset.shellPage === page));
$('shellCrumb').textContent = crumb[page] || page;
if (page === 'start') loadStart();
if (page === 'loadout') loadLoadout();
if (page === 'companion') loadCompanion();
if (page === 'servertool') loadServerTool();
if (page === 'settings') loadSettings();
if (page === 'changelog') loadChangelog();
if (page === 'gm') {
// 进入 GM:GM 覆盖整个窗口(隐藏顶部导航/面包屑/底部状态栏),并铺满工作区
document.body.classList.add('gm-fullscreen');
const w = win();
if (w && w.enter_gm) w.enter_gm();
if (!shellGmInit && typeof initGm === 'function') {
shellGmInit = true;
initGm();
if (typeof renderGmPage === 'function') renderGmPage('dashboard');
} else if (typeof renderGmPage === 'function') {
renderGmPage(gmCurrentPage || 'dashboard');
}
} else {
// 离开 GM:移除全屏状态,并恢复窗口几何(若正处于全屏)
if (document.body.classList.contains('gm-fullscreen')) {
document.body.classList.remove('gm-fullscreen');
const w2 = win();
if (w2 && w2.restore_gm) w2.restore_gm();
}
}
}
window.shellGo = shellGo;
// 从 GM 返回启动器:恢复窗口几何 + 回到开始页
function shellExitGm() {
document.body.classList.remove('gm-fullscreen');
const w = win();
if (w && w.restore_gm) w.restore_gm();
shellGo('start');
}
window.shellExitGm = shellExitGm;
document.querySelectorAll('.shell-nav-item').forEach(el => {
el.addEventListener('click', () => shellGo(el.dataset.shellPage));
});
// 顶部窗口按钮
const win = () => (window.pywebview && window.pywebview.api) || null;
const bMin = $('shellWinMin'); if (bMin) bMin.addEventListener('click', () => { const w = win(); if (w && w.minimize) w.minimize(); });
const bClose = $('shellWinClose'); if (bClose) bClose.addEventListener('click', () => { const w = win(); if (w && w.close) w.close(); });
// 账号登录:打开原账号管理弹窗,可选择默认账号
$('shellAccount').addEventListener('click', () => {
if (typeof openAccounts === 'function') openAccounts();
else alert('账号管理未加载');
});
async function shellAddAccount() {
const user = $('newAccUser').value.trim();
const pass = $('newAccPass').value;
const toast = (msg, type) => {
if (typeof gmToast === 'function') gmToast(msg, type || 'info');
else alert(msg);
};
if (!user || !pass) { toast('请填写用户名和密码', 'err'); return; }
try {
const r = await post('/api/accounts-config', {op:'add', username:user, password:pass});
if (!r.ok) { toast('添加失败:' + (r.error || '未知错误'), 'err'); return; }
$('newAccUser').value = '';
$('newAccPass').value = '';
$('accountsModal').classList.add('hidden');
await loadAccountLabel();
toast('账号添加成功:' + user, 'ok');
} catch (e) { toast('添加失败:' + e.message, 'err'); }
}
// app.js 的 init 如果没有完成绑定,这里也保证添加按钮可用
const addBtn = $('btnAddAccount');
if (addBtn) {
const fresh = addBtn.cloneNode(true);
addBtn.replaceWith(fresh);
fresh.addEventListener('click', shellAddAccount);
}
// 开始页
let startState = 'idle'; // idle | starting | started
function setStartState(state) {
startState = state;
const btn = $('shellStart');
if (!btn) return;
btn.disabled = state === 'starting';
if (state === 'starting') btn.innerHTML = '<span>⏳</span> 游戏启动中…';
else if (state === 'started') btn.innerHTML = '<span>✔</span> 游戏已启动';
else btn.innerHTML = '<span>▶</span> 开始游戏';
}
$('shellStart').addEventListener('click', () => {
if (startState === 'starting') return;
setStartState('starting');
doAction('start');
});
$('shellStartMenu').addEventListener('click', () => $('shellStartPop').classList.toggle('hidden'));
document.querySelectorAll('#shellStartPop button').forEach(b => b.addEventListener('click', () => {
$('shellStartPop').classList.add('hidden');
if (b.dataset.shellAction === 'stop') setStartState('idle');
doAction(b.dataset.shellAction);
}));
$('shellStop').addEventListener('click', () => {
setStartState('idle');
doAction('stop');
});
async function doAction(action) {
const map = {start:'start', launch:'start', stop:'stop'};
const payload = {action: map[action] || action};
if (payload.action === 'start') {
payload.account = $('shellAccountSelect').value || '';
}
try {
const r = await post('/api/action', payload);
if (!r.ok) {
if (startState === 'starting') setStartState('idle');
alert(r.error || '操作失败');
}
setTimeout(loadStart, 900);
} catch (e) {
if (startState === 'starting') setStartState('idle');
alert('操作失败:' + e.message);
}
}
async function loadStart() {
try {
const s = await api('/api/status');
const running = !!s.server_running;
if (running && startState !== 'started') setStartState('started');
else if (!running && startState === 'started') setStartState('idle');
$('shellStatusDot').style.background = running ? '#3dd68b' : '#5a7568';
$('shellStatusText').textContent = running ? '运行中' : '未运行';
$('shellPid').textContent = running ? '服务端已启动' : 'PID —';
$('shellBottomStatus').textContent = running ? '运行中' : '未运行';
$('statClient').textContent = running ? '运行中' : '未运行';
$('statPorts').textContent = running ? '18111 / 7001' : '—';
$('statPvf').textContent = s.db_exists ? '已校验通过' : '未检测';
$('statClientDir').textContent = s.client_dir || '—';
$('statDb').textContent = s.db_exists ? 'SQLite 就绪' : '未创建';
$('statGmPort').textContent = '18111';
$('statApiPort').textContent = location.port || '19527';
$('shellBottomPort').textContent = running ? '18111 / 7001' : '—';
$('statState').textContent = s.control_exists && s.client_dir_exists ? '已就绪' : '不完整';
$('statClient').textContent = s.client_dir_exists ? (running ? '运行中' : '已就绪') : '缺失';
$('shellClock').textContent = '检查于 ' + new Date().toLocaleTimeString('zh-CN', {hour12:false});
$('statResource').textContent = s.admin ? '管理员' : '普通权限';
loadAccountLabel();
loadUpdateStatus();
} catch (e) { /* ignore */ }
}
async function loadAccountLabel() {
try {
const r = await api('/api/accounts-config');
const list = (r && r.data) || [];
const selected = list.find(a => a.is_default) || list[0];
$('shellAccountName').textContent = selected ? selected.username : '未配置账号';
const sel = $('shellAccountSelect');
if (sel && list.length) {
const current = sel.value;
sel.innerHTML = list.map(a => `<option value="${a.username}">${a.username}</option>`).join('');
sel.value = selected ? selected.username : (current || list[0].username);
}
} catch (e) { /* ignore */ }
}
// 检查更新:填充「版本」卡片里的「本地 / 远程最新 / 状态」
async function loadUpdateStatus() {
const remoteEl = $('statRemote');
const versionEl = $('statVersion');
try {
const r = await api('/api/update/check');
if (versionEl) versionEl.textContent = r.current || '—';
if (!remoteEl) return;
if (r.error) {
remoteEl.textContent = '检查失败';
remoteEl.className = '';
remoteEl.title = '无法连接更新服务器:' + r.error;
} else if (r.has_update && r.latest) {
remoteEl.textContent = r.latest;
remoteEl.className = 'green';
remoteEl.title = '发现新版本,点击查看';
remoteEl.style.cursor = 'pointer';
remoteEl.onclick = () => { window.open(r.latest_url, '_blank'); };
} else if (r.latest) {
remoteEl.textContent = r.latest;
remoteEl.className = 'green';
remoteEl.title = '已是最新版本';
} else {
remoteEl.textContent = '暂无发布';
remoteEl.className = '';
}
} catch (e) {
if (remoteEl) { remoteEl.textContent = '检查失败'; remoteEl.className = ''; }
}
}
// 更新日志页面:拉取所有 Release 的 changelog
async function loadChangelog() {
const box = $('changelogBody');
if (!box) return;
box.innerHTML = '<div class="empty-box">正在获取更新日志…</div>';
try {
const r = await api('/api/update/changelog');
if (!r.ok) {
box.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(r.error || '未知错误') + '</div>';
return;
}
const rels = r.releases || [];
if (!rels.length) {
box.innerHTML = '<div class="empty-box">暂无发布记录。<br>在 Gitea 仓库「发布 Release」并填写更新说明后,这里会自动显示。</div>';
return;
}
box.innerHTML = rels.map(rel => {
const badge = rel.prerelease ? '<span class="gm-tag gm-tag-gray">预发布</span>' : '';
const body = (rel.body || '').replace(/\r?\n/g, '<br>');
const assets = (rel.assets || []).map(a =>
`<a class="changelog-asset" href="${escapeHtml(a.download_url)}" target="_blank">⬇ ${escapeHtml(a.name)} (${fmt(a.size)} B)</a>`
).join('');
const when = rel.published_at ? rel.published_at.replace('T', ' ').slice(0, 16) : '';
return `<div class="changelog-item">
<div class="changelog-head">
<span class="changelog-tag">${escapeHtml(rel.tag || rel.name || '')}</span>
<span class="changelog-name">${escapeHtml(rel.name || '')}</span>
${badge}
<span class="changelog-time">${escapeHtml(when)}</span>
</div>
<div class="changelog-body">${body}</div>
${assets ? '<div class="changelog-assets">' + assets + '</div>' : ''}
<a class="changelog-link" href="${escapeHtml(rel.html_url)}" target="_blank">在浏览器中查看 ↗</a>
</div>`;
}).join('');
} catch (e) {
box.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(e && e.message ? e.message : e) + '</div>';
}
}
$('shellAccountSelect').addEventListener('change', async () => {
const username = $('shellAccountSelect').value;
try {
const r = await post('/api/accounts-config', {op:'select', username});
if (r.ok) { $('shellAccountName').textContent = username; }
} catch (e) { /* ignore */ }
});
// ---------------- 一键换装 ----------------
function normalizeConfig(cfg) {
cfg = cfg || {};
if (!Array.isArray(cfg.Pages) || !cfg.Pages.length) cfg.Pages = [{Name:'默认页', Schemes:[]}];
cfg.Pages.forEach(p => { if (!Array.isArray(p.Schemes)) p.Schemes = []; });
cfg.ActivePage = Math.max(0, Math.min(Number(cfg.ActivePage) || 0, cfg.Pages.length - 1));
return cfg;
}
async function loadLoadout() {
try {
const r = await api('/api/loadout/config');
loConfig = normalizeConfig(r.data);
loPage = loConfig.ActivePage || 0;
renderLoadout();
} catch (e) { $('loStatus').textContent = '读取失败:' + e.message; }
}
function currentPage() { return loConfig.Pages[loPage]; }
function currentScheme() { return currentPage().Schemes[loScheme] || null; }
function loadoutCooldownRemaining() {
return Math.max(0, Math.ceil((loCooldownUntil - Date.now()) / 1000));
}
function updateLoadoutCooldown() {
const s = currentScheme();
const active = s && s.CharacterName === loCooldownCharacter;
const remain = active ? loadoutCooldownRemaining() : 0;
const el = $('loCooldown');
if (remain > 0) {
el.textContent = `换装冷却:${remain} 秒`;
el.classList.add('active');
$('loApply').disabled = true;
} else {
el.textContent = '';
el.classList.remove('active');
$('loApply').disabled = false;
if (loCooldownTimer) { clearInterval(loCooldownTimer); loCooldownTimer = null; }
}
}
function startLoadoutCooldown(character, seconds) {
loCooldownCharacter = character || '';
loCooldownUntil = Date.now() + Math.max(0, Number(seconds) || 0) * 1000;
if (loCooldownTimer) clearInterval(loCooldownTimer);
updateLoadoutCooldown();
loCooldownTimer = setInterval(updateLoadoutCooldown, 250);
}
function renderLoadout() {
const pages = $('loPages'); pages.innerHTML = '';
loConfig.Pages.forEach((p, i) => {
const b = document.createElement('button');
b.className = 'lo-page' + (i === loPage ? ' active' : '');
b.textContent = p.Name || ('页 ' + (i + 1));
b.onclick = async () => { loPage = i; loConfig.ActivePage = i; loScheme = -1; renderLoadout(); await saveLoadout(false); };
pages.appendChild(b);
});
const list = $('loList'); list.innerHTML = '';
currentPage().Schemes.forEach((s, i) => {
const d = document.createElement('div');
d.className = 'lo-item' + (i === loScheme ? ' active' : '');
d.textContent = `${s.Name || '方案'} [${s.CharacterName || '未绑定'}] ${hotkeyText(s.Modifiers, s.KeyCode)} (${(s.Slots||[]).length} 件)`;
d.onclick = () => { loScheme = i; renderLoadout(); };
list.appendChild(d);
});
const s = currentScheme();
$('loName').value = s ? (s.Name || '') : '';
$('loCharacter').value = s ? (s.CharacterName || '') : '';
$('loHotkey').value = s ? hotkeyText(s.Modifiers, s.KeyCode) : '未设置';
const slots = $('loSlots');
slots.innerHTML = '';
if (!s || !(s.Slots || []).length) {
slots.innerHTML = '<div class="empty-box">选择方案后显示装备快照;点击「保存当前装备」从服务端读取。</div>';
} else {
s.Slots.forEach(it => {
const d = document.createElement('div');
d.className = 'lo-slot';
d.innerHTML = `槽位 ${it.slot}<b>${it.item_id}</b>`;
slots.appendChild(d);
});
}
updateLoadoutCooldown();
}
function hotkeyText(mods, key) {
if (!key) return '未设置';
const p = [];
if (mods & 2) p.push('Ctrl');
if (mods & 1) p.push('Alt');
if (mods & 4) p.push('Shift');
p.push(keyName(key));
return p.join('+');
}
function keyName(code) {
if (code >= 112 && code <= 123) return 'F' + (code - 111);
if (code >= 65 && code <= 90) return String.fromCharCode(code);
if (code >= 48 && code <= 57) return String.fromCharCode(code);
return String(code);
}
$('loAddPage').addEventListener('click', async () => {
const name = prompt('换装页名称', '换装页' + (loConfig.Pages.length + 1));
if (!name) return;
loConfig.Pages.push({Name:name, Schemes:[]});
loPage = loConfig.Pages.length - 1; loConfig.ActivePage = loPage; loScheme = -1;
renderLoadout(); await saveLoadout();
});
$('loAddScheme').addEventListener('click', async () => {
const name = prompt('方案名称', '方案' + (currentPage().Schemes.length + 1));
if (!name) return;
const character = prompt('绑定角色游戏内名称(可稍后修改)', '') || '';
currentPage().Schemes.push({Name:name, CharacterName:character, Modifiers:0, KeyCode:0, Slots:[]});
loScheme = currentPage().Schemes.length - 1;
renderLoadout(); await saveLoadout();
});
$('loDeleteScheme').addEventListener('click', async () => {
if (loScheme < 0) return;
if (!confirm('删除当前方案?')) return;
currentPage().Schemes.splice(loScheme, 1);
loScheme = -1; renderLoadout(); await saveLoadout();
});
$('loSaveConfig').addEventListener('click', () => saveLoadout());
$('loName').addEventListener('change', () => { const s=currentScheme(); if(s){s.Name=$('loName').value; renderLoadout();} });
$('loCharacter').addEventListener('change', () => { const s=currentScheme(); if(s){s.CharacterName=$('loCharacter').value.trim(); renderLoadout();} });
async function saveLoadout(show = true) {
try {
if (loConfig) { loConfig.ActivePage = loPage; const s=currentScheme(); if(s){s.Name=$('loName').value; s.CharacterName=$('loCharacter').value.trim();} }
const r = await post('/api/loadout/config', {config: loConfig});
if (r.ok) { if (show) $('loStatus').textContent = '配置已保存 ' + new Date().toLocaleTimeString(); }
else $('loStatus').textContent = r.error || '保存失败';
} catch (e) { $('loStatus').textContent = '保存失败:' + e.message; }
}
$('loRefreshChars').addEventListener('click', async () => {
try {
const r = await api('/api/loadout/characters');
if (!r.ok) { $('loStatus').textContent = r.error || '读取失败'; return; }
const list = r.data || [];
$('loStatus').textContent = list.map(c => `${c.name}(${c.online?'在线':'离线'})`).join(' ') || '没有角色';
} catch (e) { $('loStatus').textContent = e.message; }
});
$('loSaveCurrent').addEventListener('click', async () => {
const s = currentScheme();
if (!s) { $('loStatus').textContent = '请先选择方案'; return; }
if (!s.CharacterName) { $('loStatus').textContent = '请先填写绑定角色'; return; }
try {
const r = await api('/api/loadout/equipment?character=' + encodeURIComponent(s.CharacterName));
if (!r.ok) { $('loStatus').textContent = r.error || '读取装备失败'; return; }
s.Slots = r.data.slots || [];
renderLoadout(); await saveLoadout();
$('loStatus').textContent = `已保存 ${s.Slots.length} 个部位`;
} catch (e) { $('loStatus').textContent = e.message; }
});
$('loApply').addEventListener('click', async () => {
const s = currentScheme();
if (!s) { $('loStatus').textContent = '请先选择方案'; return; }
if (s.CharacterName === loCooldownCharacter && loadoutCooldownRemaining() > 0) {
$('loStatus').textContent = `换装冷却中,还需 ${loadoutCooldownRemaining()} 秒`;
return;
}
try {
const r = await post('/api/loadout/apply', {character:s.CharacterName, slots:s.Slots || []});
if (r.ok) {
const d = r.data || {};
const applied = (d.applied || []).length;
const skipped = (d.skipped || []).length;
if (Number(d.cooldown_seconds) > 0) {
startLoadoutCooldown(s.CharacterName, Number(d.cooldown_seconds));
$('loStatus').textContent = `应用完成:成功 ${applied},跳过 ${skipped};换装冷却 ${d.cooldown_seconds} 秒`;
} else {
$('loStatus').textContent = `应用完成:成功 ${applied},跳过 ${skipped}`;
}
} else {
const msg = r.error || '应用失败';
const m = msg.match(/cooldown[^0-9]*(\d+)\s*seconds?/i);
if (m) {
startLoadoutCooldown(s.CharacterName, Number(m[1]));
$('loStatus').textContent = `换装冷却中,还需 ${m[1]} 秒才能再次换装`;
} else {
$('loStatus').textContent = msg;
}
}
} catch (e) { $('loStatus').textContent = e.message; }
});
$('loCaptureHotkey').addEventListener('click', () => {
if (!currentScheme()) { $('loStatus').textContent = '请先选择方案'; return; }
hotkeyCapture = true; $('loHotkey').value = '请按下组合键…'; $('loHotkey').focus();
});
$('loHotkey').addEventListener('keydown', (e) => {
if (!hotkeyCapture) return;
e.preventDefault();
let mods = 0;
if (e.ctrlKey) mods |= 2;
if (e.altKey) mods |= 1;
if (e.shiftKey) mods |= 4;
const key = e.keyCode;
if (!key || key === 17 || key === 18 || key === 16) return;
const s = currentScheme();
if (!s) return;
s.Modifiers = mods; s.KeyCode = key;
hotkeyCapture = false;
$('loHotkey').value = hotkeyText(mods, key);
saveLoadout();
});
// ---------------- 支援兵 ----------------
let cpAccounts = [], cpCharacters = [], cpState = null, cpSelected = new Set();
let cpAIProfiles = {}, cpAISource = '';
let cpLoading = false;
const CP_AI_DEFAULTS = {
behavior:'aggressive', buff_policy:'leader_first', combo_preset:'auto',
combo_enabled:true, aoe_min_targets:3, protect_leader:true,
tuning:{think_interval_ms:120,assist_attack_interval_ms:1800,assist_leash_x:180,assist_leash_y:90,
chase_range:50,vision_range:10000,target_stickiness_ms:1200,heal_leader_percent:85,
heal_self_percent:85,retry_ms:300,support_retry_ms:1500,busy_move_ms:2500,stuck_ms:700,
detour_ms:900,give_up_ms:3500,follow_distance:75}
};
async function loadCompanion() {
if (cpLoading) return;
cpLoading = true;
$('cpStatus').textContent = '读取账号和角色…';
try {
const a = await api('/api/accounts');
const ba = await api('/api/basic-attack');
$('cpBasicAttack').checked = !ba || ba.enabled !== false;
cpAccounts = (a && a.data) || [];
const sel = $('cpAccount');
sel.innerHTML = cpAccounts.map(x => `<option value="${x.account_id || x.username || ''}">${x.username || x.account_id}</option>`).join('');
if (!cpAccounts.length) { $('cpStatus').textContent = '没有账号'; return; }
await loadCompanionCharacters();
$('cpStatus').textContent = '';
} catch (e) {
$('cpStatus').textContent = '读取失败:' + e.message;
} finally { cpLoading = false; }
}
async function loadCompanionCharacters() {
const account = $('cpAccount').value;
const r = await api('/api/characters?account=' + encodeURIComponent(account));
cpCharacters = (r && r.data) || [];
const owner = $('cpOwner');
owner.innerHTML = cpCharacters.map(c => `<option value="${c.character_id}">${c.name} · Lv${c.level}</option>`).join('');
if (cpCharacters.length) await loadCompanionState();
else { $('cpGrid').innerHTML = '<div class="empty-box">当前账号没有角色</div>'; }
}
async function loadCompanionState() {
const owner = $('cpOwner').value;
if (!owner) return;
$('cpStatus').textContent = '读取支援兵配置…';
try {
const r = await api('/api/companion?character=' + encodeURIComponent(owner));
if (!r.ok) { $('cpStatus').textContent = r.error || '读取失败'; return; }
cpState = r.data || {};
const cfg = (cpState.state && cpState.state.config) || {};
$('cpEnabled').checked = !!cfg.enabled;
$('cpBalance').checked = !!cfg.balance_enabled;
$('cpProgression').checked = !!cfg.progression_enabled;
$('cpRandom').checked = !!cfg.random_equipment_enabled;
cpSelected = new Set((cfg.source_character_ids || []).map(String));
cpAIProfiles = JSON.parse(JSON.stringify(cfg.ai_profiles || {}));
cpAISource = [...cpSelected][0] || '';
renderCompanionGrid();
$('cpStatus').textContent = (cpState.state && cpState.state.preflight && cpState.state.preflight.reason_message) || '已读取';
} catch (e) { $('cpStatus').textContent = '读取失败:' + e.message; }
}
function renderCompanionGrid() {
const owner = String($('cpOwner').value || '');
const list = cpCharacters.filter(c => String(c.character_id) !== owner);
const grid = $('cpGrid');
if (!list.length) { grid.innerHTML = '<div class="empty-box">当前账号没有其他角色可作为支援兵</div>'; renderCpAI(); return; }
grid.innerHTML = list.map(c => {
const id = String(c.character_id), selected = cpSelected.has(id);
return `<label class="cp-card ${selected?'selected':''}">
<input type="checkbox" data-cp-source="${id}" ${selected?'checked':''}>
<b>${c.name || id}</b><small>Lv${c.level ?? '-'} · 职业 ${c.job ?? '-'}</small>
</label>`;
}).join('');
grid.querySelectorAll('input[data-cp-source]').forEach(inp => {
inp.onchange = () => {
const id = inp.dataset.cpSource;
if (inp.checked) cpSelected.add(id); else cpSelected.delete(id);
inp.closest('.cp-card').classList.toggle('selected', inp.checked);
renderCpAI();
};
});
renderCpAI();
}
function cpProfile(id) {
const raw = cpAIProfiles[String(id)] || {};
return {
...CP_AI_DEFAULTS, ...raw,
tuning: {...CP_AI_DEFAULTS.tuning, ...(raw.tuning || {})}
};
}
function renderCpAI() {
const select = $('cpAISource');
const ids = [...cpSelected];
if (!ids.includes(cpAISource)) cpAISource = ids[0] || '';
select.innerHTML = ids.map(id => {
const c = cpCharacters.find(x => String(x.character_id) === id);
return `<option value="${id}">${c ? c.name : id}</option>`;
}).join('');
if (cpAISource) select.value = cpAISource;
if (!cpAISource) {
$('cpAIBehavior').value = 'assist'; $('cpAIBuff').value = 'leader_first';
$('cpAIComboPreset').value = 'auto'; $('cpAIAoe').value = 3;
$('cpAIComboEnabled').checked = true; $('cpAIProtect').checked = true;
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
inp.value = CP_AI_DEFAULTS.tuning[inp.dataset.cpTuning] ?? 0;
});
return;
}
const p = cpProfile(cpAISource);
$('cpAIBehavior').value = ['assist','aggressive'].includes(p.behavior) ? p.behavior : 'aggressive';
$('cpAIBuff').value = p.buff_policy || 'leader_first';
$('cpAIComboPreset').value = p.combo_preset || 'auto';
$('cpAIAoe').value = Number(p.aoe_min_targets) || 3;
$('cpAIComboEnabled').checked = !!p.combo_enabled;
$('cpAIProtect').checked = !!p.protect_leader;
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
inp.value = p.tuning[inp.dataset.cpTuning] ?? 0;
});
}
function readCpAI() {
if (!cpAISource) return;
const p = cpProfile(cpAISource);
p.behavior = $('cpAIBehavior').value;
p.buff_policy = $('cpAIBuff').value;
p.combo_preset = $('cpAIComboPreset').value;
p.combo_enabled = $('cpAIComboEnabled').checked;
p.aoe_min_targets = Math.max(2, Math.min(6, Number($('cpAIAoe').value) || 3));
p.protect_leader = $('cpAIProtect').checked;
p.tuning = {...p.tuning};
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
const n = Number(inp.value);
if (Number.isFinite(n)) p.tuning[inp.dataset.cpTuning] = n;
});
cpAIProfiles[String(cpAISource)] = p;
}
$('cpAccount').addEventListener('change', async () => {
try { await loadCompanionCharacters(); } catch (e) { $('cpStatus').textContent = e.message; }
});
$('cpOwner').addEventListener('change', loadCompanionState);
$('cpReload').addEventListener('click', loadCompanion);
$('cpAISource').addEventListener('change', () => { cpAISource = $('cpAISource').value; renderCpAI(); });
['cpAIBehavior','cpAIBuff','cpAIComboPreset','cpAIAoe','cpAIComboEnabled','cpAIProtect'].forEach(id => {
$(id).addEventListener('change', readCpAI);
});
document.querySelectorAll('[data-cp-tuning]').forEach(inp => inp.addEventListener('change', readCpAI));
$('cpBasicAttack').addEventListener('change', async () => {
try {
const r = await post('/api/basic-attack', {enabled: $('cpBasicAttack').checked});
if (!r.ok) $('cpStatus').textContent = r.error || '普攻开关保存失败';
else $('cpStatus').textContent = $('cpBasicAttack').checked
? '已允许支援兵普攻;重启客户端后生效。'
: '已关闭支援兵普攻,技能释放不受影响;重启客户端后生效。';
} catch (e) { $('cpStatus').textContent = '普攻开关保存失败:' + e.message; }
});
$('cpSave').addEventListener('click', async () => {
const owner = $('cpOwner').value;
if (!owner) { $('cpStatus').textContent = '请先选择出战角色'; return; }
if ($('cpEnabled').checked && cpSelected.size === 0) {
$('cpStatus').textContent = '启用支援兵时至少选择一个支援兵角色'; return;
}
const state = (cpState && cpState.state) || {};
const cfg = state.config || {};
const sources = [...cpSelected];
readCpAI();
const payload = {
characterId: String(owner),
expectedRevision: Number(cfg.revision || 0),
enabled: $('cpEnabled').checked,
balanceEnabled: $('cpBalance').checked,
progressionEnabled: $('cpProgression').checked,
randomEquipmentEnabled: $('cpRandom').checked,
sourceCharacterId: sources[0] || '',
sourceCharacterIds: sources,
ai: cfg.ai || {},
aiProfiles: cpAIProfiles
};
$('cpStatus').textContent = '保存中…';
try {
const r = await post('/api/companion', payload);
if (!r.ok) { $('cpStatus').textContent = r.error || '保存失败'; return; }
cpState = r.data || cpState;
$('cpStatus').textContent = '支援兵配置已保存,下一次进入副本生效。';
} catch (e) { $('cpStatus').textContent = '保存失败:' + e.message; }
});
// ---------------- 服务端工具 ----------------
async function loadServerTool() {
try {
$('stBody').innerHTML = '<div class="empty-box">正在读取服务端配置…</div>';
const [f, a, s, g, b] = await Promise.all([api('/api/servertool/features'), api('/api/servertool/activity'), api('/api/status'), api('/api/servertool/gameplay'), api('/api/servertool/batch-pickup')]);
stFeatures = f.data || {}; stActivity = a.data || {}; stGameplay = g.data || {};
stBatchPickup = !!(b.data || {}).enabled;
window.__stStatusInfo = s || {};
$('stSource').textContent = f.source === 'native' ? 'Native API' : '文件配置';
renderServerTool();
} catch (e) {
$('stBody').innerHTML = '<div class="empty-box">读取失败:' + (e && e.message ? e.message : e) + '</div>';
$('stStatus').textContent = '读取失败';
}
}
document.querySelectorAll('.st-tab').forEach(b => b.addEventListener('click', () => {
stTab = b.dataset.st;
document.querySelectorAll('.st-tab').forEach(x => x.classList.toggle('active', x === b));
renderServerTool();
}));
const LABEL = {
raidReviveSeconds:'副本复活秒数', allDeadReturnSeconds:'全员死亡返回秒数', allowFreeRevive:'允许免费复活',
hellOrbChance:'深渊宝珠概率', hellDropChanceDenominator:'掉落分母', equipmentRollMultiplier:'装备倍率',
epicPieceRollMultiplier:'史诗碎片倍率', lukeEnergyRecoveryPercent:'卢克能量恢复%', fiendWarSquadCount:'超时空队伍数',
roomFatigueCost:'每房间疲劳', dailyFatigueResetHour:'每日疲劳重置点', adventureGroupBonusPercent:'冒险团加成%',
rentalMaxReinforce:'租赁最大强化', rentalMaxForge:'租赁最大锻造', soloTeleportCooldownSeconds:'单人传送冷却秒',
soloTeleportCooldownIndices:'单人传送索引', auctionStandardListingLimit:'普通拍卖上架数',
auctionPremiumListingLimit:'高级拍卖上架数', auctionStandardDurationHours:'普通拍卖时长',
auctionPremiumDurationHours:'高级拍卖时长', auctionStandardFeePercent:'普通手续费%', auctionPremiumFeePercent:'高级手续费%',
antonBlackFogRecoverySeconds:'安徒恩黑雾恢复秒', antonBlackFogActiveSeconds:'安徒恩黑雾持续秒',
antonQuakeRecoverySeconds:'安徒恩震颤恢复秒', antonQuakeActiveSeconds:'安徒恩震颤持续秒',
antonNavalOpenDelaySeconds:'安徒恩舰炮延迟秒', antonNavalRecoverySeconds:'安徒恩舰炮恢复秒',
antonNavalActiveSeconds:'安徒恩舰炮持续秒', lukeEnergyRecovery0Seconds:'卢克1阶段恢复秒',
lukeEnergyRecovery1Seconds:'卢克2阶段恢复秒', lukeEnergyRecovery2Seconds:'卢克3阶段恢复秒',
lukePhaseTimeoutPercent:'卢克阶段时限%', fiendWarAreaLimitPercent:'超时空区域限制%',
fiendWarDieImprisonPercent:'超时空死亡禁锢%', antonMemberMax:'安徒恩人数上限',
antonStartMinimumMembers:'安徒恩最少开局人数', antonSingleStartMinimumMembers:'安徒恩单人开局人数',
antonStartDelaySeconds:'安徒恩开始延迟', antonPhaseBreakSeconds:'安徒恩阶段间隔',
antonEntryCostItem:'安徒恩入场物品', antonEntryCostCount:'安徒恩入场数量',
antonWeeklyEnterCountA:'安徒恩A每周次数', antonWeeklyEnterCountB:'安徒恩B每周次数',
antonPhaseTimeoutSeconds:'安徒恩阶段时限', lukeDailyAccountEnterLimit:'卢克账号每日次数',
lukeSanctumTimerCount:'卢克圣所计时', lukeEntryCostItem:'卢克入场物品', lukeEntryCostCount:'卢克入场数量',
fiendWarDailyEnterLimit:'超时空每日次数', fiendWarMemberMax:'超时空人数上限',
fiendWarStartMinimumMembers:'超时空最少开局人数', fiendWarDisconnectWaitSeconds:'超时空掉线等待',
fiendWarEntryCostItem:'超时空入场物品', fiendWarEntryCostCount:'超时空入场数量', fiendWarRetry:'超时空重试',
reviveByDungeon:'按副本复活配置'
};
const ST_DESC = {
soloTeleportCooldownSeconds:'单人传送冷却时间,单位秒;服务端读取该值限制重复传送。',
soloTeleportCooldownIndices:'允许触发单人传送的技能索引,多个用逗号分隔。',
auctionStandardListingLimit:'普通拍卖行同时可上架数量。',
auctionStandardDurationHours:'普通拍卖行上架时长,单位小时。',
auctionPremiumListingLimit:'高级拍卖行同时可上架数量。',
auctionPremiumDurationHours:'高级拍卖行上架时长,单位小时。',
auctionStandardFeePercent:'普通拍卖成交手续费百分比。',
auctionPremiumFeePercent:'高级拍卖成交手续费百分比。',
raidReviveSeconds:'团本死亡后等待复活的秒数,有效范围 1-60;不是无限复活选项。',
allDeadReturnSeconds:'队伍全员死亡后返回城镇的等待秒数。',
hellOrbChance:'深渊宝珠掉落判定值。',
hellDropChanceDenominator:'深渊基础掉率分母,数值越大掉率越低。',
equipmentRollMultiplier:'史诗装备掉落 roll 次数倍率。',
epicPieceRollMultiplier:'史诗碎片掉落 roll 次数倍率。',
allowFreeRevive:'允许不使用复活币直接复活。',
roomFatigueCost:'进入每个房间消耗的疲劳值。',
dailyFatigueResetHour:'每日疲劳重置小时,0-23。',
adventureGroupBonusPercent:'冒险团经验加成百分比。',
rentalMaxReinforce:'租赁装备允许的最大强化等级。',
rentalMaxForge:'租赁装备允许的最大锻造等级。',
antonBlackFogRecoverySeconds:'安徒恩黑雾之源恢复等待秒数。',
antonBlackFogActiveSeconds:'安徒恩黑雾之源持续时间秒数。',
antonQuakeRecoverySeconds:'安徒恩震颤之地恢复等待秒数。',
antonQuakeActiveSeconds:'安徒恩震颤之地持续时间秒数。',
antonNavalOpenDelaySeconds:'安徒恩舰炮防御战开启延迟秒数。',
antonNavalRecoverySeconds:'安徒恩舰炮防御战恢复等待秒数。',
antonNavalActiveSeconds:'安徒恩舰炮防御战持续时间秒数。',
lukeEnergyRecovery0Seconds:'卢克能量控制一阶段恢复秒数。',
lukeEnergyRecovery1Seconds:'卢克能量控制二阶段恢复秒数。',
lukeEnergyRecovery2Seconds:'卢克能量控制三阶段恢复秒数。',
lukePhaseTimeoutPercent:'卢克各阶段时限百分比。',
lukeEnergyRecoveryPercent:'卢克能量恢复速度百分比。',
fiendWarSquadCount:'超时空之战小队编制数量。',
fiendWarAreaLimitPercent:'超时空区域进入限制百分比。',
fiendWarDieImprisonPercent:'超时空死亡禁锢触发百分比。'
};
const ST_FIELDS = {
global: [
'soloTeleportCooldownSeconds','soloTeleportCooldownIndices',
'auctionStandardListingLimit','auctionStandardDurationHours',
'auctionPremiumListingLimit','auctionPremiumDurationHours',
'auctionStandardFeePercent','auctionPremiumFeePercent'
],
gameplay: [
'allDeadReturnSeconds','hellOrbChance',
'hellDropChanceDenominator','equipmentRollMultiplier',
'epicPieceRollMultiplier','allowFreeRevive'
],
growth: [
'roomFatigueCost','dailyFatigueResetHour','adventureGroupBonusPercent',
'rentalMaxReinforce','rentalMaxForge'
],
raid: {
anton: [
'antonBlackFogRecoverySeconds','antonBlackFogActiveSeconds',
'antonQuakeRecoverySeconds','antonQuakeActiveSeconds',
'antonNavalOpenDelaySeconds','antonNavalRecoverySeconds','antonNavalActiveSeconds'
],
luke: [
'lukeEnergyRecovery0Seconds','lukeEnergyRecovery1Seconds',
'lukeEnergyRecovery2Seconds','lukePhaseTimeoutPercent',
'lukeEnergyRecoveryPercent'
],
fiend: [
'fiendWarSquadCount','fiendWarAreaLimitPercent','fiendWarDieImprisonPercent'
]
}
};
function allAssignedKeys() {
return new Set([...ST_FIELDS.global, ...ST_FIELDS.gameplay, ...ST_FIELDS.growth,
...ST_FIELDS.raid.anton, ...ST_FIELDS.raid.luke, ...ST_FIELDS.raid.fiend]);
}
function renderServerTool() {
const body = $('stBody');
if (stTab === 'activity') { renderActivity(body); return; }
if (stTab === 'launch') {
body.innerHTML = `<div class="st-section"><h3>启动控制</h3><p class="st-note">配置文件:server-features.json / activity.json。服务端运行状态请以开始页为准。</p><div class="lo-toolbar"><button class="shell-btn primary" id="stStart">开始游戏</button><button class="shell-btn" id="stStop">关闭服务端</button></div><div class="st-note" id="stLaunchInfo"></div></div>`;
$('stStart').onclick = () => doAction('start');
$('stStop').onclick = () => doAction('stop');
api('/api/status').then(s => {
$('stLaunchInfo').textContent = `服务端:${s.server_running ? '运行中' : '未运行'} · 目录:${s.server_root || '未绑定'}`;
}).catch(() => {});
return;
}
let sub = '';
let keys;
if (stTab === 'features') {
keys = Object.keys(stFeatures).filter(k => !allAssignedKeys().has(k));
} else if (stTab === 'raid') {
const names = {anton:'安徒恩机制', luke:'卢克', fiend:'超时空'};
$('stSubTabs').innerHTML = Object.keys(names).map(k =>
`<button class="st-subtab ${stRaidTab===k?'active':''}" data-raid="${k}">${names[k]}</button>`).join('');
$('stSubTabs').querySelectorAll('.st-subtab').forEach(b => b.onclick = () => {
stRaidTab = b.dataset.raid; renderServerTool();
});
keys = ST_FIELDS.raid[stRaidTab] || [];
} else {
$('stSubTabs').innerHTML = '';
keys = ST_FIELDS[stTab] || [];
}
let html = `<div class="st-section"><h3>${tabTitle(stTab)}</h3>`;
if (stTab === 'features') {
const info = window.__stStatusInfo || {};
const rawNames = ['instance.json','dnfbridge.toml','logic.toml','plan.json'];
$('stSubTabs').innerHTML = rawNames.map(n =>
`<button class="st-subtab ${stFeatureTab===n?'active':''}" data-raw="${n}">${n}</button>`).join('');
$('stSubTabs').querySelectorAll('.st-subtab').forEach(b => b.onclick = () => {
stFeatureTab = b.dataset.raw; renderServerTool();
});
html += `<div class="st-form">
<label>服务端目录<input value="${(info.server_root || '').replace(/"/g,'&quot;')}" readonly></label>
<label>Admin 端口<input value="18111" readonly></label>
<label>数据库<input value="${(info.db || '').replace(/"/g,'&quot;')}" readonly></label>
<label>客户端目录<input value="${(info.client_dir || '').replace(/"/g,'&quot;')}" readonly></label>
</div>`;
html += `<div class="st-note">当前配置文件:${stFeatureTab}</div>`;
html += `<textarea id="stRaw" class="st-raw" spellcheck="false">${stRawText.replace(/</g,'&lt;')}</textarea>`;
html += `<div class="st-note">服务端参数页保留实例与配置文件;下方列出未归入其他页的字段。</div>`;
}
html += `<div class="st-form">${groupFields(stFeatures, stTab === 'gameplay' ? keys.filter(k => k !== 'allowFreeRevive') : keys)}</div>`;
if (stTab === 'gameplay') {
html += `<div class="st-form">
<label>深渊史诗掉率(%)<small class="st-hint">100 = 原版,250 = 2.5 倍</small><input data-game-key="hellEpicDropRatePercent" type="number" value="${stGameplay.hell_epic_drop_rate_percent ?? 0}"></label>
<label>史诗药剂测试倍率(%)<small class="st-hint">0 = 关闭测试倍率</small><input data-game-key="epicPotionMultiplierPercent" type="number" value="${stGameplay.epic_drop_rate_potion_test_multiplier_percent ?? 0}"></label>
</div>`;
html += `<div class="st-check-row">
<label class="st-check"><input type="checkbox" data-st-key="allowFreeRevive" ${stFeatures.allowFreeRevive === true ? 'checked' : ''}>允许无复活币免费复活</label>
<label class="st-check"><input type="checkbox" data-batch-pickup ${stBatchPickup ? 'checked' : ''}>服务端允许一键拾取</label>
</div>`;
}
html += `</div>`;
html += `<details class="st-json"><summary>高级:完整 JSON 配置</summary><textarea id="stJson" spellcheck="false"></textarea><div class="lo-toolbar"><button class="shell-btn" id="stJsonApply">应用 JSON</button><span class="muted">可直接编辑全部服务端字段</span></div></details>`;
body.innerHTML = html;
if (stTab === 'features') loadRawConfig(stFeatureTab);
const jsonBox = $('stJson');
if (jsonBox) jsonBox.value = JSON.stringify(stFeatures, null, 2);
$('stJsonApply').onclick = () => {
try {
const obj = JSON.parse($('stJson').value);
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('需要 JSON 对象');
stFeatures = obj; renderServerTool();
$('stStatus').textContent = 'JSON 已载入,点击“保存当前页”写入';
} catch (e) { $('stStatus').textContent = 'JSON 错误:' + e.message; }
};
bindFeatureInputs(body);
}
async function loadRawConfig(name) {
try {
const r = await api('/api/servertool/raw?name=' + encodeURIComponent(name));
stRawText = r.text || '';
const box = $('stRaw');
if (box) box.value = stRawText;
} catch (e) { /* ignore */ }
}
function bindFeatureInputs(body) {
body.querySelectorAll('input[data-st-key]').forEach(inp => {
inp.addEventListener('change', () => {
const key = inp.dataset.stKey;
let v = inp.value;
if (inp.type === 'checkbox') v = inp.checked;
else if (inp.dataset.type === 'number') { const n = Number(v); v = Number.isFinite(n) ? n : 0; }
stFeatures[key] = v;
});
});
body.querySelectorAll('input[data-game-key]').forEach(inp => {
inp.addEventListener('change', () => {
stGameplay[inp.dataset.gameKey] = Number(inp.value) || 0;
});
});
body.querySelectorAll('input[data-batch-pickup]').forEach(inp => {
inp.addEventListener('change', () => { stBatchPickup = inp.checked; });
});
}
function groupFields(obj, keys) {
return keys.map(k => {
const val = obj[k];
const label = LABEL[k] || k;
if (typeof val === 'boolean' || /Retry|Revive|Pickup/.test(k)) {
return `<label class="st-check"><input type="checkbox" data-st-key="${k}" ${val===true?'checked':''}>${label}</label>`;
}
const numeric = (typeof val === 'number') || /Seconds|Percent|Count|Hour|Limit|Cost|Max|Min|Delay|Item/.test(k);
const type = numeric ? 'number' : 'text';
const shown = val === undefined || val === null ? '' : String(val);
const hint = ST_DESC[k] ? `<small class="st-hint">${ST_DESC[k]}</small>` : '';
return `<label>${label}${hint}<input data-st-key="${k}" data-type="${type}" type="${type}" value="${shown.replace(/"/g,'&quot;')}"></label>`;
}).join('');
}
function tabTitle(tab) {
return ({features:'服务端参数',global:'全局游戏参数',gameplay:'游戏参数',growth:'疲劳/成长',raid:'团本配置'})[tab] || tab;
}
function tabKeys(tab) {
const all = Object.keys(stFeatures);
const match = {
features: ['raidReviveSeconds','allDeadReturnSeconds','allowFreeRevive','hellOrbChance','hellDropChanceDenominator','equipmentRollMultiplier','epicPieceRollMultiplier','lukeEnergyRecoveryPercent','fiendWarSquadCount'],
global: [],
gameplay: ['hellOrbChance','hellDropChanceDenominator','equipmentRollMultiplier','epicPieceRollMultiplier','roomFatigueCost','rentalMaxReinforce','rentalMaxForge'],
growth: ['roomFatigueCost','dailyFatigueResetHour','adventureGroupBonusPercent','rentalMaxReinforce','rentalMaxForge'],
raid: ['antonBlackFogRecoverySeconds','antonBlackFogActiveSeconds','antonQuakeRecoverySeconds','antonQuakeActiveSeconds','antonNavalOpenDelaySeconds','antonNavalRecoverySeconds','antonNavalActiveSeconds','lukeEnergyRecovery0Seconds','lukeEnergyRecovery1Seconds','lukeEnergyRecovery2Seconds','lukePhaseTimeoutPercent','fiendWarAreaLimitPercent','fiendWarDieImprisonPercent','antonMemberMax','antonStartMinimumMembers','lukeDailyAccountEnterLimit','fiendWarDailyEnterLimit'],
};
if (tab === 'global') return all.filter(k => /soloTeleport|auction|allDead|revive/i.test(k));
return (match[tab] || []).filter(k => all.includes(k));
}
function renderActivity(body) {
const keys = Object.keys(stActivity);
body.innerHTML = `<div class="st-section"><h3>在线活动开关</h3><div class="st-form">${keys.map(k => {
const v = !!stActivity[k]; const label = ({daily:'每日签到',weekly:'每周签到',monthly:'每月签到',marble:'签到弹珠',returnee:'回归活动',returneeGift:'回归赠礼',lucky:'幸运活动',levelAiming:'升级预约',laborDay:'劳动节',legacyLevelup:'旧版升级',pcRoom:'网吧时间点',spendTime:'在线时长'})[k] || k;
return `<label class="st-check"><input type="checkbox" data-act-key="${k}" ${v?'checked':''}>${label}</label>`;
}).join('')}</div></div>`;
body.querySelectorAll('input[data-act-key]').forEach(inp => {
inp.addEventListener('change', () => { stActivity[inp.dataset.actKey] = inp.checked; });
});
}
$('stReload').addEventListener('click', loadServerTool);
$('stSave').addEventListener('click', async () => {
try {
let r;
if (stTab === 'activity') {
r = await post('/api/servertool/activity', stActivity);
} else if (stTab === 'features' && $('stRaw')) {
r = await post('/api/servertool/raw', {name: stFeatureTab, text: $('stRaw').value});
} else if (stTab === 'gameplay') {
r = await post('/api/servertool/features', stFeatures);
const g = await post('/api/servertool/gameplay', stGameplay);
const bp = await post('/api/servertool/batch-pickup', {enabled: stBatchPickup});
if (!g.ok) r = g;
if (!bp.ok) r = bp;
} else {
r = await post('/api/servertool/features', stFeatures);
}
$('stStatus').textContent = r.ok ? ('已保存' + (r.warning ? '(' + r.warning + ')' : '')) : (r.error || '保存失败');
} catch (e) { $('stStatus').textContent = '保存失败:' + e.message; }
});
async function loadSettings() {
try {
const s = await api('/api/status');
$('setServerRoot').textContent = s.server_root || '未绑定';
$('setControl').textContent = s.control_exists ? '已找到 DNF90Control.exe' : '缺失';
$('setDb').textContent = s.db || '未找到';
$('setClient').textContent = s.client_dir || '未找到';
$('setRun').textContent = s.server_running ? '运行中' : '未运行';
$('setNative').textContent = s.native_available ? '可用' : '不可用';
} catch (e) { /* ignore */ }
}
$('setRefresh').addEventListener('click', loadSettings);
// 顶栏拖动窗口(JS move 兜底,兼容 pywebview EdgeChromium)
// 同时支持第一行标题栏(.shell-topnav)与第二行面包屑栏(.shell-topbar)
['shell-topnav', 'shell-topbar'].forEach((sel) => {
const topbar = document.querySelector('.' + sel);
if (!topbar) return;
let dragState = null;
const onMove = (e) => {
if (!dragState) return;
const w = win();
if (w && w.move) w.move(dragState.x + e.screenX - dragState.sx, dragState.y + e.screenY - dragState.sy);
};
const onUp = () => {
dragState = null;
document.removeEventListener('mousemove', onMove, true);
document.removeEventListener('mouseup', onUp, true);
};
topbar.addEventListener('mousedown', (e) => {
if (e.target.closest('button,select,input,a')) return;
const w = win();
if (!w || !w.getPosition) return;
e.preventDefault();
const sx = e.screenX, sy = e.screenY;
w.getPosition().then((pos) => {
dragState = {x: Number(pos[0]) || 0, y: Number(pos[1]) || 0, sx, sy};
document.addEventListener('mousemove', onMove, true);
document.addEventListener('mouseup', onUp, true);
}).catch(() => {});
});
});
document.querySelectorAll('.win-resize').forEach(el => {
el.addEventListener('mousedown', (e) => {
e.preventDefault();
const edge = el.dataset.edge;
const w = win();
if (!w) return;
// 优先系统边缘缩放;右侧/底部再用 JS 尺寸兜底
if (['left','top','topleft','topright','bottomleft'].includes(edge) && w.start_resize) w.start_resize(edge);
if (['right','bottom','bottomright'].includes(edge) && w.resize_window) {
const sx = e.screenX, sy = e.screenY;
const sw = window.innerWidth, sh = window.innerHeight;
let rz = {edge, sx, sy, sw, sh};
const move = (ev) => {
let nw = sw, nh = sh;
if (edge.includes('right') || edge === 'right') nw = sw + ev.screenX - sx;
if (edge.includes('bottom') || edge === 'bottom') nh = sh + ev.screenY - sy;
w.resize_window(nw, nh);
};
const up = () => {
rz = null;
document.removeEventListener('mousemove', move, true);
document.removeEventListener('mouseup', up, true);
};
document.addEventListener('mousemove', move, true);
document.addEventListener('mouseup', up, true);
}
});
});
// 兼容旧 GM 页面里的返回
window.shellGo = shellGo;
loadStart();
setInterval(loadStart, 4000);
})();