启动器:移除服务端工具/支援兵/插件页面,支援兵模板独立保存

- 删除服务端工具、支援兵、插件三个页面(侧边栏入口 + HTML section)
- server.py: 删除 servertool(9个)、companion/basic-attack 相关接口及常量
- native.py: 删除无人引用的 get_server_features/save_server_features/
  companion_state/companion_save(保留 GM 工具依赖的 gameplay 方法)
- shell.js: 删除 loadServerTool/loadCompanion 全部逻辑及状态变量
- shell.css: 清理 st-/cp- 相关样式
- templates/支援兵页面模板.md: 支援兵页面完整导出为独立模板
- 侧边栏「更新日志」更名为「游戏更新」
This commit is contained in:
hanfuit committed 2026-11-04 19:32:01 +08:00
1 parent 56236496a0
commit 977a4a1196
6 files changed
+410 -869

No files matched your search

+2 -81
View File
@@ -28,12 +28,9 @@
<button class="shell-nav-item active" data-shell-page="start"><span class="nav-ico">▶</span>开始</button>
<button class="shell-nav-item" data-shell-page="gm"><span class="nav-ico">⚙</span>GM工具</button>
<button class="shell-nav-item" data-shell-page="loadout"><span class="nav-ico">🔄</span>一键换装</button>
<button class="shell-nav-item" data-shell-page="servertool"><span class="nav-ico">🧰</span>服务端工具</button>
<button class="shell-nav-item" data-shell-page="companion"><span class="nav-ico">👥</span>支援兵</button>
<button class="shell-nav-item" data-shell-page="plugins"><span class="nav-ico">🧩</span>插件</button>
<div class="shell-nav-gap"></div>
<button class="shell-nav-item" data-shell-page="settings"><span class="nav-ico">⚙</span>设置</button>
<button class="shell-nav-item" data-shell-page="changelog"><span class="nav-ico">📜</span>更新日志</button>
<button class="shell-nav-item" data-shell-page="changelog"><span class="nav-ico">📜</span>游戏更新</button>
</nav>
<div class="shell-win-controls">
<button class="tb-btn" id="shellWinMin" title="最小化">—</button>
@@ -111,82 +108,6 @@
</div>
</section>
<section class="shell-page" id="page-servertool">
<div class="page-pad">
<div class="page-heading"><div><h2>服务端工具</h2><p class="muted">按原 ServerTool 页签组织,配置写入 server-features.json / activity.json。</p></div><span class="tag" id="stSource">文件配置</span></div>
<div class="st-tabs" id="stTabs">
<button class="st-tab active" data-st="global">全局游戏参数</button>
<button class="st-tab" data-st="activity">在线活动</button>
<button class="st-tab" data-st="gameplay">游戏参数</button>
<button class="st-tab" data-st="growth">疲劳/成长</button>
<button class="st-tab" data-st="raid">团本配置</button>
<button class="st-tab" data-st="launch">启动控制</button>
</div>
<div class="st-subtabs" id="stSubTabs"></div>
<div class="st-toolbar"><button class="shell-btn primary" id="stSave">保存当前页</button><button class="shell-btn" id="stReload">重新读取</button><span id="stStatus" class="muted"></span></div>
<div class="st-body" id="stBody"></div>
</div>
</section>
<section class="shell-page" id="page-companion">
<div class="page-pad">
<div class="page-heading"><div><h2>支援兵</h2><p class="muted">按账号配置支援兵;配置只影响下一次进入副本。</p></div><button class="shell-btn primary" id="cpSave">保存配置</button></div>
<div class="cp-toolbar">
<label>账号<select id="cpAccount" class="shell-input"></select></label>
<label>出战角色<select id="cpOwner" class="shell-input"></select></label>
<button class="shell-btn" id="cpReload">刷新</button>
<span id="cpStatus" class="muted"></span>
</div>
<div class="cp-flags">
<label class="st-check"><input type="checkbox" id="cpEnabled">启用支援兵</label>
<label class="st-check"><input type="checkbox" id="cpBalance">读取佩戴装备</label>
<label class="st-check"><input type="checkbox" id="cpProgression">启用成长推进</label>
<label class="st-check"><input type="checkbox" id="cpRandom">随机武器装备</label>
<label class="st-check"><input type="checkbox" id="cpBasicAttack">允许支援兵普攻</label>
</div>
<h3 class="cp-title">选择支援兵角色</h3>
<div class="cp-grid" id="cpGrid"><div class="empty-box">选择出战角色后加载支援兵列表</div></div>
<div class="cp-ai">
<div class="cp-ai-head"><h3>AI 策略</h3><select id="cpAISource" class="shell-input"></select></div>
<div class="st-form">
<label>行为模式<select id="cpAIBehavior"><option value="assist">辅助</option><option value="aggressive">好战</option></select></label>
<label>BUFF 优先级<select id="cpAIBuff"><option value="leader_first">优先队长</option><option value="assist_only">仅辅助</option><option value="off">关闭</option></select></label>
<label>职业连招<select id="cpAIComboPreset"><option value="auto">按职业自动</option><option value="sword_guard">鬼剑士:控制连招</option><option value="fighter_chain">格斗家:近身连招</option><option value="gunner_burst">神枪手:远程爆发</option><option value="mage_control">魔法师:范围控制</option><option value="priest_support">圣职者:辅助连招</option></select></label>
<label>范围技能最少目标<input id="cpAIAoe" type="number" min="2" max="6" step="1"></label>
<label class="st-check"><input type="checkbox" id="cpAIComboEnabled">启用连招</label>
<label class="st-check"><input type="checkbox" id="cpAIProtect">保护队长</label>
</div>
<details class="st-json">
<summary>高级调参:距离、追击、视野、重试和治疗阈值</summary>
<div class="st-form" id="cpAITuning">
<label>思考间隔(ms)<input data-cp-tuning="think_interval_ms" type="number" min="50" max="2000"></label>
<label>辅助攻击间隔(ms)<input data-cp-tuning="assist_attack_interval_ms" type="number" min="200" max="10000"></label>
<label>辅助横向范围<input data-cp-tuning="assist_leash_x" type="number" min="30" max="500"></label>
<label>辅助纵向范围<input data-cp-tuning="assist_leash_y" type="number" min="20" max="300"></label>
<label>追击范围<input data-cp-tuning="chase_range" type="number" min="20" max="1000"></label>
<label>视野范围<input data-cp-tuning="vision_range" type="number" min="100" max="20000"></label>
<label>目标黏性(ms)<input data-cp-tuning="target_stickiness_ms" type="number" min="0" max="10000"></label>
<label>队长治疗阈值(%)<input data-cp-tuning="heal_leader_percent" type="number" min="0" max="100"></label>
<label>自身治疗阈值(%)<input data-cp-tuning="heal_self_percent" type="number" min="0" max="100"></label>
<label>重试间隔(ms)<input data-cp-tuning="retry_ms" type="number" min="50" max="5000"></label>
<label>辅助重试(ms)<input data-cp-tuning="support_retry_ms" type="number" min="100" max="10000"></label>
<label>忙碌移动(ms)<input data-cp-tuning="busy_move_ms" type="number" min="100" max="10000"></label>
<label>卡住判定(ms)<input data-cp-tuning="stuck_ms" type="number" min="100" max="5000"></label>
<label>绕行等待(ms)<input data-cp-tuning="detour_ms" type="number" min="100" max="5000"></label>
<label>放弃目标(ms)<input data-cp-tuning="give_up_ms" type="number" min="500" max="20000"></label>
<label>跟随距离<input data-cp-tuning="follow_distance" type="number" min="20" max="300"></label>
</div>
</details>
<div class="st-note">AI 策略按角色保存;保存后下一次进入副本生效。</div>
</div>
<div class="st-note" id="cpHint">支援兵按账号隔离,只能选择同一账号下的其他角色。</div>
</div>
</section>
<section class="shell-page" id="page-plugins">
<div class="page-pad"><h2>插件</h2><div class="empty-box">插件目录:启动器扩展位,后续接入 LoadoutTool / ServerTool 之外的模块。</div></div>
</section>
<section class="shell-page" id="page-settings">
<div class="page-pad">
<div class="page-heading"><div><h2>设置</h2><p class="muted">当前启动器实际绑定的服务端、数据库和游戏端。</p></div><button class="shell-btn" id="setRefresh">刷新</button></div>
@@ -202,7 +123,7 @@
</section>
<section class="shell-page" id="page-changelog">
<div class="page-pad"><h2>更新日志</h2><p class="muted">从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。</p><div id="changelogBody"></div></div>
<div class="page-pad"><h2>游戏更新</h2><p class="muted">从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。</p><div id="changelogBody"></div></div>
</section>
<section class="shell-page" id="page-gm">
-13
View File
@@ -622,16 +622,3 @@ class NativeClient(object):
def loadout_apply(self, character_id, slots):
return self._unwrap(self._post("/local/dnf-gm/loadout/apply", {
"character_id": str(character_id), "slots": slots}))
def get_server_features(self):
return self._unwrap(self._get("/dnf/gm/features"))
def save_server_features(self, features):
return self._unwrap(self._post("/dnf/gm/features", features))
def companion_state(self, character_id):
qs = urlencode({"characterId": str(character_id)})
return self._unwrap(self._get("/local/companion?" + qs))
def companion_save(self, payload):
return self._unwrap(self._post("/local/companion", payload))
+1 -203
View File
@@ -111,12 +111,8 @@ LOADOUT_EXE = os.path.join(LOADOUT_DIR, "换装工具.exe")
if not os.path.exists(LOADOUT_EXE):
LOADOUT_EXE = os.path.join(LOADOUT_DIR, "DNFLoadout.exe")
# 一键换装 / 服务端工具的持久化配置路径
# 一键换装的持久化配置路径
LOADOUT_CONFIG = os.path.join(LOADOUT_DIR, "config.json")
SERVER_CONFIG_DIR = os.path.join(SERVER_ROOT, "Server", "runtime", "config")
SERVER_FEATURES_PATH = os.path.join(SERVER_CONFIG_DIR, "server-features.json")
ACTIVITY_CONFIG_PATH = os.path.join(SERVER_CONFIG_DIR, "activity.json")
RAW_CONFIG_NAMES = ("instance.json", "dnfbridge.toml", "logic.toml", "plan.json")
# 赞助二维码图片路径(放启动器目录下 sponsor.png)
SPONSOR_IMG = os.path.join(BASE_DIR, "sponsor.png")
@@ -547,23 +543,6 @@ class Handler(BaseHTTPRequestHandler):
self._api_loadout_characters()
elif path == "/api/loadout/equipment":
self._api_loadout_equipment(qs)
elif path == "/api/servertool/features":
self._api_servertool_features()
elif path == "/api/servertool/activity":
self._api_servertool_activity()
elif path == "/api/servertool/info":
self._api_servertool_info()
elif path == "/api/servertool/raw":
self._api_servertool_raw(qs)
elif path == "/api/servertool/gameplay":
self._api_servertool_gameplay()
elif path == "/api/servertool/batch-pickup":
self._api_servertool_batch_pickup()
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"))})
elif path == "/api/logs":
self._api_logs()
elif path == "/api/gm/health":
@@ -629,32 +608,6 @@ class Handler(BaseHTTPRequestHandler):
self._api_save_loadout_config(data)
elif path == "/api/loadout/apply":
self._api_loadout_apply(data)
elif path == "/api/servertool/features":
self._api_save_servertool_features(data)
elif path == "/api/servertool/activity":
self._api_save_servertool_activity(data)
elif path == "/api/servertool/raw":
self._api_save_servertool_raw(data)
elif path == "/api/servertool/gameplay":
self._api_save_servertool_gameplay(data)
elif path == "/api/servertool/batch-pickup":
self._api_save_servertool_batch_pickup(data)
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)
elif path == "/api/accounts-config":
self._api_accounts_config_post(data)
elif path == "/api/source":
@@ -868,161 +821,6 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e:
self._json({"ok": False, "error": str(e)}, 502)
# ==================== 服务端工具 ====================
def _api_servertool_features(self):
if NATIVE is not None and server_running():
try:
self._json({"ok": True, "source": "native",
"data": NATIVE.get_server_features()})
return
except Exception:
pass
cfg = _read_json_file(SERVER_FEATURES_PATH, {})
self._json({"ok": True, "source": "file", "data": cfg})
def _api_save_servertool_features(self, data):
if NATIVE is not None and server_running():
try:
self._json({"ok": True, "source": "native",
"data": NATIVE.save_server_features(data)})
return
except Exception as e:
fallback_error = str(e)
else:
fallback_error = "Native API 不可用"
_write_json_file(SERVER_FEATURES_PATH, data)
self._json({"ok": True, "source": "file",
"warning": fallback_error, "data": data})
def _api_servertool_activity(self):
self._json({"ok": True,
"data": _read_json_file(ACTIVITY_CONFIG_PATH, {})})
def _api_save_servertool_activity(self, data):
_write_json_file(ACTIVITY_CONFIG_PATH, data)
self._json({"ok": True, "data": data})
def _api_servertool_info(self):
self._json({
"ok": True,
"features_path": SERVER_FEATURES_PATH,
"activity_path": ACTIVITY_CONFIG_PATH,
"config_dir": SERVER_CONFIG_DIR,
"raw_names": list(RAW_CONFIG_NAMES),
"server_running": server_running(),
})
def _api_servertool_raw(self, qs):
name = (qs.get("name") or [""])[0]
if name not in RAW_CONFIG_NAMES:
self._json({"error": "invalid config name"}, 400)
return
path = os.path.join(SERVER_CONFIG_DIR, name)
try:
text = ""
if os.path.exists(path):
with open(path, "r", encoding="utf-8-sig") as f:
text = f.read()
self._json({"ok": True, "name": name, "path": path, "text": text})
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_save_servertool_raw(self, data):
name = str(data.get("name", ""))
text = str(data.get("text", ""))
if name not in RAW_CONFIG_NAMES:
self._json({"error": "invalid config name"}, 400)
return
path = os.path.join(SERVER_CONFIG_DIR, name)
try:
os.makedirs(SERVER_CONFIG_DIR, exist_ok=True)
if os.path.exists(path):
shutil.copy2(path, path + ".bak_tool")
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
self._json({"ok": True, "name": name, "path": path})
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_servertool_gameplay(self):
if NATIVE is not None and server_running():
try:
self._json({"ok": True, "source": "native",
"data": NATIVE.get_gameplay_settings()})
return
except Exception:
pass
self._json({"ok": True, "source": "file", "data": {}})
def _api_save_servertool_gameplay(self, data):
if NATIVE is None or not server_running():
self._json({"error": "服务端未运行,无法写入玩法参数"}, 400)
return
try:
if data.get("hellEpicDropRatePercent") is not None:
NATIVE.set_hell_drop_rate(int(data["hellEpicDropRatePercent"]))
if data.get("epicPotionMultiplierPercent") is not None:
NATIVE.set_epic_potion_multiplier(int(data["epicPotionMultiplierPercent"]))
self._json({"ok": True, "data": data})
except Exception as e:
self._json({"error": str(e)}, 502)
def _batch_pickup_path(self):
return INSTANCE_PATH if INSTANCE_PATH else os.path.join(
SERVER_CONFIG_DIR, "instance.json")
def _api_servertool_batch_pickup(self):
path = self._batch_pickup_path()
cfg = _read_json_file(path, {})
value = True
try:
value = bool(cfg.get("game", {}).get("dungeonBatchPickupEnabled", True))
except Exception:
value = True
self._json({"ok": True, "data": {"enabled": value}, "path": path})
def _api_save_servertool_batch_pickup(self, data):
path = self._batch_pickup_path()
cfg = _read_json_file(path, None)
if not isinstance(cfg, dict):
self._json({"error": "instance.json 不存在或格式错误"}, 400)
return
game = cfg.get("game")
if not isinstance(game, dict):
game = {}
cfg["game"] = game
game["dungeonBatchPickupEnabled"] = bool(data.get("enabled", True))
try:
if os.path.exists(path):
shutil.copy2(path, path + ".bak_tool")
_write_json_file(path, cfg)
self._json({"ok": True, "data": {"enabled": game["dungeonBatchPickupEnabled"]}})
except Exception as e:
self._json({"error": str(e)}, 500)
# ==================== 支援兵 ====================
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)
# ==================== 自定义账号配置 ====================
def _api_accounts_config(self):
"""返回账号列表(含默认标记)。"""
+1 -76
View File
@@ -165,32 +165,6 @@ button,input,select{font:inherit}
.lo-slot b{display:block;color:#d5ece0;font-weight:400;font-family:Consolas,monospace}
/* server tool */
.st-tabs{display:flex;flex-wrap:wrap;gap:2px;border-bottom:1px solid #244435;margin-bottom:10px}
.st-tab{background:none;border:1px solid transparent;border-bottom:0;color:#8dac9c;padding:8px 13px;cursor:pointer;font-size:13px}
.st-tab:hover{color:#d8eee3}
.st-tab.active{background:#10251c;border-color:#2a503d;color:#8fe5b8}
.st-subtabs{display:flex;gap:4px;margin:-2px 0 10px;padding:6px 0;border-bottom:1px solid #1d3a2d}
.st-subtab{background:transparent;border:1px solid transparent;color:#8dac9c;padding:3px 9px;cursor:pointer;font-size:11px;border-radius:5px}
.st-subtab.active{background:rgba(124,92,255,.16);border-color:rgba(124,92,255,.35);color:#dcd4ff}
.st-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.st-body{border:1px solid #1d3a2d;background:#0b1712;padding:14px;min-height:300px}
.st-section{margin-bottom:16px}
.st-section h3{margin:0 0 8px;color:#9cd8b8;font-size:15px;font-weight:400;border-left:2px solid #4d9f77;padding-left:8px}
.st-form{display:grid;grid-template-columns:repeat(2,minmax(260px,1fr));gap:12px 18px}
.st-form label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:#83a393}
.st-hint{font-size:11.5px;color:#687f73;line-height:1.4;margin-bottom:1px}
.st-form input,.st-form select{background:#08130f;border:1px solid #254636;color:#d2e7db;padding:8px 9px;border-radius:2px;font-size:13px;height:34px}
.st-check{display:flex;align-items:center;gap:6px;font-size:13px;color:#b7d4c5;padding:5px 0}
.st-check-row{display:flex;gap:28px;flex-wrap:wrap;border:1px solid #1d3a2d;background:#0b1712;padding:8px 12px;margin-bottom:10px;border-radius:3px}
.st-check-row .st-check{color:#d5e8de;font-size:13px}
.st-check input{width:15px;height:15px}
.st-note{font-size:12.5px;color:#638373;margin:6px 0}
.st-json{margin-top:14px;border-top:1px solid #1d3a2d;padding-top:10px;color:#8eae9d;font-size:12px}
.st-raw{width:100%;height:300px;background:#07110e;border:1px solid #254636;color:#cfe5d9;
font:11px/1.5 Consolas,monospace;padding:8px;resize:vertical;border-radius:3px;margin:6px 0}
.st-json summary{cursor:pointer;color:#a9d8bf;margin-bottom:8px}
.st-json textarea{width:100%;height:220px;background:#07110e;border:1px solid #254636;color:#cfe5d9;
font:11px/1.5 Consolas,monospace;padding:8px;resize:vertical;border-radius:2px}
/* GM embedded */
#page-gm{padding:0}
@@ -210,7 +184,6 @@ button,input,select{font:inherit}
@media (max-width:1000px){
.stat-grid{grid-template-columns:repeat(2,1fr)}
.loadout-layout{grid-template-columns:1fr}
.st-form{grid-template-columns:repeat(2,1fr)}
}
/* ================= 沿用原启动器配色与图标 ================= */
@@ -277,7 +250,7 @@ button,input,select{font:inherit}
.shell-input:focus{outline:1px solid var(--primary);border-color:var(--primary)}
.tag{border-color:rgba(124,92,255,.45);color:#c9beff;background:rgba(124,92,255,.1)}
.empty-box{border-color:rgba(255,255,255,.12);color:#70748d;background:rgba(20,20,36,.45)}
.loadout-side,.loadout-main,.st-body{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px}
.loadout-side,.loadout-main{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px}
.lo-page{border-color:rgba(255,255,255,.12);color:#aeb2c8;border-radius:6px}
.lo-page.active{background:rgba(124,92,255,.2);color:#eee9ff;border-color:rgba(124,92,255,.5)}
.lo-list{border-top-color:rgba(255,255,255,.08)}
@@ -285,38 +258,11 @@ button,input,select{font:inherit}
.lo-item:hover,.lo-item.active{background:rgba(124,92,255,.16);color:#f2f3f8}
.lo-slot{background:rgba(0,0,0,.18);border-color:rgba(255,255,255,.09);color:#aeb2c8}
.lo-slot b{color:#eef0fb}
.st-tabs{border-bottom-color:rgba(255,255,255,.1)}
.st-tab{color:#9a9eb5}.st-tab:hover{color:#fff}
.st-tab.active{background:rgba(124,92,255,.15);border-color:rgba(124,92,255,.35);color:#dcd4ff;border-radius:8px 8px 0 0}
.st-section h3{color:#c9beff;border-left-color:var(--primary)}
.st-form label,.st-json{color:#9a9eb5}
.st-form input,.st-form select{background:rgba(0,0,0,.22);border-color:rgba(255,255,255,.13);color:#eef0fb;border-radius:6px}
.st-form input:focus,.st-form select:focus{outline:1px solid var(--primary)}
.st-check{color:#c4c8dc}
.st-json summary{color:#c9beff}
.st-json textarea{background:rgba(0,0,0,.25);border-color:rgba(255,255,255,.13);color:#e6e8f5}
.shell-statusbar{background:#08080f;border-top-color:rgba(255,255,255,.08);color:#7d8198}
.shell-statusbar b{color:#b8bcd0}
#page-gm #gmView{background:#0b0b14}
.settings-card{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px}
.settings-label{color:#8d91a8}.settings-value{color:#e2e4f2}
.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;
padding:10px 16px;margin-bottom:18px;background:rgba(20,20,36,.45)}
.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}
/* ================= 字号整体 +2,间距放宽 ================= */
body{font-size:16px}
@@ -381,26 +327,6 @@ body{font-size:16px}
.lo-cooldown.active{color:#ffcf7a}
.lo-slot{font-size:13px;padding:9px}
.lo-slot b{font-size:13px}
.st-tabs{gap:4px;margin-bottom:14px}
.st-tab{font-size:15px;padding:9px 14px}
.st-subtabs{gap:6px;padding:8px 0;margin-bottom:12px}
.st-subtab{font-size:13px;padding:5px 11px}
.st-toolbar{gap:10px;margin-bottom:14px}
.st-body{padding:18px;min-height:380px}
.st-section{margin-bottom:20px}
.st-section h3{font-size:17px;margin-bottom:12px;padding-left:10px}
.st-form{grid-template-columns:repeat(2,minmax(280px,1fr));gap:16px 22px}
.st-form label{font-size:15px;gap:6px}
.st-form input,.st-form select{font-size:15px;padding:9px 10px;height:38px}
.st-hint{font-size:13px;line-height:1.5}
.st-note{font-size:14px;line-height:1.6}
.st-check{font-size:15px;gap:9px;padding:8px 0}
.st-check input{width:17px;height:17px}
.st-check-row{gap:34px;padding:12px 16px;margin-bottom:14px}
.st-check-row .st-check{font-size:15px}
.st-json{font-size:14px;padding-top:14px;margin-top:18px}
.st-json summary{font-size:14px;margin-bottom:10px}
.st-json textarea,.st-raw{font-size:13px;height:260px}
.shell-statusbar{height:34px;font-size:13px;gap:22px;padding:0 18px}
.settings-grid{gap:14px;margin-top:18px}
.settings-card{padding:16px 18px}
@@ -408,7 +334,6 @@ body{font-size:16px}
.settings-value{font-size:13px;line-height:1.7}
@media (max-width:1100px){
.stat-grid{grid-template-columns:repeat(2,1fr)}
.st-form{grid-template-columns:1fr}
.hero-actions{flex-wrap:wrap;gap:10px}
}
.win-resize{position:fixed;z-index:10000;display:none}
+2 -496
View File
@@ -1,19 +1,13 @@
/* 90轮回启动器 · 单窗口页面与工具整合 */
(function () {
const $ = (id) => document.getElementById(id);
const pages = ['start','gm','loadout','companion','servertool','plugins','settings','changelog'];
const pages = ['start','gm','loadout','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:'更新日志'};
const crumb = {start:'开始 / 本地服务端',gm:'GM工具',loadout:'工具 / 一键换装',settings:'设置',changelog:'更新日志'};
function shellGo(page) {
if (!pages.includes(page)) page = 'start';
@@ -22,8 +16,6 @@
$('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') {
@@ -483,492 +475,6 @@
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');
+404
View File
@@ -0,0 +1,404 @@
# 支援兵页面模板(Companion Page Template)
> 从 DNF90 启动器 v2.1.0 中完整导出的「支援兵」页面模板。
> 导出日期:2026-11-04。
> 用途:日后如需恢复「支援兵」功能,按本文档把 HTML / JS / CSS / 后端接口 四个部分分别粘贴回对应文件即可。
---
## 一、HTML(index.html 中的 section)
插入位置:`<section class="shell-page" id="page-changelog">` 之前(或任意 shell-page 之间)。
```html
<section class="shell-page" id="page-companion">
<div class="page-pad">
<div class="page-heading"><div><h2>支援兵</h2><p class="muted">按账号配置支援兵;配置只影响下一次进入副本。</p></div><button class="shell-btn primary" id="cpSave">保存配置</button></div>
<div class="cp-toolbar">
<label>账号<select id="cpAccount" class="shell-input"></select></label>
<label>出战角色<select id="cpOwner" class="shell-input"></select></label>
<button class="shell-btn" id="cpReload">刷新</button>
<span id="cpStatus" class="muted"></span>
</div>
<div class="cp-flags">
<label class="st-check"><input type="checkbox" id="cpEnabled">启用支援兵</label>
<label class="st-check"><input type="checkbox" id="cpBalance">读取佩戴装备</label>
<label class="st-check"><input type="checkbox" id="cpProgression">启用成长推进</label>
<label class="st-check"><input type="checkbox" id="cpRandom">随机武器装备</label>
<label class="st-check"><input type="checkbox" id="cpBasicAttack">允许支援兵普攻</label>
</div>
<h3 class="cp-title">选择支援兵角色</h3>
<div class="cp-grid" id="cpGrid"><div class="empty-box">选择出战角色后加载支援兵列表</div></div>
<div class="cp-ai">
<div class="cp-ai-head"><h3>AI 策略</h3><select id="cpAISource" class="shell-input"></select></div>
<div class="st-form">
<label>行为模式<select id="cpAIBehavior"><option value="assist">辅助</option><option value="aggressive">好战</option></select></label>
<label>BUFF 优先级<select id="cpAIBuff"><option value="leader_first">优先队长</option><option value="assist_only">仅辅助</option><option value="off">关闭</option></select></label>
<label>职业连招<select id="cpAIComboPreset"><option value="auto">按职业自动</option><option value="sword_guard">鬼剑士:控制连招</option><option value="fighter_chain">格斗家:近身连招</option><option value="gunner_burst">神枪手:远程爆发</option><option value="mage_control">魔法师:范围控制</option><option value="priest_support">圣职者:辅助连招</option></select></label>
<label>范围技能最少目标<input id="cpAIAoe" type="number" min="2" max="6" step="1"></label>
<label class="st-check"><input type="checkbox" id="cpAIComboEnabled">启用连招</label>
<label class="st-check"><input type="checkbox" id="cpAIProtect">保护队长</label>
</div>
<details class="st-json">
<summary>高级调参:距离、追击、视野、重试和治疗阈值</summary>
<div class="st-form" id="cpAITuning">
<label>思考间隔(ms)<input data-cp-tuning="think_interval_ms" type="number" min="50" max="2000"></label>
<label>辅助攻击间隔(ms)<input data-cp-tuning="assist_attack_interval_ms" type="number" min="200" max="10000"></label>
<label>辅助横向范围<input data-cp-tuning="assist_leash_x" type="number" min="30" max="500"></label>
<label>辅助纵向范围<input data-cp-tuning="assist_leash_y" type="number" min="20" max="300"></label>
<label>追击范围<input data-cp-tuning="chase_range" type="number" min="20" max="1000"></label>
<label>视野范围<input data-cp-tuning="vision_range" type="number" min="100" max="20000"></label>
<label>目标黏性(ms)<input data-cp-tuning="target_stickiness_ms" type="number" min="0" max="10000"></label>
<label>队长治疗阈值(%)<input data-cp-tuning="heal_leader_percent" type="number" min="0" max="100"></label>
<label>自身治疗阈值(%)<input data-cp-tuning="heal_self_percent" type="number" min="0" max="100"></label>
<label>重试间隔(ms)<input data-cp-tuning="retry_ms" type="number" min="50" max="5000"></label>
<label>辅助重试(ms)<input data-cp-tuning="support_retry_ms" type="number" min="100" max="10000"></label>
<label>忙碌移动(ms)<input data-cp-tuning="busy_move_ms" type="number" min="100" max="10000"></label>
<label>卡住判定(ms)<input data-cp-tuning="stuck_ms" type="number" min="100" max="5000"></label>
<label>绕行等待(ms)<input data-cp-tuning="detour_ms" type="number" min="100" max="5000"></label>
<label>放弃目标(ms)<input data-cp-tuning="give_up_ms" type="number" min="500" max="20000"></label>
<label>跟随距离<input data-cp-tuning="follow_distance" type="number" min="20" max="300"></label>
</div>
</details>
<div class="st-note">AI 策略按角色保存;保存后下一次进入副本生效。</div>
</div>
<div class="st-note" id="cpHint">支援兵按账号隔离,只能选择同一账号下的其他角色。</div>
</div>
</section>
```
---
## 二、侧边栏导航入口(index.html 的 nav 内)
```html
<button class="shell-nav-item" data-shell-page="companion"><span class="nav-ico">👥</span>支援兵</button>
```
---
## 三、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 => `<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; }
});
```
同时,需要在 `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. 重新打包
---
(完)