# 支援兵页面模板(Companion Page Template) > 从 DNF90 启动器 v2.1.0 中完整导出的「支援兵」页面模板。 > 导出日期:2026-11-04。 > 用途:日后如需恢复「支援兵」功能,按本文档把 HTML / JS / CSS / 后端接口 四个部分分别粘贴回对应文件即可。 --- ## 一、HTML(index.html 中的 section) 插入位置:`
` 之前(或任意 shell-page 之间)。 ```html

支援兵

按账号配置支援兵;配置只影响下一次进入副本。

选择支援兵角色

选择出战角色后加载支援兵列表

AI 策略

高级调参:距离、追击、视野、重试和治疗阈值
AI 策略按角色保存;保存后下一次进入副本生效。
支援兵按账号隔离,只能选择同一账号下的其他角色。
``` --- ## 二、侧边栏导航入口(index.html 的 nav 内) ```html ``` --- ## 三、JavaScript(shell.js 中的支援兵模块) 插入位置:`// ---------------- 一键换装 ----------------` 区块之后、`// ---------------- 服务端工具 ----------------` 区块之前。 ```javascript // ---------------- 支援兵 ---------------- 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 => ``).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 => ``).join(''); if (cpCharacters.length) await loadCompanionState(); else { $('cpGrid').innerHTML = '
当前账号没有角色
'; } } 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 = '
当前账号没有其他角色可作为支援兵
'; renderCpAI(); return; } grid.innerHTML = list.map(c => { const id = String(c.character_id), selected = cpSelected.has(id); return ``; }).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 ``; }).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; } }); ``` 同时,需要在 `shellGo` 函数中加回页面切换调用: ```javascript if (page === 'companion') loadCompanion(); ``` 以及在 `pages` 数组加回 `'companion'`,在 `crumb` 加回 `companion:'工具 / 支援兵'`。 --- ## 四、后端接口(server.py) ### 4.1 GET 路由注册(do_GET 内) ```python elif path == "/api/companion": self._api_companion(qs) elif path == "/api/basic-attack": self._json({"ok": True, "enabled": not os.path.exists( os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled"))}) ``` ### 4.2 POST 路由注册(do_POST 内) ```python elif path == "/api/companion": self._api_save_companion(data) elif path == "/api/basic-attack": enabled = bool(data.get("enabled", True)) flag = os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled") try: if enabled: if os.path.exists(flag): os.remove(flag) else: os.makedirs(CLIENT_DIR, exist_ok=True) with open(flag, "w", encoding="utf-8") as f: f.write("disabled\n") self._json({"ok": True, "enabled": enabled}) except Exception as e: self._json({"ok": False, "error": str(e)}, 500) ``` ### 4.3 处理函数 ```python # ==================== 支援兵 ==================== def _api_companion(self, qs): if NATIVE is None or not server_running(): self._json({"ok": False, "error": "服务端未运行,支援兵配置不可用"}, 400) return character = (qs.get("character") or qs.get("id") or [""])[0].strip() if not character: self._json({"ok": False, "error": "缺少角色 ID"}, 400) return try: self._json({"ok": True, "data": NATIVE.companion_state(character)}) except Exception as e: self._json({"ok": False, "error": str(e)}, 502) def _api_save_companion(self, data): if NATIVE is None or not server_running(): self._json({"ok": False, "error": "服务端未运行,无法保存支援兵配置"}, 400) return try: self._json({"ok": True, "data": NATIVE.companion_save(data)}) except Exception as e: self._json({"ok": False, "error": str(e)}, 502) ``` --- ## 五、CSS 样式(shell.css 中的 cp- 相关) 支援兵页面复用了 `.st-form`、`.st-check`、`.st-json`、`.st-note` 等「服务端工具」样式(这些在服务端工具删除后需要一并保留或从模板恢复),以及以下独立的 cp- 样式(位于 shell.css 明暗主题区域): ```css .cp-toolbar{display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-bottom:16px} .cp-toolbar label{display:flex;flex-direction:column;gap:5px;font-size:13px;color:#9a9eb5} .cp-toolbar select{min-width:220px} .cp-flags{display:flex;flex-wrap:wrap;gap:28px;border:1px solid rgba(255,255,255,.1);border-radius:10px} .cp-title{font-size:15px;font-weight:400;color:#c9beff;margin:0 0 10px} .cp-grid{display:grid;grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px} .cp-card{border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:12px;background:rgba(20,20,36,.55);cursor:pointer} .cp-card.selected{border-color:rgba(124,92,255,.7);background:rgba(124,92,255,.16)} .cp-card b{font-size:14px;display:block;color:#eef0fb} .cp-card small{font-size:12px;color:#9a9eb5} .cp-card input{margin-right:7px} .cp-ai{margin-top:20px;border:1px solid rgba(255,255,255,.1);border-radius:10px;background:rgba(20,20,36,.45);padding:14px} .cp-ai-head{display:flex;align-items:center;gap:14px;margin-bottom:12px} .cp-ai-head h3{margin:0;font-size:15px;font-weight:400;color:#c9beff} .cp-ai-head select{width:240px} .cp-ai .st-form{margin-bottom:10px} ``` > 注意:`cp-flags` 样式在原文件里是分成两行(续行),恢复时需合并为完整声明。 --- ## 六、依赖说明 支援兵页面依赖以下「服务端工具」的通用样式(这些如果被一并删除,恢复支援兵时需一并恢复): - `.st-form` / `.st-form label` / `.st-form input` / `.st-form select` - `.st-check` / `.st-check input` - `.st-json` / `.st-json summary` / `.st-json textarea` - `.st-note` 依赖的后端接口: - `/api/accounts`(账号列表) - `/api/characters?account=`(角色列表) - `/api/companion`(支援兵状态读取/保存) - `/api/basic-attack`(普攻开关) --- ## 七、恢复步骤速查 1. `index.html`:粘贴 section + 侧边栏按钮 2. `shell.js`:粘贴 JS 模块,并在 `pages`/`crumb`/`shellGo` 三处加回 `companion` 3. `server.py`:粘贴路由注册 + 两个处理函数 4. `shell.css`:粘贴 cp- 样式(若 st- 样式已删,一并恢复 st- 通用样式) 5. 重新打包 --- (完)