DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -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,
|
||||
)
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+801
@@ -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,
|
||||
}
|
||||
@@ -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 |
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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}
|
||||
|
||||
@@ -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,'"')}" readonly></label>
|
||||
<label>Admin 端口<input value="18111" readonly></label>
|
||||
<label>数据库<input value="${(info.db || '').replace(/"/g,'"')}" readonly></label>
|
||||
<label>客户端目录<input value="${(info.client_dir || '').replace(/"/g,'"')}" readonly></label>
|
||||
</div>`;
|
||||
html += `<div class="st-note">当前配置文件:${stFeatureTab}</div>`;
|
||||
html += `<textarea id="stRaw" class="st-raw" spellcheck="false">${stRawText.replace(/</g,'<')}</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,'"')}"></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 |
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user