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

+46
View File
@@ -0,0 +1,46 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
datas = [('index.html', '.'), ('style.css', '.'), ('shell.css', '.'), ('app.js', '.'), ('shell.js', '.'), ('sponsor.png', '.'), ('db.py', '.'), ('native.py', '.'), ('launcher.ico', '.')]
binaries = []
hiddenimports = ['clr_loader', 'pythonnet']
tmp_ret = collect_all('webview')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis(
['server.py'],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='DNF90启动器',
icon='launcher.ico',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+31
View File
@@ -0,0 +1,31 @@
# 整合启动器 + GM 管理工具
单 EXE 的 DNF90 启动器,内置完整 GM 管理工具(角色管理、道具发放、邮件、背包装备、拍卖行、任务、副本、钱包点券、删档等)。
## 技术栈
- **Python 3.15**(32 位)+ **pywebview 6.x**(Edge WebView2 内嵌窗口)
- 纯标准库 HTTP 后端 + 原生 HTML/CSS/JS 前端
- **PyInstaller** 打包单文件 EXE
## 文件说明
| 文件 | 说明 |
|------|------|
| `server.py` | 整合后端(启动器 + GM 工具,纯标准库) |
| `db.py` | SQLite 数据访问层(删角色/删档/疲劳/物品名缓存) |
| `native.py` | Native API 客户端(完整 GM 端点) |
| `index.html` / `style.css` / `app.js` | 双视图前端 |
| `DNF90启动器.spec` | PyInstaller 打包配置 |
| `sponsor.png` / `sponsor_qr.png` | 赞助二维码 |
## 打包
```bash
cd launcher
python -m pip install pywebview pythonnet clr-loader pyinstaller
python -m PyInstaller --clean --noconfirm "DNF90启动器.spec"
# 产物:dist/DNF90启动器.exe
```
> 详见 [`docs/开发文档.md`](../docs/开发文档.md) 完整说明(环境搭建、架构、API 契约、常见问题)。
+2324
View File
File diff suppressed because it is too large. Load diff
+801
View File
@@ -0,0 +1,801 @@
# -*- coding: utf-8 -*-
"""SQLite data-access layer for the DNF90 GM tool.
The server stores almost everything as JSON blobs in dnf_repository_records
(record_kind + record_key -> payload). This module reads/writes those blobs
with a small amount of schema-aware convenience on top.
"""
import hashlib
import json
import os
import sqlite3
import subprocess
import sys
import threading
import time
def _resource_dir():
"""资源目录(PyInstaller 打包时资源在 _MEIPASS,否则脚本目录)。"""
if getattr(sys, "frozen", False):
return getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
return os.path.dirname(os.path.abspath(__file__))
# 物品名称映射缓存(item_id -> name),从外部 GM配置 目录的 dnf_items.json 惰性加载。
# 该文件由 pvfexport.exe 从 Script.pvf 导出,离线也能显示物品名称。
# 服务端(SERVER_ROOT)与 GM配置 目录由 server.py 启动时通过 set_gm_config 注入。
_ITEM_NAMES = None
_ITEM_NAMES_LOADED = False
_GM_CONFIG_DIR = ""
_PVF_PATH = ""
_EXPORT_LOCK = threading.Lock()
def set_gm_config(gm_config_dir, pvf_path):
"""由 server.py 启动时注入 GM配置 目录与 Script.pvf 路径(可多次调用重置)。"""
global _GM_CONFIG_DIR, _PVF_PATH, _ITEM_NAMES, _ITEM_NAMES_LOADED
_GM_CONFIG_DIR = gm_config_dir or ""
_PVF_PATH = pvf_path or ""
_ITEM_NAMES = None
_ITEM_NAMES_LOADED = False
def _items_json_path():
return os.path.join(_GM_CONFIG_DIR, "dnf_items.json") if _GM_CONFIG_DIR else ""
def _find_pvf_path():
"""返回 Script.pvf 路径;优先 _PVF_PATH,否则在 GM配置 上级的 Client/ 下查找。"""
if _PVF_PATH and os.path.exists(_PVF_PATH):
return _PVF_PATH
# 兜底:GM配置 目录上级的 Client/Script.pvf 或 Server/deploy/assets/Script.pvf
if _GM_CONFIG_DIR:
root = os.path.dirname(_GM_CONFIG_DIR)
for cand in (
os.path.join(root, "Client", "Script.pvf"),
os.path.join(root, "Server", "deploy", "assets", "Script.pvf"),
):
if os.path.exists(cand):
return cand
return _PVF_PATH
def _run_pvfexport(items_path, pvf_path):
"""调用 pvfexport.exe 重新导出名称表,成功返回 True。"""
exe = os.path.join(_GM_CONFIG_DIR, "pvfexport.exe")
if not os.path.exists(exe):
return False
try:
# 用 subprocess 调 Windows 程序,路径须为 Windows 风格(反斜杠/盘符)。
exe_win = exe
pvf_win = pvf_path.replace("/", "\\")
out_win = items_path.replace("/", "\\")
proc = subprocess.run(
[exe_win, "-pvf", pvf_win, "-out", out_win, "-compact"],
capture_output=True, text=True, timeout=300,
)
return proc.returncode == 0 and os.path.exists(items_path)
except Exception:
return False
def _load_item_names():
"""加载 dnf_items.json({item_id: name} 映射),失败返回空 dict。
PVF 比名称表新(或名称表缺失)时,自动调用 pvfexport.exe 重新导出。
"""
global _ITEM_NAMES, _ITEM_NAMES_LOADED
if _ITEM_NAMES_LOADED:
return _ITEM_NAMES
_ITEM_NAMES_LOADED = True
_ITEM_NAMES = {}
items_path = _items_json_path()
pvf_path = _find_pvf_path()
if not items_path or not pvf_path:
# 回退到打包资源目录(旧行为,兼容无 GM配置 目录的情况)
candidate = os.path.join(_resource_dir(), "dnf_items.json")
if os.path.exists(candidate):
try:
with open(candidate, "r", encoding="utf-8") as f:
_ITEM_NAMES = json.load(f)
except Exception:
_ITEM_NAMES = {}
return _ITEM_NAMES
# 需要重导出:名称表缺失,或 PVF 修改时间晚于名称表
need_export = not os.path.exists(items_path)
if not need_export:
try:
need_export = os.path.getmtime(pvf_path) > os.path.getmtime(items_path)
except OSError:
need_export = True
if need_export:
with _EXPORT_LOCK:
# 二次确认(避免并发重复触发)
need_export = not os.path.exists(items_path)
if not need_export:
try:
need_export = os.path.getmtime(pvf_path) > os.path.getmtime(items_path)
except OSError:
need_export = True
if need_export:
_run_pvfexport(items_path, pvf_path)
if os.path.exists(items_path):
try:
with open(items_path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
_ITEM_NAMES = data
except Exception:
_ITEM_NAMES = {}
return _ITEM_NAMES
def item_name(item_id):
"""返回 item_id 对应的物品名称,未知则返回空字符串。"""
names = _load_item_names()
return names.get(str(item_id), "")
class DNFDatabase(object):
def __init__(self, path):
self.path = path
self.con = None
self._lock = threading.RLock()
def connect(self):
if self.con is None:
self.con = sqlite3.connect(self.path, check_same_thread=False)
self.con.row_factory = sqlite3.Row
return self.con
def close(self):
with self._lock:
if self.con is not None:
self.con.close()
self.con = None
def _exec(self, fn):
"""run fn(connection) under the thread lock."""
with self._lock:
return fn(self.connect())
# ---- low level -------------------------------------------------
def list_tables(self):
def fn(con):
cur = con.cursor()
cur.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
return [r["name"] for r in cur.fetchall()]
return self._exec(fn)
def list_records(self):
def fn(con):
cur = con.cursor()
cur.execute("SELECT record_kind, record_key, length(payload) AS size "
"FROM dnf_repository_records ORDER BY record_kind, record_key")
return [dict(r) for r in cur.fetchall()]
return self._exec(fn)
def get_record(self, kind, key):
def fn(con):
cur = con.cursor()
cur.execute("SELECT payload, checksum, updated_at FROM dnf_repository_records "
"WHERE record_kind=? AND record_key=?", (kind, key))
r = cur.fetchone()
if r is None:
return None
return {"payload": r["payload"], "checksum": r["checksum"],
"updated_at": r["updated_at"]}
return self._exec(fn)
def get_record_json(self, kind, key):
r = self.get_record(kind, key)
if r is None:
return None
try:
return json.loads(r["payload"].decode("utf-8"))
except (ValueError, AttributeError):
return None
def put_record_json(self, kind, key, obj):
"""overwrite a record's JSON payload, recomputing the sha256 checksum."""
blob = json.dumps(obj, ensure_ascii=False).encode("utf-8")
checksum = hashlib.sha256(blob).hexdigest()
def fn(con):
cur = con.cursor()
cur.execute("UPDATE dnf_repository_records SET payload=?, checksum=? "
"WHERE record_kind=? AND record_key=?",
(sqlite3.Binary(blob), checksum, kind, key))
if cur.rowcount == 0:
cur.execute("INSERT INTO dnf_repository_records "
"(record_kind, record_key, payload, checksum, updated_at) "
"VALUES (?, ?, ?, ?, ?)",
(kind, key, sqlite3.Binary(blob), checksum, ""))
con.commit()
self._exec(fn)
# ---- domain helpers -------------------------------------------
def list_accounts(self):
def fn(con):
cur = con.cursor()
cur.execute("SELECT account_id, username, state, created_at, last_login_at "
"FROM dnf_login_accounts ORDER BY username")
return [dict(r) for r in cur.fetchall()]
return self._exec(fn)
def list_characters(self):
"""character records (record_kind='character') with parsed summary."""
def fn(con):
out = []
cur = con.cursor()
cur.execute("SELECT record_key, payload FROM dnf_repository_records "
"WHERE record_kind='character' ORDER BY record_key")
for r in cur.fetchall():
try:
ch = json.loads(r["payload"].decode("utf-8"))
except (ValueError, AttributeError):
continue
out.append({
"character_id": ch.get("character_id", r["record_key"]),
"account_id": ch.get("account_id", ""),
"name": ch.get("name", ""),
"job": ch.get("job", "0"),
"level": ch.get("level", 0),
"updated_at": ch.get("updated_at", ""),
})
return out
return self._exec(fn)
def get_character(self, char_id):
return self.get_record_json("character", str(char_id))
def get_character_stats(self, char_id):
ch = self.get_character(str(char_id))
return (ch or {}).get("stats", {}) if ch else None
def set_character_field(self, char_id, field, value):
"""set a top-level field (name/job/level) on a character record."""
ch = self.get_character(str(char_id))
if ch is None:
raise KeyError("角色 %s 不存在" % char_id)
ch[field] = value
self.put_record_json("character", str(char_id), ch)
def set_character_stat(self, char_id, stat_name, value):
"""set a value inside character.stats."""
ch = self.get_character(str(char_id))
if ch is None:
raise KeyError("角色 %s 不存在" % char_id)
ch.setdefault("stats", {})[stat_name] = value
self.put_record_json("character", str(char_id), ch)
def grant_item(self, char_id, item_id, count=1):
"""add an item to a character's inventory (SQLite mode).
slot keys are "{container}:{slot}" (e.g. "0:23"). We append to the
next free slot of container 0 (the main bag)."""
inv = self.get_record_json("inventory", str(char_id)) or {}
slots = inv.setdefault("slots", {})
used = set()
for k in slots:
if ":" in k:
cont, _, slot = k.partition(":")
if cont == "0" and slot.isdigit():
used.add(int(slot))
slot = 1
while slot in used:
slot += 1
slots["0:%d" % slot] = {"item_id": item_id, "count": count,
"expire_at": "0001-01-01T00:00:00Z"}
inv["updated_at"] = self._now_iso()
self.put_record_json("inventory", str(char_id), inv)
return {"slot": "0:%d" % slot}
def inventory_list_slots(self, char_id):
"""列出角色背包所有行(SQLite 直读),供「背包管理」展示。
返回 [{"collection":"slots","entry_key":key,"item_id":..,"count":..,"bind":..}, ...]。
key 格式 "{container}:{slot}"(如 "0:23")。"""
inv = self.get_record_json("inventory", str(char_id)) or {}
slots = inv.get("slots", {})
out = []
for key, v in sorted(slots.items()):
if not isinstance(v, dict):
continue
iid = v.get("item_id", 0)
out.append({
"collection": "slots",
"entry_key": key,
"item_id": iid,
"count": int(v.get("count", 0) or 0),
"bind": bool(v.get("bind", False)),
"item_name": item_name(iid),
})
return out
def inventory_delete_slot(self, char_id, entry_key):
"""删除背包里指定的一行(SQLite 直读),key 格式 "{container}:{slot}"。"""
inv = self.get_record_json("inventory", str(char_id))
if inv is None:
raise KeyError("角色 %s 的背包记录不存在" % char_id)
slots = inv.get("slots", {})
if entry_key not in slots:
raise KeyError("背包行 %s 不存在" % entry_key)
removed = slots.pop(entry_key)
inv["slots"] = slots
inv["updated_at"] = self._now_iso()
self.put_record_json("inventory", str(char_id), inv)
return {
"entry_key": entry_key,
"item_id": removed.get("item_id", 0) if isinstance(removed, dict) else 0,
"removed": removed.get("count", 1) if isinstance(removed, dict) else 1,
}
def set_fatigue(self, char_id, fatigue, fatigue_limit=None):
"""设置疲劳值(fatigue)与疲劳上限(fatigue_limit)。
- fatigue : 当前疲劳值(剩余疲劳)
- fatigue_limit : 疲劳上限(每天 6 点重置回该值,默认 156 / 黑钻 188)
所谓「无限疲劳」即把两者都设成一个很大的值,同时把已用疲劳
(fatigue_display_update)清零,保证客户端显示正确。
"""
ch = self.get_character(str(char_id))
if ch is None:
raise KeyError("角色 %s 不存在" % char_id)
stats = ch.setdefault("stats", {})
fatigue = int(fatigue)
if fatigue < 0:
fatigue = 0
stats["fatigue"] = fatigue
if fatigue_limit is not None:
limit = int(fatigue_limit)
if limit < 0:
limit = 0
stats["fatigue_limit"] = limit
# 已用疲劳 = 上限 - 当前,客户端据此显示;拉满时清零
limit = stats.get("fatigue_limit", 0)
used = max(0, limit - fatigue)
stats["fatigue_display_update"] = used
ch["updated_at"] = self._now_iso()
self.put_record_json("character", str(char_id), ch)
return {"fatigue": stats["fatigue"],
"fatigue_limit": stats.get("fatigue_limit", 0),
"used": used}
def get_fatigue(self, char_id):
"""读取角色的疲劳相关信息。"""
ch = self.get_character(str(char_id))
if ch is None:
return None
stats = (ch or {}).get("stats", {})
fatigue = stats.get("fatigue")
if fatigue is None:
# 兼容旧字段名
for key in ("fp", "疲劳"):
if key in stats:
fatigue = stats[key]
break
return {
"fatigue": fatigue,
"fatigue_limit": stats.get("fatigue_limit"),
"used": stats.get("fatigue_display_update"),
}
@staticmethod
def _now_iso():
import datetime
return datetime.datetime.now().isoformat()
# ---- delete / wipe (dangerous, irreversible) ------------------
def delete_character(self, char_id):
"""Delete a character and every record that belongs to it.
Record kinds whose key is the raw character id (character, equipment,
inventory, mailbox, quest, skill, dungeon_permission) plus the
`character:{id}:...` settings keys are removed. Account-scoped data
(account, account_inventory, account-v1:* dungeon permission) is kept.
Returns the number of rows deleted.
"""
cid = str(char_id)
def fn(con):
cur = con.cursor()
# character-scoped records keyed directly by the id
kinds = ("character", "equipment", "inventory", "mailbox",
"quest", "skill", "dungeon_permission")
qmarks = ",".join("?" for _ in kinds)
cur.execute(
"DELETE FROM dnf_repository_records "
"WHERE (record_kind IN (%s) AND record_key=?) "
" OR record_key LIKE ?" % qmarks,
tuple(kinds) + (cid, "character:" + cid + ":%"))
deleted = cur.rowcount
con.commit()
return deleted
return self._exec(fn)
def delete_account(self, account_id):
"""Delete an account and all records referencing it.
Removes the dnf_login_accounts row and every record whose key is the
account id, the `account:{id}:...` settings keys, or an `account-v1:`
dungeon-permission key belonging to that account (the latter requires
reading the account-v1 key to resolve its owner, so here we delete by
exact key match plus the account-scoped prefix matches we know).
"""
aid = str(account_id)
def fn(con):
cur = con.cursor()
# login account row
cur.execute("DELETE FROM dnf_login_accounts WHERE account_id=?", (aid,))
n1 = cur.rowcount
# repository records: exact key, or account-prefixed settings
cur.execute(
"DELETE FROM dnf_repository_records "
"WHERE record_key=? OR record_key LIKE ?",
(aid, "account:" + aid + ":%"))
n2 = cur.rowcount
con.commit()
return {"account_rows": n1, "record_rows": n2}
return self._exec(fn)
def list_character_record_kinds(self, char_id):
"""Return the record_kind/record_key rows belonging to a character,
for the confirmation UI to show exactly what will be deleted."""
cid = str(char_id)
def fn(con):
cur = con.cursor()
cur.execute(
"SELECT record_kind, record_key FROM dnf_repository_records "
"WHERE record_key=? OR record_key LIKE ? "
"ORDER BY record_kind",
(cid, "character:" + cid + ":%"))
return [dict(r) for r in cur.fetchall()]
return self._exec(fn)
def list_account_record_kinds(self, account_id):
"""Return records belonging to an account (for confirmation UI)."""
aid = str(account_id)
def fn(con):
cur = con.cursor()
cur.execute(
"SELECT record_kind, record_key FROM dnf_repository_records "
"WHERE record_key=? OR record_key LIKE ? "
"ORDER BY record_kind",
(aid, "account:" + aid + ":%"))
return [dict(r) for r in cur.fetchall()]
return self._exec(fn)
# ==================================================================
# 拍卖行(上下架工具)
#
# 服务端把挂单全部存放在托管角色 system:auction:escrow 的 inventory
# 记录里,slots 的 key 为 auction:listing:<id>,价格/卖家/时间等字段
# 编码在 ItemStack.extra 里(见 90server go-server 的 auction/owner.go)。
# 这里按同一套数据结构直接读写 SQLite,行为与游戏内一致。
# ==================================================================
AUCTION_ESCROW_CHAR = "system:auction:escrow"
AUCTION_LISTING_PREFIX = "auction:listing:"
AUCTION_DELIVERY_PREFIX = "auction:delivery:"
AUCTION_REFUND_PREFIX = "auction:refund:"
# 上架押金 / 手续费(与服务端常量一致)
AUCTION_DEPOSIT = 10000 # 上架押金(取消上架不退)
AUCTION_SALE_FEE_PERCENT = 5 # 成交手续费(%)
# GM 挂单 ID 使用高位段,避免与服务端进程内计数器(重启后从 1 递增)碰撞
AUCTION_GM_ID_BASE = 9000000000000000000
def _auction_escrow(self):
"""读取(或初始化)拍卖行托管角色的 inventory 记录。"""
return self.get_record_json("inventory", self.AUCTION_ESCROW_CHAR) or {}
def _auction_save_escrow(self, escrow):
escrow.setdefault("character_id", self.AUCTION_ESCROW_CHAR)
escrow.setdefault("slots", {})
escrow["updated_at"] = self._now_iso()
self.put_record_json("inventory", self.AUCTION_ESCROW_CHAR, escrow)
@staticmethod
def _auction_next_id(slots):
"""返回一个未被占用、且落在高位段的挂单 ID。
服务端的挂单 ID 是进程内计数器(重启后从 1 重新开始,碰撞时重试 8 次),
因此 GM 工具造的挂单若占用小 ID,会让重启后的服务端连续撞号。这里直接
使用一个极大的固定段,既不会与进程内计数器冲突,也能通过单调递增避免
已产生过投递/退款的 ID 被复用。"""
best = DNFDatabase.AUCTION_GM_ID_BASE - 1
for key in slots:
if not key.startswith(DNFDatabase.AUCTION_LISTING_PREFIX):
continue
try:
best = max(best, int(key[len(DNFDatabase.AUCTION_LISTING_PREFIX):]))
except (TypeError, ValueError):
continue
return best + 1
def auction_list_listings(self):
"""返回当前所有挂单(含已过期的),解析成前端友好的结构。"""
escrow = self._auction_escrow()
slots = escrow.get("slots", {})
out = []
for key, stack in slots.items():
if not key.startswith(self.AUCTION_LISTING_PREFIX):
continue
if not isinstance(stack, dict):
continue
extra = stack.get("extra", {}) or {}
listing_id = key[len(self.AUCTION_LISTING_PREFIX):]
try:
unit_price = int(extra.get("unit_price", "0") or 0)
start_price = int(extra.get("start_price", "-1") or -1)
buyout_price = int(extra.get("buyout_price", "-1") or -1)
deposit = int(extra.get("deposit", "0") or 0)
fee_percent = int(extra.get("fee_percent", "0") or 0)
bid_total = int(extra.get("bid_total", "0") or 0)
created_at = int(extra.get("created_at", "0") or 0)
expires_at = int(extra.get("expires_at", "0") or 0)
source_slot = extra.get("source_slot", "")
list_type = extra.get("list_type", "0")
except (ValueError, TypeError):
continue
count = int(stack.get("count", 1) or 1)
now = int(time.time())
out.append({
"listing_id": listing_id,
"item_id": stack.get("item_id", 0),
"count": count,
"seller_id": extra.get("seller_id", ""),
"seller_name": extra.get("seller_name", ""),
"list_type": list_type,
"source_slot": source_slot,
"unit_price": unit_price,
"start_price": start_price,
"buyout_price": buyout_price,
"deposit": deposit,
"fee_percent": fee_percent,
"bid_total": bid_total,
"bidder_id": extra.get("bidder_id", ""),
"created_at": created_at,
"expires_at": expires_at,
"expired": expires_at > 0 and expires_at < now,
})
# 新→旧排序(GM 高位段 id 天然更大,排前面)
out.sort(key=lambda x: x["listing_id"], reverse=True)
return out
def auction_escrow_gold(self):
"""返回托管角色的金币(用于核对)。"""
ch = self.get_character(self.AUCTION_ESCROW_CHAR)
if not ch:
return 0
return int((ch.get("stats") or {}).get("gold", 0) or 0)
def auction_list(self, seller_id, item_id, count, unit_price,
start_price=None, buyout_price=None, duration_hours=24):
"""上架一件物品。
行为对齐服务端 registerWithin:
- 从卖家背包移除物品,写入 escrow 挂单槽
- 押金从卖家金币扣除,转入 escrow 角色金币
- 挂单 Extra 记录全部价格/时间/卖家字段
:param start_price: 每件起拍价(-1 表示不设竞拍,一口价模式)
:param buyout_price: 每件一口价(-1 表示不设一口价)
"""
seller_id = str(seller_id)
item_id = int(item_id)
count = int(count)
unit_price = int(unit_price)
if unit_price <= 0:
raise ValueError("单价必须大于 0")
if count <= 0:
raise ValueError("数量必须大于 0")
# 一口价:未显式给定时默认 = 单价;起拍价:未给定时默认 -1(无竞拍)
if buyout_price is None:
buyout_price = unit_price
else:
buyout_price = int(buyout_price)
if start_price is None:
start_price = -1
else:
start_price = int(start_price)
# 校验:至少一口价或起拍价其一有效
if buyout_price <= 0 and start_price <= 0:
raise ValueError("一口价和起拍价不能同时为空")
if start_price > 0 and start_price > unit_price:
raise ValueError("起拍价不能高于单价")
# 卖家角色
seller = self.get_character(seller_id)
if seller is None:
raise KeyError("卖家角色 %s 不存在" % seller_id)
seller_name = seller.get("name", "")
seller_account = seller.get("account_id", "")
# 卖家背包
inv = self.get_record_json("inventory", seller_id) or {}
slots = inv.setdefault("slots", {})
# 找到包含该物品的背包槽(主背包 0:*)
source_key = None
source_stack = None
for k, v in slots.items():
if not isinstance(v, dict):
continue
if str(v.get("item_id")) == str(item_id) and int(v.get("count", 0) or 0) >= count:
source_key = k
source_stack = v
break
if source_key is None:
raise ValueError("卖家背包中该物品数量不足")
# 托管角色与 escrow
escrow = self._auction_escrow()
escrow_slots = escrow.setdefault("slots", {})
listing_id = self._auction_next_id(escrow_slots)
listing_key = self.AUCTION_LISTING_PREFIX + str(listing_id)
now = int(time.time())
expires = now + int(duration_hours) * 3600
# 挂单 ItemStack:复制原物品(含 extra),再写入挂单字段
listed = dict(source_stack)
listed["count"] = count
extra = dict(source_stack.get("extra", {}) or {})
extra["seller_id"] = seller_id
extra["seller_name"] = seller_name or ""
extra["list_type"] = "0"
extra["source_slot"] = source_key.split(":", 1)[-1] if ":" in source_key else source_key
extra["unit_price"] = str(unit_price)
extra["start_price"] = str(start_price)
extra["buyout_price"] = str(buyout_price)
extra["deposit"] = str(self.AUCTION_DEPOSIT)
extra["fee_percent"] = str(self.AUCTION_SALE_FEE_PERCENT)
extra["created_at"] = str(now)
extra["expires_at"] = str(expires)
listed["extra"] = extra
# 卖家背包:数量扣减或整槽删除
remaining = int(source_stack.get("count", 0) or 0) - count
if remaining <= 0:
del slots[source_key]
else:
source_stack["count"] = remaining
slots[source_key] = source_stack
inv["updated_at"] = self._now_iso()
# 押金:卖家 -> escrow
stats = seller.setdefault("stats", {})
gold = int(stats.get("gold", 0) or 0)
if gold < self.AUCTION_DEPOSIT:
raise ValueError("卖家金币不足,无法缴纳上架押金 %d" % self.AUCTION_DEPOSIT)
stats["gold"] = gold - self.AUCTION_DEPOSIT
seller["updated_at"] = self._now_iso()
escrow_char = self.get_character(self.AUCTION_ESCROW_CHAR)
if escrow_char is None:
escrow_char = {
"character_id": self.AUCTION_ESCROW_CHAR,
"account_id": "system:auction",
"name": "拍卖行托管",
"job": "0", "level": 0, "stats": {"gold": 0},
"created_at": self._now_iso(), "updated_at": self._now_iso(),
}
escrow_stats = escrow_char.setdefault("stats", {})
escrow_stats["gold"] = int(escrow_stats.get("gold", 0) or 0) + self.AUCTION_DEPOSIT
escrow_char["updated_at"] = self._now_iso()
# 写入挂单槽
escrow_slots[listing_key] = listed
escrow["updated_at"] = self._now_iso()
# 持久化(顺序:背包 -> 角色 -> escrow 角色 -> escrow 背包)
self.put_record_json("inventory", seller_id, inv)
self.put_record_json("character", seller_id, seller)
self.put_record_json("character", self.AUCTION_ESCROW_CHAR, escrow_char)
self._auction_save_escrow(escrow)
return {
"listing_id": listing_id,
"item_id": item_id,
"count": count,
"unit_price": unit_price,
"start_price": start_price,
"buyout_price": buyout_price,
"deposit": self.AUCTION_DEPOSIT,
"expires_at": expires,
"seller_gold_after": stats["gold"],
}
def auction_cancel(self, listing_id):
"""下架(取消)一件挂单。
行为对齐服务端 Cancel:
- 从 escrow 移除挂单槽
- 物品退回卖家背包(游戏内经邮件,这里直接写入背包,GM 工具便于核对)
- 押金不退(取消上架押金没收,服务端注释明确此为 gold sink)
- 若有竞价,退款给竞价者(这里按相同 refund 槽逻辑写入)
"""
listing_id = str(listing_id)
escrow = self._auction_escrow()
slots = escrow.setdefault("slots", {})
listing_key = self.AUCTION_LISTING_PREFIX + listing_id
stack = slots.get(listing_key)
if stack is None:
raise KeyError("挂单 %s 不存在" % listing_id)
extra = stack.get("extra", {}) or {}
seller_id = extra.get("seller_id", "")
item_id = stack.get("item_id", 0)
count = int(stack.get("count", 1) or 1)
# 移除挂单
del slots[listing_key]
# 物品退回卖家背包(找空闲槽位)
if seller_id:
inv = self.get_record_json("inventory", seller_id) or {}
inv_slots = inv.setdefault("slots", {})
returned = dict(stack)
returned.pop("extra", None) # 去掉挂单字段,还原为普通物品
# 尝试放回原槽位,否则找空闲槽
target_key = None
source_slot = extra.get("source_slot", "")
preferred = "0:" + source_slot if source_slot else None
if preferred and preferred not in inv_slots:
target_key = preferred
else:
used = set()
for k in inv_slots:
if ":" in k:
c, _, s = k.partition(":")
if c == "0" and s.isdigit():
used.add(int(s))
slot = 1
while slot in used:
slot += 1
target_key = "0:%d" % slot
inv_slots[target_key] = returned
inv["updated_at"] = self._now_iso()
self.put_record_json("inventory", seller_id, inv)
# 竞价退款:bid_total > 0 时退回竞价者(写 refund 槽,与 releaseListingBid 一致)
bid_total = int(extra.get("bid_total", "0") or 0)
bidder_id = extra.get("bidder_id", "")
refund = None
if bid_total > 0 and bidder_id:
refund_key = self.AUCTION_REFUND_PREFIX + listing_id + ":" + bidder_id
slots[refund_key] = {
"item_id": item_id,
"count": bid_total,
"extra": {
"seller_id": bidder_id,
"listing_id": listing_id,
"sold_count": "0",
"refund": "1",
},
}
# 退款金币写回竞价者角色
bidder = self.get_character(bidder_id)
if bidder:
bstats = bidder.setdefault("stats", {})
bstats["gold"] = int(bstats.get("gold", 0) or 0) + bid_total
bidder["updated_at"] = self._now_iso()
self.put_record_json("character", bidder_id, bidder)
refund = {"bidder_id": bidder_id, "gold": bid_total}
escrow["updated_at"] = self._now_iso()
self._auction_save_escrow(escrow)
return {
"listing_id": listing_id,
"item_id": item_id,
"count": count,
"seller_id": seller_id,
"returned_to": seller_id,
"deposit_forfeited": self.AUCTION_DEPOSIT,
"refund": refund,
}
+248
View File
@@ -0,0 +1,248 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>90轮回</title>
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="/shell.css">
</head>
<body>
<!-- 兼容旧 app.js 的隐藏控件 -->
<div id="launcherView" class="hidden" aria-hidden="true">
<div id="logBody"></div>
<span id="statusBadge"><span id="statusText"></span></span>
<span id="footInfo"></span>
<button id="btnStart"></button><button id="btnGm"></button><button id="btnStop"></button>
<button id="btnSponsor"></button><button id="btnWinLoadout"></button><button id="btnWinSetting"></button>
<button id="btnClearLog"></button>
</div>
<div id="appShell">
<header class="shell-topnav">
<div class="shell-brand">
<div class="shell-logo">90</div>
<div><b>90轮回</b><span>启动器 <em>v2.1.0</em></span></div>
</div>
<nav class="shell-nav">
<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>
</nav>
<div class="shell-win-controls">
<button class="tb-btn" id="shellWinMin" title="最小化">—</button>
<button class="tb-btn tb-close" id="shellWinClose" title="关闭">×</button>
</div>
</header>
<main class="shell-main">
<header class="shell-topbar">
<div class="shell-crumb" id="shellCrumb">开始 / 本地服务端</div>
</header>
<section class="shell-page active" id="page-start">
<div class="hero">
<div class="hero-bg"></div>
<div class="hero-content">
<div class="hero-kicker">DNF · 90级 · 本地服务端</div>
<h1 id="shellServerName">Server90轮回</h1>
<div class="hero-status">
<span class="live-dot" id="shellStatusDot"></span>
<span id="shellStatusText">检测中…</span>
<span class="muted" id="shellPid">PID —</span>
</div>
<div class="hero-actions">
<button class="start-btn" id="shellStart"><span>▶</span> 开始游戏</button>
<button class="start-menu" id="shellStartMenu">▾</button>
<div class="start-menu-pop hidden" id="shellStartPop">
<button data-shell-action="start">启动服务端并登录</button>
<button data-shell-action="launch">仅启动客户端</button>
<button data-shell-action="stop">关闭服务端</button>
</div>
<button class="account-login" id="shellAccount" title="管理并选择游戏账号">
<span class="account-ico">👤</span>
<span><b>账号管理</b><small id="shellAccountName">加载中…</small></span>
</button>
<div class="account-picker">
<span class="account-picker-label">当前登录账号</span>
<select id="shellAccountSelect" title="选择登录账号"></select>
</div>
<button class="stop-server-btn" id="shellStop"><span>■</span> 关闭服务端</button>
</div>
<div class="hero-hint">服务端和客户端会一起启动,首次约 10 秒;切换账号后再点“开始游戏”可多开。</div>
</div>
<div class="hero-clock" id="shellClock">检查于 --:--:--</div>
</div>
<div class="stat-grid">
<div class="stat-card"><div class="stat-label">版本</div><div class="stat-row"><span>本地</span><b id="statVersion">v2026.11.03</b></div><div class="stat-row"><span>远程最新</span><b class="green" id="statRemote">检查中</b></div><div class="stat-row"><span>状态</span><b class="green" id="statState">—</b></div></div>
<div class="stat-card"><div class="stat-label">运行</div><div class="stat-row"><span>客户端</span><b id="statClient">—</b></div><div class="stat-row"><span>游戏端口</span><b id="statPorts">—</b></div><div class="stat-row"><span>资源</span><b id="statResource">—</b></div></div>
<div class="stat-card"><div class="stat-label">数据</div><div class="stat-row"><span>PVF 文件</span><b id="statPvf">—</b></div><div class="stat-row"><span>客户端目录</span><b class="small" id="statClientDir">—</b></div><div class="stat-row"><span>版本记录</span><b id="statDb">—</b></div></div>
<div class="stat-card"><div class="stat-label">工具</div><div class="stat-row"><span>内置 GM</span><b class="green">已开启</b></div><div class="stat-row"><span>GM 端口</span><b id="statGmPort">18111</b></div><div class="stat-row"><span>接口端口</span><b id="statApiPort">19527</b></div></div>
</div>
</section>
<section class="shell-page" id="page-loadout">
<div class="page-pad">
<div class="page-heading"><div><h2>一键换装</h2><p class="muted">方案保存在 LoadoutTool/config.json;全局热键由启动器后端注册。</p></div><button class="shell-btn primary" id="loSaveConfig">保存配置</button></div>
<div class="loadout-layout">
<div class="loadout-side">
<div class="lo-pages" id="loPages"></div>
<div class="lo-list" id="loList"></div>
<div class="lo-side-actions"><button class="shell-btn" id="loAddPage">新增换装页</button><button class="shell-btn" id="loAddScheme">新增方案</button><button class="shell-btn" id="loDeleteScheme">删除方案</button></div>
</div>
<div class="loadout-main">
<div class="lo-form">
<label>方案名称<input id="loName" class="shell-input"></label>
<label>绑定角色<input id="loCharacter" class="shell-input" placeholder="角色游戏内名称"></label>
<label>热键<input id="loHotkey" class="shell-input" placeholder="例如 Ctrl+F1"></label>
<button class="shell-btn" id="loCaptureHotkey">录制热键</button>
</div>
<div class="lo-toolbar"><button class="shell-btn" id="loRefreshChars">刷新角色</button><button class="shell-btn" id="loSaveCurrent">保存当前装备</button><button class="shell-btn primary" id="loApply">应用方案</button><span id="loStatus" class="muted"></span><span id="loCooldown" class="lo-cooldown"></span></div>
<div class="lo-slots" id="loSlots"><div class="empty-box">选择方案后显示装备快照</div></div>
</div>
</div>
</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>
<div class="settings-grid">
<div class="settings-card"><div class="settings-label">服务端根目录</div><div class="settings-value" id="setServerRoot">—</div></div>
<div class="settings-card"><div class="settings-label">服务端控制</div><div class="settings-value" id="setControl">—</div></div>
<div class="settings-card"><div class="settings-label">数据库</div><div class="settings-value" id="setDb">—</div></div>
<div class="settings-card"><div class="settings-label">游戏客户端</div><div class="settings-value" id="setClient">—</div></div>
<div class="settings-card"><div class="settings-label">服务端状态</div><div class="settings-value" id="setRun">—</div></div>
<div class="settings-card"><div class="settings-label">Native API</div><div class="settings-value" id="setNative">—</div></div>
</div>
</div>
</section>
<section class="shell-page" id="page-changelog">
<div class="page-pad"><h2>更新日志</h2><p class="muted">启动器与工具集成记录。</p><div class="empty-box">2.1.0 · 单窗口整合启动器、GM、一键换装、服务端工具</div></div>
</section>
<section class="shell-page" id="page-gm">
<div id="gmView" class="gm-app">
<aside class="gm-sidebar">
<div class="gm-logo"><div class="gm-logo-mark">D</div><div><div class="gm-logo-title">DNF90 GM</div><div class="gm-logo-sub">九零轮回 · 管理工具</div></div></div>
<div class="gm-nav-group">总览</div>
<div class="gm-nav-item active" data-page="dashboard"><span class="ico">📊</span>控制台</div>
<div class="gm-nav-item" data-page="characters"><span class="ico">👥</span>角色管理</div>
<div class="gm-nav-group">资源</div>
<div class="gm-nav-item" data-page="items"><span class="ico">🎁</span>道具发放</div>
<div class="gm-nav-item" data-page="mail"><span class="ico">✉️</span>邮件系统</div>
<div class="gm-nav-item" data-page="inventory"><span class="ico">🎒</span>背包装备</div>
<div class="gm-nav-item" data-page="auction"><span class="ico">🏷️</span>拍卖行</div>
<div class="gm-nav-group">进度</div>
<div class="gm-nav-item" data-page="quests"><span class="ico">📜</span>任务管理</div>
<div class="gm-nav-item" data-page="dungeons"><span class="ico">⚔️</span>副本控制</div>
<div class="gm-nav-group">系统</div>
<div class="gm-nav-item" data-page="wallet"><span class="ico">💰</span>钱包点券</div>
<div class="gm-nav-item" data-page="wipe"><span class="ico">🗑️</span>删档管理</div>
<div class="gm-nav-item" data-page="settings"><span class="ico">⚙️</span>连接设置</div>
<div class="gm-sidebar-foot"><div><span class="conn-dot off" id="gmConnDot"></span><span class="conn-text" id="gmConnText">数据源:SQLite</span></div><button class="gm-back" id="btnBackLauncher">← 返回启动器</button></div>
</aside>
<div class="gm-main"><header class="gm-topbar"><h1 id="gmPageTitle">控制台</h1><span class="gm-crumb" id="gmPageCrumb">/ 服务端总览</span><div class="topbar-spacer"></div><button class="gm-btn gm-btn-sm gm-btn-ghost" id="gmBtnRefresh">↻ 刷新</button><button class="gm-win-btn gm-win-min" id="gmWinMin" title="最小化">—</button><button class="gm-win-btn gm-win-close" id="gmWinClose" title="关闭">×</button></header><div class="gm-content" id="gmContent"></div></div>
</div>
</section>
</main>
</div>
<div class="shell-statusbar"><span><i class="live-dot"></i> <b id="shellBottomStatus">运行中</b></span><span>端口 <b id="shellBottomPort">—</b></span><span>内存 <b id="shellBottomMem">—</b></span><span>CPU <b>0.0%</b></span><span class="grow"></span><span id="shellBottomLog">日志 就绪</span></div>
<div class="win-resize n" data-edge="top"></div><div class="win-resize s" data-edge="bottom"></div>
<div class="win-resize w" data-edge="left"></div><div class="win-resize e" data-edge="right"></div>
<div class="win-resize nw" data-edge="topleft"></div><div class="win-resize ne" data-edge="topright"></div>
<div class="win-resize sw" data-edge="bottomleft"></div><div class="win-resize se" data-edge="bottomright"></div>
<div class="modal-mask hidden" id="sponsorModal"><div class="modal"><div class="modal-head"><span>赞助我们</span><button class="modal-close" id="btnCloseSponsor">×</button></div><div class="modal-body"><p class="sponsor-tip">感谢支持 DNF90 单机模拟器!</p><div class="qrcode-box" id="qrcodeBox"></div></div></div></div>
<div class="modal-mask hidden" id="accountsModal"><div class="modal" style="width:520px"><div class="modal-head"><span>账号管理</span><button class="modal-close" id="btnCloseAccounts" onclick="document.getElementById('accountsModal').classList.add('hidden')">×</button></div><div class="modal-body" style="text-align:left"><div id="accountsList" style="margin-bottom:16px"></div><div class="accounts-add"><div class="accounts-add-title">添加新账号</div><div class="gm-form-row"><div class="gm-field"><label>用户名</label><input class="gm-input" id="newAccUser"></div><div class="gm-field"><label>密码</label><input class="gm-input" id="newAccPass" type="password"></div></div><button class="gm-btn gm-btn-primary" id="btnAddAccount">+ 添加账号</button></div></div></div></div>
<script src="/app.js"></script>
<script src="/shell.js"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

+637
View File
@@ -0,0 +1,637 @@
# -*- coding: utf-8 -*-
"""Native API (127.0.0.1:18111) client for the DNF90 GM tool.
本文件依据服务端源码 (go-server/internal/app/dnf90/gm_tool_admin.go、
gm_admin.go、gm_tool_compat.go 以及 internal/services/dnfbridge/*) 精确实现
全部 GM 端点,字段名/类型/校验规则均与源码一致。
两类端点:
- 读端点 GET /dnf/gm/... (返回 {"success":true,"data":...})
- 写端点 POST /dnf/gm/... 或 /local/dnf-gm/... (需要 X-Admin-Confirm)
认证头:
X-Admin-Token : adminToken(来自 instance.json)
X-Admin-Confirm: 写操作设为 "1"
"""
import json
import os
import uuid
import urllib.request
import urllib.error
from urllib.parse import urlencode
class NativeError(Exception):
pass
class NativeClient(object):
def __init__(self, base_url="http://127.0.0.1:18111", token=""):
self.base_url = base_url.rstrip("/")
self.token = token
# ---- low-level HTTP ------------------------------------------
def _request(self, method, path, body=None, confirm=False):
url = self.base_url + path
data = None
headers = {"Accept": "application/json"}
if self.token:
headers["X-Admin-Token"] = self.token
if confirm:
headers["X-Admin-Confirm"] = "1"
headers["X-Admin-Idempotency-Key"] = uuid.uuid4().hex
if body is not None:
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read()
if not raw:
return {}
return json.loads(raw.decode("utf-8"))
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", "replace")
# 尝试提取服务端返回的 error 字段,给出更友好的提示
try:
parsed = json.loads(raw)
msg = parsed.get("error") or raw[:200]
except Exception:
msg = raw[:200]
raise NativeError("HTTP %d: %s" % (e.code, msg))
except urllib.error.URLError as e:
raise NativeError("连接失败: %s" % e.reason)
def _get(self, path):
return self._request("GET", path)
def _post(self, path, body=None, confirm=True):
return self._request("POST", path, body=body, confirm=confirm)
def _unwrap(self, raw):
"""取服务端统一响应里的 data 字段。"""
if isinstance(raw, dict) and "data" in raw:
return raw["data"]
return raw
# ---- 状态 / 账号 / 角色 --------------------------------------
def ping(self):
return self._get("/dnf/gm/status")
def list_accounts(self, query="", limit=50):
qs = urlencode({"q": query, "limit": limit})
try:
return self._unwrap(self._get("/dnf/gm/accounts?" + qs)) or []
except NativeError:
return []
def get_account(self, account_id):
return self._unwrap(self._get("/dnf/gm/accounts/%s" % account_id))
def set_account_value(self, account_id, field, value):
"""POST /dnf/gm/accounts/{account_id}/value {field, value:uint64}"""
return self._unwrap(self._post(
"/dnf/gm/accounts/%s/value" % account_id,
{"field": field, "value": int(value)}))
def list_characters(self, account_id=None):
"""角色按账号枚举:/dnf/gm/accounts/{id}/characters。
无 account_id 时遍历所有账号。"""
account_ids = []
if account_id:
account_ids = [account_id]
else:
accs = self.list_accounts()
account_ids = [a.get("account_id") for a in accs
if isinstance(a, dict) and a.get("account_id")]
out = []
for aid in account_ids:
try:
raw = self._unwrap(self._get("/dnf/gm/accounts/%s/characters" % aid))
except NativeError:
continue
if not isinstance(raw, list):
raw = [raw] if raw else []
for c in raw:
if not isinstance(c, dict):
continue
cid = c.get("character_id") or c.get("id")
out.append({
"character_id": str(cid),
"account_id": c.get("account_id", aid),
"name": c.get("name", ""),
"job": c.get("job", "0"),
"level": c.get("level", 0),
"gold": c.get("gold", 0),
"profession_name": c.get("profession_name", ""),
"grow_type": c.get("grow_type", 0),
"first_grow": c.get("first_grow", 0),
"awakening": c.get("awakening", 0),
"online": c.get("online", False),
"raw": c,
})
return out
def get_character(self, character_id):
raw = self._unwrap(self._get("/dnf/gm/characters/%s" % character_id))
if not isinstance(raw, dict):
return raw
stats = {k: v for k, v in raw.items()
if k not in ("character_id", "account_id", "name", "job",
"level", "slot", "profession_name", "grow_type",
"first_grow", "awakening", "online", "gold", "cera")}
if "gold" in raw:
stats["gold"] = raw["gold"]
out = {
"character_id": str(raw.get("character_id") or character_id),
"account_id": raw.get("account_id", ""),
"name": raw.get("name", ""),
"job": raw.get("job", "0"),
"level": raw.get("level", 0),
"gold": raw.get("gold", 0),
"profession_name": raw.get("profession_name", ""),
"grow_type": raw.get("grow_type", 0),
"first_grow": raw.get("first_grow", 0),
"awakening": raw.get("awakening", 0),
"online": raw.get("online", False),
"stats": stats,
}
return out
def get_character_detail(self, character_id):
return self._unwrap(self._get("/dnf/gm/characters/%s/detail" % character_id))
def get_inventory(self, character_id):
return self._unwrap(self._get("/dnf/gm/characters/%s/inventory" % character_id))
def get_equipment(self, character_id):
return self._unwrap(self._get("/dnf/gm/characters/%s/equipment-slots" % character_id))
def get_mailbox(self, character_id):
return self._unwrap(self._get("/dnf/gm/characters/%s/mailbox" % character_id))
def get_account_cubes(self, character_id):
return self._unwrap(self._get("/dnf/gm/characters/%s/account-cubes" % character_id))
# ---- 物品目录 / 搜索 -----------------------------------------
def get_item_detail(self, item_id):
return self._unwrap(self._get("/dnf/gm/items/%s/detail" % item_id))
def get_item_mail_options(self, item_id, mode="", query="", rarity=-1, limit=40, offset=0):
qs = urlencode({"mode": mode, "q": query, "rarity": rarity,
"limit": limit, "offset": offset})
return self._unwrap(self._get("/dnf/gm/items/%s/mail-options?%s" % (item_id, qs)))
def get_item_categories(self):
return self._unwrap(self._get("/dnf/gm/items/categories"))
def search_items(self, query="", kind="", tag="", segment="", group="", job="",
min_level=0, max_level=0, rarity=-1, expiration="",
offset=0, limit=50):
"""GET /dnf/gm/items/page —— 完整筛选参数(与源码 GMItemQuery 一致)。
返回归一化结构 {items:[...], total, limit, offset}。"""
params = {
"q": query, "kind": kind, "tag": tag, "segment": segment,
"group": group, "job": job, "min_level": min_level,
"max_level": max_level, "rarity": rarity, "expiration": expiration,
"offset": offset, "limit": limit,
}
# 去掉空值参数,避免干扰
params = {k: v for k, v in params.items() if v not in ("", -1, 0) or k in ("offset", "limit", "q", "rarity")}
qs = urlencode(params)
raw = self._unwrap(self._get("/dnf/gm/items/page?" + qs))
if isinstance(raw, dict) and "items" in raw:
return raw
return {"items": raw if isinstance(raw, list) else [], "total": 0,
"limit": limit, "offset": offset}
def search_avatar_sets(self, query="", job="", limit=24, offset=0):
qs = urlencode({"q": query, "job": job, "limit": limit, "offset": offset})
return self._unwrap(self._get("/dnf/gm/avatar-sets?" + qs))
# ---- 物品发放 -------------------------------------------------
# 注意:服务端有两个发放端点:
# 1) POST /local/dnf-gm/item/grant —— 直接进背包(服务端决定格位)
# 请求体 {character_id:string, item_id:uint32, count:uint32}
# 2) POST /local/dnf-gm/item/grant-options —— 带强化/增幅/锻造/品质/耐久/附魔/徽章
# 3) POST /local/dnf-gm/item/grant-with-emblems —— 带附魔宝珠 + 徽章(装备/时装)
# 4) POST /dnf/gm/characters/{id}/mail —— 邮件发放(带完整选项)
def grant_item(self, character_id, item_id, count=1):
body = {"character_id": str(character_id),
"item_id": int(item_id),
"count": int(count)}
return self._unwrap(self._post("/local/dnf-gm/item/grant", body))
def grant_item_options(self, character_id, item_id, count=1, reinforce=0,
amplify_type=0, forging=0, equipment_quality="",
durability=None, expire_days=0, emblem_item_ids=None,
enchant_bead_item_id=0):
"""带强化/增幅/锻造/品质/耐久/附魔的发放。
校验范围(源码 gm_tool_items_extra.go):
reinforce 0..31, amplify_type 0..4, forging 0..8, expire_days 0..3650"""
body = {"character_id": str(character_id), "item_id": int(item_id),
"count": int(count)}
if reinforce:
body["reinforce"] = int(reinforce)
if amplify_type:
body["amplify_type"] = int(amplify_type)
if forging:
body["forging"] = int(forging)
if equipment_quality:
body["equipment_quality"] = equipment_quality
if durability is not None:
body["durability"] = int(durability)
if expire_days:
body["expire_days"] = int(expire_days)
if emblem_item_ids:
body["emblem_item_ids"] = [int(x) for x in emblem_item_ids]
if enchant_bead_item_id:
body["enchant_bead_item_id"] = int(enchant_bead_item_id)
return self._unwrap(self._post("/local/dnf-gm/item/grant-options", body))
def grant_item_with_emblems(self, character_id, item_id, enchant_bead_item_id=0,
enchant_upgrade_count=None, emblems=None, open_sockets=None):
"""带附魔宝珠 + 徽章发放(仅装备/时装)。
emblems: [{socket_index:int, emblem_item_id:int, emblem_slot:int}, ...]"""
body = {"character_id": str(character_id), "item_id": int(item_id)}
if enchant_bead_item_id:
body["enchant_bead_item_id"] = int(enchant_bead_item_id)
if enchant_upgrade_count is not None:
body["enchant_upgrade_count"] = int(enchant_upgrade_count)
if emblems:
body["emblems"] = emblems
if open_sockets is not None:
body["open_sockets"] = int(open_sockets)
return self._unwrap(self._post("/local/dnf-gm/item/grant-with-emblems", body))
def send_item_mail(self, character_id, item_id, count=1, title="", body="",
expire_days=0, reinforce=0, amplify_type=0,
equipment_quality="", forging=0, emblem_item_ids=None,
enchant_bead_item_id=0):
"""POST /dnf/gm/characters/{id}/mail —— 邮件发放(完整选项)。"""
req = {"item_id": int(item_id), "count": int(count)}
if title:
req["title"] = title
if body:
req["body"] = body
if expire_days:
req["expire_days"] = int(expire_days)
if reinforce:
req["reinforce"] = int(reinforce)
if amplify_type:
req["amplify_type"] = int(amplify_type)
if equipment_quality:
req["equipment_quality"] = equipment_quality
if forging:
req["forging"] = int(forging)
if emblem_item_ids:
req["emblem_item_ids"] = [int(x) for x in emblem_item_ids]
if enchant_bead_item_id:
req["enchant_bead_item_id"] = int(enchant_bead_item_id)
return self._unwrap(self._post(
"/dnf/gm/characters/%s/mail" % character_id, req))
def delete_item(self, character_id, collection="", list_type=None, slot=None,
entry_key=""):
"""POST /local/dnf-gm/item/delete —— 删除背包行或穿戴装备行。
collection: "slots"(背包,默认) 或 "wear"(穿戴装备)"""
req = {"character_id": str(character_id)}
if collection:
req["collection"] = collection
if entry_key:
req["entry_key"] = entry_key
else:
req["list_type"] = int(list_type)
req["slot"] = int(slot)
return self._unwrap(self._post("/local/dnf-gm/item/delete", req))
# ---- 属性修改 -------------------------------------------------
def set_gold(self, character_id, amount):
"""POST /dnf/gm/characters/{id}/gold {value:int64}"""
return self._unwrap(self._post(
"/dnf/gm/characters/%s/gold" % str(character_id), {"value": int(amount)}))
def set_level(self, character_id, level):
"""POST /dnf/gm/characters/{id}/level {level:int}"""
return self._unwrap(self._post(
"/dnf/gm/characters/%s/level" % str(character_id), {"level": int(level)}))
def set_skill_bonus(self, character_id, bonus_sp, bonus_tp):
"""POST /dnf/gm/characters/{id}/skill-points {bonus_sp, bonus_tp:int64}"""
return self._unwrap(self._post(
"/dnf/gm/characters/%s/skill-points" % str(character_id),
{"bonus_sp": int(bonus_sp), "bonus_tp": int(bonus_tp)}))
def get_career(self, character_id):
return self._unwrap(self._get("/dnf/gm/characters/%s/career" % character_id))
def set_career(self, character_id, job, first, awakening):
"""POST /dnf/gm/characters/{id}/career {job:int, first:int, awakening:int}
这是修改职业/转职的正确接口(旧 /local/dnf-gm/character/job 语义不同)。"""
return self._unwrap(self._post(
"/dnf/gm/characters/%s/career" % str(character_id),
{"job": int(job), "first": int(first), "awakening": int(awakening)}))
def set_cera(self, account_id, amount=None, value=None, currency=""):
"""POST /local/dnf-gm/account/cera
{account_id, currency, amount(增量) 或 value(覆写)}
currency: cera / token_cera / happy_token_cera,默认 cera"""
req = {"account_id": str(account_id)}
if currency:
req["currency"] = currency
if amount is not None:
req["amount"] = int(amount)
elif value is not None:
req["value"] = int(value)
return self._unwrap(self._post("/local/dnf-gm/account/cera", req))
def adjust_revive_coin(self, character_id, amount=None, value=None, source=""):
"""POST /local/dnf-gm/character/revive-coin
amount(增量) 或 value(覆写),二者互斥"""
req = {"character_id": str(character_id)}
if amount is not None:
req["amount"] = int(amount)
elif value is not None:
req["value"] = int(value)
if source:
req["source"] = source
return self._unwrap(self._post("/local/dnf-gm/character/revive-coin", req))
def query_wallet(self, character_id="", character_name="", currency=""):
"""POST /local/dnf-gm/wallet/query"""
req = {}
if character_id:
req["character_id"] = str(character_id)
if character_name:
req["character_name"] = character_name
if currency:
req["currency"] = currency
return self._unwrap(self._post("/local/dnf-gm/wallet/query", req))
def update_wallet(self, character_id="", character_name="", currency="",
mode="", amount=0):
"""POST /local/dnf-gm/wallet/update
currency + mode + amount;mode 语义见源码 gm_admin_state.go"""
req = {}
if character_id:
req["character_id"] = str(character_id)
if character_name:
req["character_name"] = character_name
if currency:
req["currency"] = currency
if mode:
req["mode"] = mode
if amount:
req["amount"] = int(amount)
return self._unwrap(self._post("/local/dnf-gm/wallet/update", req))
# ---- 背包 / 装备 ---------------------------------------------
def inventory_list(self, character_id):
"""GET /local/dnf-gm/inventory/list —— 列角色背包全部行 + 穿戴装备行。
返回 {"character_id":..., "rows":[{collection,entry_key,item_id,count,bind,item_name},...]}"""
return self._unwrap(self._get(
"/local/dnf-gm/inventory/list?character_id=%s" % character_id))
def delete_inventory_item(self, character_id, container, key, count):
"""POST /dnf/gm/characters/{id}/inventory/delete {container, key, count}"""
return self._unwrap(self._post(
"/dnf/gm/characters/%s/inventory/delete" % str(character_id),
{"container": container, "key": key, "count": int(count)}))
def clear_inventory(self, character_id, container="", keep_wallet=True, collection=""):
"""POST /local/dnf-gm/inventory/clear {character_id, collection, keep_wallet}
工具端版本会保留主背包 0:0~0:2 的固定钱包槽(金币/复活币/点券载体)。"""
req = {"character_id": str(character_id)}
if collection:
req["collection"] = collection
req["keep_wallet"] = bool(keep_wallet)
return self._unwrap(self._post("/local/dnf-gm/inventory/clear", req))
def unequip_equipment(self, character_id, key):
"""POST /dnf/gm/characters/{id}/equipment/unequip {key}"""
return self._unwrap(self._post(
"/dnf/gm/characters/%s/equipment/unequip" % str(character_id), {"key": key}))
def delete_equipment(self, character_id, key):
"""POST /dnf/gm/characters/{id}/equipment/delete {key}"""
return self._unwrap(self._post(
"/dnf/gm/characters/%s/equipment/delete" % str(character_id), {"key": key}))
def unlock_equip_slots(self, character_id, slots):
"""POST /dnf/gm/characters/{id}/equipment-slots/unlock {slots:[]string}"""
return self._unwrap(self._post(
"/dnf/gm/characters/%s/equipment-slots/unlock" % str(character_id),
{"slots": slots}))
def set_account_cube(self, character_id, selection, count):
"""POST /dnf/gm/characters/{id}/account-cubes {selection:int, count:int64}"""
return self._unwrap(self._post(
"/dnf/gm/characters/%s/account-cubes" % str(character_id),
{"selection": int(selection), "count": int(count)}))
# ---- 邮件 -----------------------------------------------------
def send_system_mail(self, character_id="", character_name="", title="",
body="", gold=0, attachments=None):
"""POST /local/dnf-gm/mail {character_id/name, title, body, gold, attachments}
attachments: [{item_id, count, bind, reinforce_level, amplify_level, amplify_type}]"""
req = {}
if character_id:
req["character_id"] = str(character_id)
if character_name:
req["character_name"] = character_name
if title:
req["title"] = title
if body:
req["body"] = body
if gold:
req["gold"] = int(gold)
if attachments:
req["attachments"] = attachments
return self._unwrap(self._post("/local/dnf-gm/mail", req))
def clear_mailbox(self, character_id="", character_name="", confirm=True):
"""POST /local/dnf-gm/mail/clear {character_id/name, confirm}"""
req = {"confirm": bool(confirm)}
if character_id:
req["character_id"] = str(character_id)
if character_name:
req["character_name"] = character_name
return self._unwrap(self._post("/local/dnf-gm/mail/clear", req))
def delete_mailbox_mails(self, character_id, mail_ids, force=False):
"""POST /dnf/gm/characters/{id}/mailbox/delete {mail_ids, force}"""
return self._unwrap(self._post(
"/dnf/gm/characters/%s/mailbox/delete" % str(character_id),
{"mail_ids": mail_ids, "force": bool(force)}))
# ---- 拍卖行(服务端 GM 接口,线上上架/下架)------------------
def auction_listings(self):
"""GET /local/dnf-gm/auction/listings —— 列出所有挂单(含过期)。
返回 {"listings":[...], "escrow_gold":int}。"""
return self._unwrap(self._get("/local/dnf-gm/auction/listings"))
def auction_list(self, seller_id, item_id, count, unit_price,
start_price=None, buyout_price=None, duration_hours=24,
grant_if_missing=False):
"""POST /local/dnf-gm/auction/list —— 上架。
服务端 GMAuctionListRequest 字段:
seller_id / seller_name / item_id / count / unit_price /
start_price(每件起拍,-1=不设) / buyout_price(每件一口价,-1=不设) /
duration_hours(24 或 48) / grant_if_missing(背包不足自动发放差额)"""
req = {
"seller_id": str(seller_id),
"item_id": int(item_id),
"count": int(count),
"unit_price": int(unit_price),
}
# start/buyout:None 或空 -> -1(服务端约定 -1 表示不设)
req["start_price"] = int(start_price) if start_price not in (None, "") else -1
req["buyout_price"] = int(buyout_price) if buyout_price not in (None, "") else -1
req["duration_hours"] = int(duration_hours) if duration_hours else 24
if grant_if_missing:
req["grant_if_missing"] = True
return self._unwrap(self._post("/local/dnf-gm/auction/list", req))
def auction_cancel(self, listing_id):
"""POST /local/dnf-gm/auction/cancel —— 下架。
GMAuctionCancelRequest: {listing_id:uint64}。"""
return self._unwrap(self._post(
"/local/dnf-gm/auction/cancel", {"listing_id": int(listing_id)}))
# ---- 任务 -----------------------------------------------------
def get_quest_overview(self, character_id):
return self._unwrap(self._get("/dnf/gm/characters/%s/quests" % character_id))
def search_quests(self, character_id, query, limit=40):
qs = urlencode({"q": query, "limit": limit})
return self._unwrap(self._get(
"/dnf/gm/characters/%s/quests/search?%s" % (character_id, qs)))
def get_quest_detail(self, character_id, quest_id):
return self._unwrap(self._get(
"/dnf/gm/characters/%s/quests/%s/detail" % (character_id, quest_id)))
def _quest_mutate(self, character_id, quest_id, action):
return self._unwrap(self._post(
"/dnf/gm/characters/%s/quests/%s/%s" % (character_id, quest_id, action), {}))
def accept_quest(self, character_id, quest_id):
return self._quest_mutate(character_id, quest_id, "accept")
def mark_quest_ready(self, character_id, quest_id):
return self._quest_mutate(character_id, quest_id, "ready")
def give_up_quest(self, character_id, quest_id):
return self._quest_mutate(character_id, quest_id, "give-up")
def complete_quest(self, character_id, quest_id):
return self._quest_mutate(character_id, quest_id, "complete")
def uncomplete_quest(self, character_id, quest_id):
return self._quest_mutate(character_id, quest_id, "uncomplete")
def complete_quest_batch(self, character_id, quest_ids):
return self._unwrap(self._post(
"/dnf/gm/characters/%s/quests/complete-batch" % character_id,
{"quest_ids": [int(x) for x in quest_ids]}))
# ---- 副本 -----------------------------------------------------
def get_dungeons(self, character_id):
return self._unwrap(self._get("/dnf/gm/characters/%s/dungeons" % character_id))
def unlock_dungeon_difficulties(self, character_id):
return self._unwrap(self._post(
"/dnf/gm/characters/%s/dungeons/unlock-difficulties" % character_id, {}))
def unlock_hell_challenges(self, character_id):
return self._unwrap(self._post(
"/dnf/gm/characters/%s/dungeons/unlock-hell" % character_id, {}))
# ---- 黑钻 -----------------------------------------------------
def get_black_diamond(self, character_id="", character_name=""):
qs = urlencode({"id": character_id, "name": character_name})
return self._unwrap(self._get("/local/dnf-gm/character/black-diamond?" + qs))
def activate_black_diamond(self, character_id, days=0):
req = {"character_id": str(character_id)}
if days:
req["days"] = int(days)
return self._unwrap(self._post("/local/dnf-gm/character/black-diamond", req))
# ---- 运营参数 / 爆率(Native,服务端在线) ----------------------
def get_gameplay_settings(self):
"""GET /dnf/gm/gameplay -> GMGameplaySettings(深渊史诗掉率等运营参数)"""
return self._unwrap(self._get("/dnf/gm/gameplay"))
def set_hell_drop_rate(self, percent):
"""POST /dnf/gm/gameplay/hell-drop-rate {percent:int}(深渊史诗掉落率百分比)"""
return self._unwrap(self._post("/dnf/gm/gameplay/hell-drop-rate",
{"percent": int(percent)}))
def set_epic_potion_multiplier(self, percent):
"""POST /dnf/gm/gameplay/epic-potion-test-multiplier {percent:int}
城镇增益药剂测试倍率,0 = 关闭按 PVF 表走。"""
return self._unwrap(self._post(
"/dnf/gm/gameplay/epic-potion-test-multiplier",
{"percent": int(percent)}))
def set_fatigue_mode(self, mode):
"""POST /dnf/gm/gameplay/fatigue-mode {mode:str}
mode: none(正常)/ free(无限不扣)/ lock(锁死高值)/ refill(一键回满)。"""
return self._unwrap(self._post("/dnf/gm/gameplay/fatigue-mode",
{"mode": str(mode)}))
def set_burning(self, enabled):
"""POST /dnf/gm/gameplay/burning {enabled:bool}
燃烧疲劳:双倍消耗 + 通关经验翻倍。"""
return self._unwrap(self._post("/dnf/gm/gameplay/burning",
{"enabled": bool(enabled)}))
def refill_fatigue(self, character_id):
"""POST /dnf/gm/characters/{id}/fatigue/refill 一键回满角色疲劳。"""
return self._unwrap(self._post(
"/dnf/gm/characters/%s/fatigue/refill" % str(character_id), {}))
# ---- 宠物 -----------------------------------------------------
def update_pet(self, character_id, pet_key="", level=None, exp=None,
satiety=None, clear_satiety=False, pet_index=None):
req = {"character_id": str(character_id)}
if pet_key:
req["pet_key"] = pet_key
if level is not None:
req["level"] = int(level)
if exp is not None:
req["exp"] = int(exp)
if satiety is not None:
req["satiety"] = int(satiety)
if clear_satiety:
req["clear_satiety"] = True
if pet_index is not None:
req["pet_index"] = int(pet_index)
return self._unwrap(self._post("/local/dnf-gm/pet/update", req))
# ---- 启动器工具扩展(换装 / 服务端工具 / 支援兵)----------------
def loadout_characters(self):
return self._unwrap(self._get("/local/dnf-gm/characters")) or []
def loadout_equipment(self, character_id):
qs = urlencode({"character_id": str(character_id)})
return self._unwrap(self._get("/local/dnf-gm/loadout/equipment?" + qs))
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))
+2394
View File
File diff suppressed because it is too large. Load diff
+438
View File
@@ -0,0 +1,438 @@
/* 95JP 启动器 · 单窗口整合版 */
:root{
--bg:#08110f; --bg2:#0d1a16; --panel:#0f211b; --panel2:#132a22;
--line:#1d3b31; --text:#dbe9e1; --muted:#7f9c90;
--green:#7fe3b0; --green2:#45c98e; --dim:#4e7b68;
--danger:#e07a6a; --gold:#e6c36a;
}
html,body{height:100%;margin:0;background:var(--bg);color:var(--text);
font:14px/1.5 "Microsoft YaHei","Segoe UI",sans-serif;overflow:hidden}
*{box-sizing:border-box}
button,input,select{font:inherit}
.hidden{display:none!important}
#launcherView{display:none!important}
#appShell{display:flex;flex-direction:column;height:calc(100vh - 30px);background:var(--bg)}
/* top nav(横向标签页导航,替代原左侧竖排侧栏) */
.shell-topnav{flex:none;height:56px;display:flex;align-items:center;gap:18px;
background:#0a1512;border-bottom:1px solid #14291f;padding:0 16px}
.shell-brand{height:100%;display:flex;align-items:center;gap:10px;padding:0 4px;border-bottom:0;flex:none}
.shell-logo{width:30px;height:30px;border:1px solid var(--green2);color:var(--green);
display:grid;place-items:center;font-weight:700;font-size:12px;border-radius:2px}
.shell-brand b{font-size:15px;letter-spacing:.5px;white-space:nowrap}
.shell-brand span{display:block;font-size:10px;color:var(--muted)}
.shell-brand em{font-style:normal;color:var(--dim);margin-left:4px}
.shell-nav{display:flex;flex-direction:row;align-items:center;flex:1;gap:2px;padding:0}
.shell-nav-item{appearance:none;background:transparent;border:0;color:#a9c1b6;text-align:left;
padding:8px 14px;border-left:0;border-bottom:2px solid transparent;cursor:pointer;border-radius:3px 3px 0 0;
white-space:nowrap}
.shell-nav-item:hover{background:#10241d;color:var(--text)}
.shell-nav-item.active{background:#12352a;color:var(--green);border-bottom-color:var(--green2)}
.shell-nav-gap{width:1px;height:22px;border-top:0;border-left:1px solid #14291f;margin:0 6px;flex:none}
.shell-side-foot{flex:none;padding:0 4px;color:#4b6b5c;font-size:11px}
/* main */
.shell-main{flex:1;min-width:0;overflow:auto;background:
radial-gradient(900px 400px at 70% -10%,#123329 0,transparent 60%),var(--bg)}
.shell-topbar{height:42px;display:flex;align-items:center;justify-content:space-between;
padding:0 18px;border-bottom:1px solid #14291f;color:var(--muted);font-size:12px;
cursor:move;user-select:none}
.shell-topbar button,.shell-topbar select,.shell-topbar input{cursor:pointer}
.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:10px;margin-top:14px}
.settings-card{border:1px solid #1d3a2d;background:#0b1712;padding:12px 14px;border-radius:4px}
.settings-label{font-size:11px;color:#83a393;margin-bottom:5px}
.settings-value{font:11px/1.6 Consolas,"Microsoft YaHei",monospace;color:#d5e8de;word-break:break-all}
.shell-top-actions{display:flex;gap:8px;align-items:center}
.shell-top-actions select{background:#0c1b16;border:1px solid #25483a;color:#bfe4d2;padding:4px 8px;border-radius:2px}
.tb-btn{width:28px;height:24px;border:1px solid #25483a;background:#0c1b16;color:#a8c7b6;cursor:pointer}
.tb-close:hover,.tb-btn:hover{background:#1a3a2c;color:#fff}
.shell-page{display:none;padding:0 20px 20px}
.shell-page.active{display:block}
.page-pad{padding-top:18px}
.page-pad h2{margin:0 0 4px;font-size:20px;font-weight:500}
.page-heading{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:14px}
.muted{color:var(--muted)}
.small{font-size:11px!important;font-weight:400!important}
.green{color:var(--green)!important}
.tag{border:1px solid #2c5a46;color:var(--green);padding:2px 8px;font-size:11px;border-radius:2px}
.empty-box{border:1px dashed #244638;color:#5f8272;padding:26px;text-align:center;margin-top:14px;background:#0b1813}
/* hero */
.hero{position:relative;margin:16px 0 12px;min-height:252px;border:1px solid #1e3d30;
overflow:hidden;background:#0b1914}
.hero-bg{position:absolute;inset:0;background:
linear-gradient(90deg,#07110e 0%,rgba(7,17,14,.94) 32%,rgba(10,28,21,.55) 62%,rgba(8,18,14,.9)),
repeating-linear-gradient(115deg,rgba(80,180,130,.05) 0 1px,transparent 1px 26px),
radial-gradient(500px 300px at 82% 35%,#245a43 0,#0a1914 65%);
filter:saturate(.85)}
.hero-bg:after{content:"";position:absolute;right:3%;top:-20%;width:42%;height:130%;
background:radial-gradient(ellipse at 50% 40%,rgba(140,210,175,.12),transparent 65%);
transform:rotate(-8deg)}
.hero-content{position:relative;padding:26px 30px}
.hero-kicker{font-size:11px;color:#769887;letter-spacing:.4px}
.hero h1{margin:4px 0 2px;font-size:34px;font-weight:400;color:#e5f2ea;letter-spacing:.4px}
.hero-status{display:flex;align-items:center;gap:10px;color:#b7d2c4;font-size:12px}
.live-dot{width:7px;height:7px;border-radius:50%;background:#3dd68b;display:inline-block;
box-shadow:0 0 10px #3dd68b}
.hero-actions{display:flex;align-items:stretch;gap:0;margin:16px 0 8px}
.start-btn{width:225px;height:46px;background:linear-gradient(180deg,#bff3d8,#8ee5b9);
border:1px solid #c9ffe2;color:#0b2a1c;font-size:17px;font-weight:600;cursor:pointer}
.start-btn:hover{filter:brightness(1.06)}
.start-menu{width:34px;height:46px;background:#123126;border:1px solid #3f7b5c;border-left:0;
color:#a9d9c0;cursor:pointer;font-size:11px}
.account-login{height:46px;margin-left:12px;padding:0 16px;display:flex;align-items:center;gap:8px;
border:1px solid rgba(255,255,255,.16);border-radius:9px;background:rgba(255,255,255,.05);
color:#e4e6f5;cursor:pointer;text-align:left}
.account-login:hover{background:rgba(124,92,255,.18);border-color:rgba(124,92,255,.5)}
.account-login .account-ico{font-size:18px}
.account-login b{display:block;font-size:13px;font-weight:500;line-height:1.2}
.account-login small{display:block;font-size:10px;color:#9a9eb5;line-height:1.4;max-width:120px;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.account-picker{height:46px;margin-left:8px;min-width:160px;padding:4px 10px;
border:1px solid rgba(255,255,255,.16);border-radius:9px;background:rgba(255,255,255,.05);
display:flex;flex-direction:column;justify-content:center}
.account-picker-label{font-size:10px;color:#8d91a8;line-height:1.2}
.account-picker select{border:0;outline:0;background:transparent;color:#eef0fb;font-size:12px;
width:100%;cursor:pointer;appearance:none;padding:0 12px 0 0;
background-image:linear-gradient(45deg,transparent 50%,#a9a2d8 50%),linear-gradient(135deg,#a9a2d8 50%,transparent 50%);
background-position:calc(100% - 5px) 55%,calc(100% - 1px) 55%;background-size:4px 4px;background-repeat:no-repeat}
.account-picker select option{background:#141424;color:#eef0fb}
.stop-server-btn{height:46px;margin-left:10px;padding:0 16px;border:1px solid rgba(255,93,108,.45);
border-radius:9px;background:rgba(255,93,108,.08);color:#ffb8bf;cursor:pointer;font-size:13px}
.stop-server-btn:hover{background:rgba(255,93,108,.2);color:#fff}
.stop-server-btn span{margin-right:5px}
.start-menu-pop{position:absolute;z-index:20;margin-top:50px;background:#10241c;border:1px solid #315f49;
min-width:190px;box-shadow:0 8px 20px #0009}
.start-menu-pop button{display:block;width:100%;text-align:left;background:none;border:0;color:#c9e3d5;
padding:9px 12px;cursor:pointer;font-size:12px}
.start-menu-pop button:hover{background:#1a3b2d;color:#fff}
.hero-hint{font-size:11px;color:#759485}
.hero-hint a{color:#a8d9c2;text-decoration:underline}
.hero-clock{position:absolute;right:26px;bottom:18px;color:#70917f;font-size:11px}
/* stats */
.stat-grid{display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid #1b3529;
border-left:1px solid #1b3529;margin-top:12px}
.stat-card{border-right:1px solid #1b3529;border-bottom:1px solid #1b3529;padding:10px 16px 12px;background:#0b1712}
.stat-label{font-size:11px;color:#789786;margin-bottom:6px}
.stat-row{display:flex;justify-content:space-between;gap:8px;font-size:11px;color:#88a696;line-height:1.9}
.stat-row b{font-weight:400;color:#d1e6dc;max-width:64%;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.section-head{display:flex;align-items:baseline;justify-content:space-between;margin:18px 0 6px}
.section-head h2{font-size:13px;font-weight:400;color:#a8c8b8;margin:0}
.section-head span{font-size:11px;color:#607f70}
.changelog{border-top:1px solid #1b3529}
.change-row{display:flex;gap:14px;align-items:center;padding:8px 2px;border-bottom:1px solid #152a22;font-size:11px;color:#8eada0}
.change-row span{color:#4c9c76;width:72px;flex:none;font-family:Consolas,monospace}
.change-row b{font-weight:400;color:#bdd5c8;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.change-row em{font-style:normal;color:#5e7e6f;width:70px;text-align:right}
/* buttons/input */
.shell-btn{background:#11271e;border:1px solid #2b5542;color:#b8d8c8;padding:6px 12px;cursor:pointer;border-radius:2px}
.shell-btn:hover{background:#19392b;color:#fff}
.shell-btn.primary{background:#1d5c43;border-color:#3d9970;color:#e3fff1}
.shell-input{background:#0a1712;border:1px solid #274b3a;color:#d6e9df;padding:6px 8px;width:100%;border-radius:2px}
.shell-input:focus{outline:1px solid #4d9f77;border-color:#4d9f77}
/* loadout */
.loadout-layout{display:grid;grid-template-columns:300px 1fr;gap:14px}
.loadout-side,.loadout-main{border:1px solid #1d3a2d;background:#0b1712;padding:10px}
.lo-pages{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:8px}
.lo-page{padding:4px 9px;border:1px solid #284c3b;color:#9bbdac;cursor:pointer;font-size:11px}
.lo-page.active{background:#1b4936;color:#d5f6e4;border-color:#4e9e77}
.lo-list{min-height:210px;max-height:330px;overflow:auto;border-top:1px solid #1b3328}
.lo-item{padding:8px;border-bottom:1px solid #172c23;cursor:pointer;color:#a9c7b7;font-size:12px}
.lo-item:hover,.lo-item.active{background:#143225;color:#e1f7ea}
.lo-side-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
.lo-form{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px}
.lo-form label{font-size:11px;color:#83a393;display:flex;flex-direction:column;gap:4px}
.lo-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;border-top:1px solid #1b3328;padding-top:9px}
.lo-slots{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;margin-top:10px;max-height:320px;overflow:auto}
.lo-slot{border:1px solid #1d3a2d;background:#0e2019;padding:6px;font-size:11px;color:#9ebdac}
.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}
#page-gm.active{height:100%}
#page-gm #gmView{height:100%;display:flex;background:#0b0b14;border:0}
#page-gm .gm-sidebar{width:180px}
#page-gm .gm-main{flex:1;min-width:0}
#page-gm .gm-content{height:calc(100% - 58px);overflow:auto}
/* statusbar */
.shell-statusbar{height:30px;display:flex;align-items:center;gap:18px;padding:0 14px;
background:#07110e;border-top:1px solid #183025;color:#739283;font-size:11px}
.shell-statusbar b{color:#a9cbb9;font-weight:400}
.shell-statusbar .grow{flex:1}
.shell-statusbar .live-dot{width:6px;height:6px}
@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)}
}
/* ================= 沿用原启动器配色与图标 ================= */
:root{
--bg:#0b0b14; --bg2:#10101f; --panel:rgba(20,20,36,.82); --panel2:#141424;
--line:rgba(255,255,255,.08); --text:#f2f3f8; --muted:#6d7188;
--green:#35d07f; --green2:#7c5cff; --dim:#8d91a8;
--danger:#ff5d6c; --gold:#ffc44d;
--primary:#7c5cff; --primary2:#b45cff;
}
#appShell{background:radial-gradient(1200px 700px at 20% -10%,rgba(124,92,255,.22),transparent 55%),
radial-gradient(1000px 650px at 100% 0%,rgba(56,217,240,.12),transparent 50%),var(--bg)}
.shell-topnav{background:rgba(12,12,24,.92);border-bottom:1px solid rgba(255,255,255,.08)}
.shell-brand{border-bottom:0}
.shell-logo{border-color:rgba(124,92,255,.55);color:#c9beff;background:linear-gradient(135deg,rgba(124,92,255,.25),rgba(180,92,255,.12))}
.shell-brand b{color:#f2f3f8}
.shell-brand span,.shell-brand em{color:#8d91a8}
.shell-nav-item{color:#b8bcd0;border-bottom-color:transparent;border-radius:4px 4px 0 0;margin:0 2px;padding:8px 13px}
.shell-nav-item:hover{background:rgba(255,255,255,.07);color:#fff}
.shell-nav-item.active{background:rgba(124,92,255,.18);color:#e8e2ff;border-bottom-color:var(--primary)}
.nav-ico{display:inline-block;width:22px;color:#9c8cff;font-size:13px;text-align:center}
.shell-nav-item.active .nav-ico{color:#c9beff}
.shell-nav-gap{border-left-color:rgba(255,255,255,.08)}
.shell-side-foot{color:#5d6178}
.shell-main{background:radial-gradient(900px 400px at 70% -10%,rgba(124,92,255,.18),transparent 60%),var(--bg)}
.shell-topbar{border-bottom-color:rgba(255,255,255,.08);color:#8d91a8}
.shell-top-actions select{background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.12);color:#dcdff0;border-radius:8px}
.tb-btn{border:0;border-radius:8px;background:rgba(255,255,255,.07);color:#b8bcd0}
.tb-btn:hover{background:rgba(255,255,255,.16);color:#fff}
.tb-close:hover{background:var(--danger);color:#fff}
.page-pad h2,.hero h1{color:#f2f3f8}
.hero{border-color:rgba(255,255,255,.09);background:#11111f}
.hero-bg{background:
linear-gradient(90deg,#0b0b14 0%,rgba(11,11,20,.92) 32%,rgba(20,20,44,.45) 62%,rgba(11,11,20,.92)),
radial-gradient(520px 320px at 82% 35%,rgba(124,92,255,.38),transparent 65%),
radial-gradient(420px 260px at 65% 80%,rgba(56,217,240,.16),transparent 70%),
repeating-linear-gradient(115deg,rgba(124,92,255,.05) 0 1px,transparent 1px 26px)}
.hero-bg:after{background:radial-gradient(ellipse at 50% 40%,rgba(180,92,255,.18),transparent 65%)}
.hero-kicker{color:#8d91a8}
.hero-status{color:#c4c8dc}
.hero-status .muted{color:#8d91a8}
.live-dot{background:var(--green);box-shadow:0 0 10px var(--green)}
.start-btn{background:linear-gradient(135deg,var(--primary),var(--primary2));border:1px solid rgba(180,160,255,.55);color:#fff;box-shadow:0 6px 20px rgba(124,92,255,.28);display:flex;align-items:center;justify-content:center;gap:8px}
.start-btn:hover{filter:brightness(1.1)}
.start-menu{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.14);border-left:0;color:#c9c4e8;border-radius:0 9px 9px 0}
.hero-actions .start-btn{border-radius:9px 0 0 9px}
.start-menu-pop{background:#141424;border-color:rgba(255,255,255,.14);border-radius:10px}
.start-menu-pop button{color:#c4c8dc}
.start-menu-pop button:hover{background:rgba(124,92,255,.2);color:#fff}
.hero-hint{color:#8d91a8}.hero-hint a{color:#b9aaff}
.hero-clock{color:#7d8198}
.stat-grid{border-color:rgba(255,255,255,.08)}
.stat-card{border-color:rgba(255,255,255,.08);background:rgba(20,20,36,.55)}
.stat-label{color:#8d91a8}.stat-row{color:#9a9eb5}.stat-row b{color:#e2e4f2}
.green{color:var(--green)!important}
.section-head h2{color:#c9cbe0}.section-head span{color:#6d7188}
.changelog{border-top-color:rgba(255,255,255,.08)}
.change-row{border-bottom-color:rgba(255,255,255,.06);color:#9a9eb5}
.change-row span{color:#a897ff}.change-row b{color:#d4d6e6}.change-row em{color:#6d7188}
.shell-btn{background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.13);color:#d0d2e4;border-radius:8px}
.shell-btn:hover{background:rgba(255,255,255,.1);color:#fff;border-color:rgba(255,255,255,.22)}
.shell-btn.primary{background:linear-gradient(135deg,var(--primary),var(--primary2));border-color:rgba(180,160,255,.45);color:#fff}
.shell-input{background:rgba(0,0,0,.22);border-color:rgba(255,255,255,.13);color:#eef0fb;border-radius:8px}
.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}
.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)}
.lo-item{border-bottom-color:rgba(255,255,255,.06);color:#b8bcd0}
.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}
.shell-brand{height:100%;padding:0 4px;gap:12px}
.shell-logo{width:36px;height:36px;font-size:14px}
.shell-brand b{font-size:17px}
.shell-brand span{font-size:12px}
.shell-nav{padding:0;gap:4px}
.shell-nav-item{font-size:15px;padding:12px 15px;margin:0 3px}
.nav-ico{width:24px;font-size:15px}
.shell-nav-gap{margin:0 6px}
.shell-side-foot{font-size:13px;padding:0 4px}
.shell-topbar{position:sticky;top:0;z-index:300;flex:none;height:50px;font-size:14px;padding:0 22px;
background:rgba(10,10,24,.94);backdrop-filter:blur(12px);
box-shadow:0 1px 0 rgba(255,255,255,.06),0 8px 18px rgba(0,0,0,.18)}
.tb-btn{width:34px;height:30px;font-size:16px;border-radius:9px}
.shell-top-actions{gap:10px}
.hero{min-height:292px;margin:20px 0 16px}
.hero-content{padding:30px 36px}
.hero-kicker{font-size:13px}
.hero h1{font-size:40px;margin:6px 0 4px}
.hero-status{font-size:14px;gap:12px}
.hero-actions{gap:0;margin:22px 0 12px}
.start-btn{width:250px;height:52px;font-size:19px;border-radius:10px}
.start-menu{width:40px;height:52px;font-size:14px}
.account-login{height:52px;margin-left:14px;padding:0 18px;border-radius:10px}
.account-login .account-ico{font-size:21px}
.account-login b{font-size:15px}
.account-login small{font-size:12px;max-width:140px}
.account-picker{height:52px;margin-left:10px;min-width:180px;padding:6px 12px;border-radius:10px}
.account-picker-label{font-size:12px}
.account-picker select{font-size:14px}
.stop-server-btn{height:52px;margin-left:12px;padding:0 18px;font-size:15px;border-radius:10px}
.hero-hint{font-size:13px;margin-top:10px}
.hero-clock{font-size:13px;right:32px;bottom:22px}
.start-menu-pop{min-width:220px;margin-top:56px}
.start-menu-pop button{font-size:14px;padding:11px 14px}
.stat-grid{margin-top:16px}
.stat-card{padding:14px 18px 16px}
.stat-label{font-size:13px;margin-bottom:9px}
.stat-row{font-size:13px;line-height:2.15;gap:12px}
.stat-row b{font-size:13px}
.section-head h2{font-size:15px}
.section-head span{font-size:13px}
.change-row{font-size:13px}
.page-pad h2{font-size:22px}
.page-heading{margin-bottom:16px}
.shell-btn{font-size:14px;padding:8px 15px;border-radius:8px}
.shell-input{font-size:14px;padding:8px 10px}
.muted{font-size:13px}
.loadout-layout{gap:18px}
.loadout-side,.loadout-main{padding:14px}
.lo-pages{gap:8px;margin-bottom:12px}
.lo-page{font-size:13px;padding:6px 11px}
.lo-list{min-height:240px}
.lo-item{font-size:14px;padding:11px 9px}
.lo-side-actions{gap:8px;margin-top:14px}
.lo-form{gap:12px;margin-bottom:14px}
.lo-form label{font-size:13px;gap:6px}
.lo-toolbar{gap:10px;padding-top:12px}
.lo-cooldown{font-size:12px;color:#f0c674;white-space:nowrap}
.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}
.settings-label{font-size:13px}
.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}
.win-resize.n{left:8px;right:8px;top:0;height:5px;cursor:n-resize}
.win-resize.s{left:8px;right:8px;bottom:0;height:5px;cursor:s-resize}
.win-resize.w{top:8px;bottom:8px;left:0;width:5px;cursor:w-resize}
.win-resize.e{top:8px;bottom:8px;right:0;width:5px;cursor:e-resize}
.win-resize.nw{left:0;top:0;width:9px;height:9px;cursor:nw-resize}
.win-resize.ne{right:0;top:0;width:9px;height:9px;cursor:ne-resize}
.win-resize.sw{left:0;bottom:0;width:9px;height:9px;cursor:sw-resize}
.win-resize.se{right:0;bottom:0;width:9px;height:9px;cursor:se-resize}
/* ================= GM 全屏模式(点击 GM 标签覆盖整个窗口) ================= */
body.gm-fullscreen #appShell{height:100vh}
body.gm-fullscreen .shell-topnav{display:none}
body.gm-fullscreen .shell-topbar{display:none}
body.gm-fullscreen .shell-statusbar{display:none}
body.gm-fullscreen #page-gm #gmView{height:100%}
body.gm-fullscreen .shell-main{overflow:hidden}
/* GM 全屏下隐藏窗口缩放热区(铺满工作区时不可再拖边) */
body.gm-fullscreen .win-resize{display:none}
/* GM 顶部栏内的窗口按钮:仅在全屏模式(顶部导航隐藏)时显示 */
.gm-win-btn{display:none;width:28px;height:24px;border:1px solid var(--border,#25483a);
background:transparent;color:#a8c7b6;cursor:pointer;font-size:14px;line-height:1;
border-radius:3px;flex-shrink:0}
.gm-win-btn:hover{background:#1a3a2c;color:#fff}
.gm-win-close:hover{background:#7a2a33;border-color:#a33a44;color:#fff}
body.gm-fullscreen .gm-win-btn{display:inline-block}
/* ================= 标题栏右侧窗口控制按钮(最小化/关闭,无边框线性图标) ================= */
.shell-topnav{cursor:move;user-select:none;padding:0 6px 0 16px}
.shell-topnav button,.shell-topnav .shell-side-foot{cursor:default}
.shell-win-controls{flex:none;display:flex;align-items:center;gap:2px;margin-left:auto}
.shell-win-controls .tb-btn{
width:42px;height:100%;border:0;background:transparent;color:#d6d9ea;
font-size:16px;line-height:1;border-radius:0;cursor:pointer;padding:0;
display:flex;align-items:center;justify-content:center;flex-shrink:0}
.shell-win-controls .tb-btn:hover{background:rgba(255,255,255,.1);color:#fff}
.shell-win-controls .tb-close:hover{background:var(--danger);color:#fff}
+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);
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+838
View File
@@ -0,0 +1,838 @@
/* DNF90 单机模拟器 启动器 · 精美深色主题 */
:root {
--bg: #0b0b14;
--panel: rgba(20, 20, 36, 0.82);
--panel-solid: #141424;
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.16);
--text: #f2f3f8;
--text-dim: #b8bcd0;
--text-muted: #6d7188;
--primary: #7c5cff;
--primary-2: #b45cff;
--gold: #ffc44d;
--danger: #ff5d6c;
--green: #35d07f;
--radius: 18px;
--font: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
font-family: var(--font);
color: var(--text);
background: var(--bg);
overflow: hidden;
}
/* ============ 无边框窗口标题栏(透明悬浮,与页面背景融为一体) ============ */
#titlebar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 40px;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 14px;
background: transparent;
border-bottom: none;
z-index: 9999;
user-select: none;
-webkit-user-select: none;
cursor: default;
}
.tb-title { flex: 1; }
.tb-actions {
display: flex;
gap: 8px;
pointer-events: auto;
}
.tb-btn {
width: 28px;
height: 28px;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.07);
color: var(--text-dim);
font-size: 15px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
backdrop-filter: blur(6px);
}
.tb-min:hover {
background: rgba(255, 255, 255, 0.16);
color: var(--text);
}
.tb-close:hover {
background: var(--danger);
color: #fff;
}
.stage {
position: relative;
width: 100%;
height: calc(100vh - 40px);
margin-top: 40px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background:
radial-gradient(1200px 700px at 20% -10%, rgba(124, 92, 255, 0.22), transparent 55%),
radial-gradient(1000px 650px at 100% 0%, rgba(56, 217, 240, 0.12), transparent 50%),
radial-gradient(900px 900px at 50% 120%, rgba(180, 92, 255, 0.18), transparent 55%);
}
/* 背景光斑 */
.bg-glow {
position: absolute;
border-radius: 50%;
filter: blur(90px);
opacity: 0.5;
pointer-events: none;
animation: float 12s ease-in-out infinite;
}
.bg-1 { width: 420px; height: 420px; background: rgba(124,92,255,0.35); top: -120px; left: -80px; }
.bg-2 { width: 360px; height: 360px; background: rgba(255,196,77,0.22); bottom: -100px; right: -60px; animation-delay: 3s; }
.bg-3 { width: 300px; height: 300px; background: rgba(56,217,240,0.25); bottom: 20%; left: -100px; animation-delay: 6s; }
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(20px, -20px) scale(1.08); }
}
.panel {
position: relative;
z-index: 10;
width: 760px;
max-width: 96vw;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
padding: 30px 36px 24px;
display: flex;
flex-direction: column;
animation: rise 0.5s cubic-bezier(0.2, 0.8, 0.3, 1);
}
@keyframes rise {
from { transform: translateY(24px); opacity: 0; }
to { transform: none; opacity: 1; }
}
/* 头部 */
.header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 28px;
}
.logo-ring {
width: 58px;
height: 58px;
border-radius: 16px;
background: linear-gradient(135deg, var(--primary), var(--primary-2));
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 28px rgba(124, 92, 255, 0.45);
flex-shrink: 0;
}
.logo-inner {
font-size: 28px;
font-weight: 800;
color: #fff;
}
.title-box { flex: 1; }
.title-box h1 {
font-size: 22px;
font-weight: 800;
letter-spacing: 1px;
background: linear-gradient(90deg, #fff, #cfc3ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle { font-size: 12.5px; color: var(--text-muted); margin-top: 3px; letter-spacing: 0.5px; }
.status-area {
display: flex;
align-items: center;
gap: 10px;
}
.account-setting-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 13px;
border-radius: 20px;
background: rgba(124, 92, 255, 0.14);
border: 1px solid rgba(124, 92, 255, 0.35);
font-size: 12.5px;
color: #cfc3ff;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.account-setting-btn:hover {
background: rgba(124, 92, 255, 0.28);
border-color: rgba(124, 92, 255, 0.6);
color: #fff;
}
.loadout-btn {
background: rgba(56, 189, 248, 0.14);
border-color: rgba(56, 189, 248, 0.35);
color: #bae6fd;
}
.loadout-btn:hover {
background: rgba(56, 189, 248, 0.28);
border-color: rgba(56, 189, 248, 0.6);
color: #fff;
}
.status-badge {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 13px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border);
font-size: 12.5px;
color: var(--text-dim);
white-space: nowrap;
}
.status-badge .dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--text-muted);
transition: all 0.3s;
}
.status-badge.on .dot { background: var(--green); box-shadow: 0 0 10px var(--green); }
.status-badge.off .dot { background: var(--danger); box-shadow: 0 0 10px var(--danger); }
/* 按钮区 */
.btn-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.action-btn {
position: relative;
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
border-radius: 14px;
border: 1px solid var(--border-strong);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
cursor: pointer;
text-align: left;
transition: all 0.18s;
overflow: hidden;
}
.action-btn:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
}
.action-btn:active { transform: translateY(0); }
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.action-btn .btn-icon {
width: 44px; height: 44px;
border-radius: 11px;
display: flex; align-items: center; justify-content: center;
font-size: 20px;
flex-shrink: 0;
color: #fff;
}
.action-btn .btn-text {
flex: 1;
min-width: 0;
}
.btn-label { font-size: 16px; font-weight: 700; display: block; white-space: nowrap; }
.btn-desc { font-size: 12px; color: var(--text-muted); display: block; margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.action-btn.primary .btn-icon {
background: linear-gradient(135deg, var(--primary), var(--primary-2));
box-shadow: 0 4px 16px rgba(124, 92, 255, 0.4);
}
.action-btn.primary { border-color: rgba(124, 92, 255, 0.4); }
.action-btn.gold .btn-icon {
background: linear-gradient(135deg, #ffb84d, #ff8a4d);
box-shadow: 0 4px 16px rgba(255, 196, 77, 0.35);
}
.action-btn.gold { border-color: rgba(255, 196, 77, 0.4); }
.action-btn.danger .btn-icon {
background: linear-gradient(135deg, #ff5d6c, #ff3d5a);
box-shadow: 0 4px 16px rgba(255, 93, 108, 0.35);
}
.action-btn.danger { border-color: rgba(255, 93, 108, 0.35); }
.action-btn:not(.primary):not(.gold):not(.danger) .btn-icon {
background: linear-gradient(135deg, #38d9f0, #4d9fff);
box-shadow: 0 4px 16px rgba(56, 217, 240, 0.3);
}
/* 日志区 */
.log-panel {
margin-top: 22px;
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(0, 0, 0, 0.25);
overflow: hidden;
display: flex;
flex-direction: column;
height: 180px;
flex-shrink: 0;
}
.log-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
font-size: 12px;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
letter-spacing: 0.5px;
flex-shrink: 0;
}
.log-clear {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 12px;
}
.log-clear:hover { color: var(--text); }
.log-body {
padding: 10px 14px;
font-size: 12.5px;
line-height: 1.7;
overflow-y: auto;
flex: 1;
font-family: "Cascadia Code", "Consolas", "Microsoft YaHei", monospace;
}
/* 日志区自定义滚动条 */
.log-body::-webkit-scrollbar { width: 8px; }
.log-body::-webkit-scrollbar-track { background: transparent; }
.log-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.14);
border-radius: 4px;
}
.log-body::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.26); }
.log-line { color: var(--text-dim); word-break: break-all; }
.log-line.muted { color: var(--text-muted); }
.log-line.error { color: var(--danger); }
.log-line.ok { color: var(--green); }
/* 底部 */
.footer {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
font-size: 11.5px;
color: var(--text-muted);
}
.footer .sep { opacity: 0.5; }
/* 弹窗 */
.modal-mask {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s;
}
.modal-mask.hidden { display: none; }
.modal {
width: 400px;
max-width: 92vw;
background: var(--panel-solid);
border: 1px solid var(--border-strong);
border-radius: 18px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
overflow: hidden;
animation: popIn 0.24s cubic-bezier(0.2, 0.8, 0.3, 1);
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
font-size: 16px;
font-weight: 700;
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.modal-close:hover { color: var(--text); }
.modal-body {
padding: 22px;
text-align: center;
}
.sponsor-tip {
font-size: 13px;
color: var(--text-dim);
line-height: 1.8;
margin-bottom: 18px;
}
.qrcode-box {
width: 240px;
height: 240px;
margin: 0 auto;
border-radius: 14px;
border: 1px dashed var(--border-strong);
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.03);
overflow: hidden;
}
.qrcode-box img {
width: 100%;
height: 100%;
object-fit: contain;
}
.qrcode-placeholder {
font-size: 13px;
color: var(--text-muted);
padding: 0 20px;
line-height: 1.8;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes popIn { from { transform: scale(0.94); opacity: 0; } to { transform: none; opacity: 1; } }
/* 滚动条 */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 8px; }
@media (max-width: 560px) {
.btn-grid { grid-template-columns: 1fr; }
.header { flex-wrap: wrap; }
}
/* ============================================================
GM 工具视图样式(内置管理工具)
============================================================ */
.hidden { display: none !important; }
.gm-app {
display: flex;
height: calc(100vh - 40px);
margin-top: 40px;
width: 100%;
background:
radial-gradient(1200px 700px at 15% -10%, rgba(124, 92, 255, 0.16), transparent 55%),
radial-gradient(1000px 600px at 100% 0%, rgba(56, 217, 240, 0.10), transparent 50%),
radial-gradient(900px 900px at 50% 120%, rgba(180, 92, 255, 0.12), transparent 55%),
var(--bg);
}
/* 侧边栏 */
.gm-sidebar {
width: 220px;
flex-shrink: 0;
background: linear-gradient(180deg, rgba(22, 22, 42, 0.92), rgba(13, 13, 26, 0.96));
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 20px 12px;
gap: 4px;
}
.gm-logo {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 10px 20px;
}
.gm-logo-mark {
width: 38px; height: 38px;
border-radius: 11px;
background: linear-gradient(135deg, var(--primary), var(--primary-2));
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 18px; color: #fff;
box-shadow: 0 0 22px rgba(124, 92, 255, 0.35);
}
.gm-logo-title { font-size: 17px; font-weight: 700; letter-spacing: 0.5px; }
.gm-logo-sub { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.gm-nav-group {
font-size: 10.5px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1.5px;
padding: 16px 12px 6px;
font-weight: 600;
}
.gm-nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 10px 12px;
border-radius: 10px;
color: var(--text-dim);
font-size: 14px;
cursor: pointer;
transition: all 0.18s;
border: 1px solid transparent;
user-select: none;
}
.gm-nav-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--text); }
.gm-nav-item.active {
background: linear-gradient(135deg, rgba(124, 92, 255, 0.22), rgba(180, 92, 255, 0.12));
color: #fff;
border-color: rgba(124, 92, 255, 0.4);
box-shadow: 0 0 18px rgba(124, 92, 255, 0.15);
}
.gm-nav-item .ico { width: 20px; text-align: center; font-size: 16px; }
.gm-sidebar-foot {
margin-top: auto;
padding: 12px;
font-size: 11px;
color: var(--text-muted);
display: flex;
flex-direction: column;
gap: 10px;
}
.conn-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.conn-dot.on { background: var(--green); box-shadow: 0 0 8px var(--green); }
.conn-dot.off { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
.gm-back {
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border);
border-radius: 9px;
color: var(--text-dim);
padding: 9px 12px;
cursor: pointer;
font-size: 13px;
transition: all 0.16s;
}
.gm-back:hover { background: rgba(255, 255, 255, 0.1); color: var(--text); }
/* 主内容 */
.gm-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.gm-topbar {
height: 58px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 14px;
padding: 0 22px;
border-bottom: 1px solid var(--border);
background: rgba(16, 16, 28, 0.6);
backdrop-filter: blur(14px);
}
.gm-topbar h1 { font-size: 17px; font-weight: 700; }
.gm-crumb { color: var(--text-muted); font-size: 12px; }
.topbar-spacer { flex: 1; }
.gm-content {
flex: 1;
overflow-y: auto;
padding: 22px;
}
/* GM 通用组件 */
.gm-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(10px);
}
.gm-card + .gm-card { margin-top: 16px; }
.gm-card-title {
font-size: 15px; font-weight: 700; margin-bottom: 14px;
display: flex; align-items: center; gap: 8px;
}
.gm-card-title .dot { width: 4px; height: 16px; border-radius: 2px; background: linear-gradient(180deg, var(--primary), var(--primary-2)); }
.gm-grid { display: grid; gap: 16px; }
.gm-grid-2 { grid-template-columns: 1fr 1fr; }
.gm-grid-3 { grid-template-columns: 1fr 1fr 1fr; }
.gm-grid-4 { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 1100px) { .gm-grid-3, .gm-grid-4 { grid-template-columns: 1fr 1fr; } }
@media (max-width: 720px) { .gm-grid-2, .gm-grid-3, .gm-grid-4 { grid-template-columns: 1fr; } }
/* 统计卡片 */
.gm-stat {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 18px 20px;
position: relative;
overflow: hidden;
}
.gm-stat .label { font-size: 12.5px; color: var(--text-muted); }
.gm-stat .value { font-size: 28px; font-weight: 800; margin-top: 6px; letter-spacing: -0.5px; }
.gm-stat .unit { font-size: 13px; color: var(--text-muted); margin-left: 3px; font-weight: 500; }
.gm-stat .ico-bg { position: absolute; right: -10px; bottom: -14px; font-size: 70px; opacity: 0.08; }
.gm-stat.accent { border-color: rgba(124, 92, 255, 0.4); }
.gm-stat.accent .value { color: #a98aff; }
/* GM 按钮 */
.gm-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 9px 16px;
border-radius: 9px;
border: 1px solid var(--border-strong);
background: #1d1d36;
color: var(--text);
font-size: 13.5px;
font-weight: 600;
transition: all 0.16s;
white-space: nowrap;
cursor: pointer;
font-family: var(--font);
}
.gm-btn:hover { background: #24243f; border-color: rgba(255,255,255,0.25); transform: translateY(-1px); }
.gm-btn:active { transform: translateY(0); }
.gm-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
.gm-btn-primary {
background: linear-gradient(135deg, var(--primary), var(--primary-2));
border-color: transparent;
box-shadow: 0 4px 18px rgba(124, 92, 255, 0.35);
}
.gm-btn-primary:hover { filter: brightness(1.12); background: linear-gradient(135deg, var(--primary), var(--primary-2)); }
.gm-btn-danger { background: linear-gradient(135deg, #ff5d6c, #ff3d5a); border-color: transparent; }
.gm-btn-danger:hover { filter: brightness(1.1); background: linear-gradient(135deg, #ff5d6c, #ff3d5a); }
.gm-btn-ghost { background: transparent; }
.gm-btn-sm { padding: 6px 12px; font-size: 12.5px; }
.gm-btn-xs { padding: 4px 9px; font-size: 11.5px; border-radius: 7px; }
/* 物品分区 Tab(装备 / 道具) */
.gm-item-tabs {
display: flex;
gap: 6px;
margin-bottom: 12px;
padding: 4px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
border-radius: 11px;
}
.gm-item-tab {
flex: 1;
padding: 8px 0;
border: none;
background: transparent;
border-radius: 8px;
color: var(--text-dim);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.16s;
font-family: var(--font);
}
.gm-item-tab:hover { color: var(--text); }
.gm-item-tab.active {
background: linear-gradient(135deg, var(--primary), var(--primary-2));
color: #fff;
box-shadow: 0 4px 14px rgba(124, 92, 255, 0.35);
}
/* GM 输入 */
.gm-field { display: flex; flex-direction: column; gap: 6px; }
.gm-field label { font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
.gm-input, .gm-select, .gm-textarea {
background: #10101c;
border: 1px solid var(--border-strong);
border-radius: 9px;
padding: 9px 12px;
font-size: 13.5px;
color: var(--text);
outline: none;
transition: all 0.16s;
width: 100%;
font-family: var(--font);
}
.gm-input:focus, .gm-select:focus, .gm-textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(124, 92, 255, 0.15);
}
.gm-input::placeholder, .gm-textarea::placeholder { color: var(--text-muted); }
.gm-select option { background: #16162a; color: var(--text); }
.gm-textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
/* GM 表格 */
.gm-table-wrap { overflow-x: auto; border-radius: 10px; border: 1px solid var(--border); }
.gm-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.gm-table thead th {
text-align: left; padding: 11px 14px;
background: #16162a;
color: var(--text-muted);
font-weight: 600; font-size: 12px;
letter-spacing: 0.3px;
position: sticky; top: 0;
white-space: nowrap;
}
.gm-table tbody td { padding: 11px 14px; border-top: 1px solid var(--border); color: var(--text-dim); }
.gm-table tbody tr { transition: background 0.12s; }
.gm-table tbody tr:hover { background: rgba(255, 255, 255, 0.035); }
/* GM 标签 */
.gm-tag {
display: inline-flex; align-items: center;
padding: 3px 9px; border-radius: 20px;
font-size: 11.5px; font-weight: 600;
white-space: nowrap;
}
.gm-tag-green { background: rgba(53, 208, 127, 0.15); color: var(--green); }
.gm-tag-red { background: rgba(255, 93, 108, 0.15); color: var(--danger); }
.gm-tag-gray { background: rgba(255, 255, 255, 0.08); color: var(--text-muted); }
.gm-tag-gold { background: rgba(255, 196, 77, 0.15); color: var(--gold); }
.gm-tag-purple { background: rgba(124, 92, 255, 0.16); color: #b79bff; }
/* GM Toast */
.gm-toast-wrap {
position: fixed; top: 20px; right: 20px; z-index: 999;
display: flex; flex-direction: column; gap: 10px;
}
.gm-toast {
min-width: 240px; max-width: 360px;
padding: 13px 16px;
border-radius: 11px;
background: #16162a;
border: 1px solid var(--border-strong);
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
font-size: 13.5px;
display: flex; align-items: center; gap: 10px;
animation: slideIn 0.28s cubic-bezier(0.2, 0.8, 0.3, 1);
}
.gm-toast.ok { border-left: 3px solid var(--green); }
.gm-toast.err { border-left: 3px solid var(--danger); }
.gm-toast.info { border-left: 3px solid #4d9fff; }
@keyframes slideIn { from { transform: translateX(30px); opacity: 0; } to { transform: none; opacity: 1; } }
/* GM 加载 / 空态 */
.gm-loading {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 60px 0; gap: 16px; color: var(--text-muted);
}
.gm-spinner {
width: 34px; height: 34px;
border: 3px solid rgba(255,255,255,0.12);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.gm-empty {
text-align: center; padding: 50px 20px; color: var(--text-muted);
font-size: 14px;
}
/* GM 其他 */
.gm-search-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.gm-search-row .gm-input { flex: 1; min-width: 160px; }
.gm-toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.gm-hint { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
.gm-num-gold { color: var(--gold); font-weight: 700; }
.gm-num-cyan { color: #38d9f0; font-weight: 700; }
.gm-avatar {
width: 40px; height: 40px; border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--primary-2));
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; font-size: 16px;
flex-shrink: 0;
}
.gm-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.gm-form-row .full { grid-column: 1 / -1; }
@media (max-width: 620px) { .gm-form-row { grid-template-columns: 1fr; } }
/* 危险区 */
.gm-danger-zone {
border: 1px solid rgba(255, 93, 108, 0.35);
border-radius: 12px;
padding: 18px;
background: rgba(255, 93, 108, 0.05);
}
/* ===== 账号设置面板 ===== */
.account-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 12px;
margin-bottom: 10px;
background: rgba(255, 255, 255, 0.03);
transition: border-color 0.15s, background 0.15s;
}
.account-row.default {
border-color: rgba(124, 92, 255, 0.55);
background: rgba(124, 92, 255, 0.08);
}
.account-info { min-width: 0; }
.account-user {
font-size: 14px;
font-weight: 600;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.account-pass {
font-size: 12px;
color: var(--text-muted);
margin-top: 3px;
letter-spacing: 1px;
}
.account-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.accounts-add {
border-top: 1px solid var(--border);
padding-top: 14px;
}
.accounts-add-title {
font-size: 13px;
font-weight: 600;
color: var(--text-dim);
margin-bottom: 10px;
}