DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

+981
View File
@@ -0,0 +1,981 @@
/* 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 === '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(); });
// GM 全屏模式下的窗口按钮(最小化 / 关闭)
const gmWinMin = $('gmWinMin');
const gmWinClose = $('gmWinClose');
if (gmWinMin) gmWinMin.addEventListener('click', () => { const w = win(); if (w && w.minimize) w.minimize(); });
if (gmWinClose) gmWinClose.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 ? '已就绪' : '不完整';
$('statVersion').textContent = 'v2026.11.03';
$('statRemote').textContent = '当前版本';
$('statClient').textContent = s.client_dir_exists ? (running ? '运行中' : '已就绪') : '缺失';
$('shellClock').textContent = '检查于 ' + new Date().toLocaleTimeString('zh-CN', {hour12:false});
$('statResource').textContent = s.admin ? '管理员' : '普通权限';
loadAccountLabel();
} 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 */ }
}
$('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);
})();