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

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

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

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
# DNF90 GM 管理工具
一个全新的、页面精美的 DNF90(九零轮回)单机私服 GM 管理工具。
基于对旧源码(`90轮回源码` 与 `DNF90工具与资料备份`)的深入分析,完整还原了服务端 GM API 协议,并重新实现了一套现代化、美观的管理界面。
## 功能特性
- 🎨 **精美深色主题**:DNF 风格渐变 + 毛玻璃质感,响应式布局
- 👥 **角色管理**:按账号/角色名/用户名查询角色,编辑等级、金币、技能点
- 🎁 **道具发放**:搜索道具并发放、查看道具详情
- ✉️ **邮件系统**:系统邮件、角色邮件、附件道具与金币
- 🎒 **背包装备**:查看背包、删除道具、清空背包、装备管理
- 📜 **任务管理**:查询任务、完成/放弃/撤销任务
- ⚔️ **副本控制**:副本状态监控、解锁难度/深渊、安全回城
- 💰 **钱包点券**:钱包查询、黑钻开通
- 🎮 **玩法配置**:深渊掉率、史诗药剂倍率
- 📡 **频道管理**:频道列表、热重载
- 🔍 **自动发现**:自动探测本地服务端地址与 Admin Token
## 技术架构
```
浏览器前端 (HTML/CSS/JS 单页应用)
↓ fetch
FastAPI 后端 (Python) ← 端口 9527,CORS 代理
↓ HTTP + X-Admin-Token
DNF90Server 本地 GM API ← 端口 18111
```
- **后端**:Python 3.15 + FastAPI + requests
- **前端**:纯原生 HTML/CSS/JS,无框架依赖
- **API 协议**:完整对接服务端 `/dnf/gm/*` 与 `/local/dnf-gm/*` 两套路由
## 快速开始
### 1. 安装依赖
```bash
pip install fastapi uvicorn requests
```
### 2. 启动服务
```bash
python gmtool/main.py
```
或双击 `启动GM工具.bat`。
### 3. 访问
浏览器打开 http://127.0.0.1:9527
## 连接服务端
GM 工具需要连接本地运行的 DNF90 服务端(`DNF90Server.exe`):
1. **自动发现**:点击顶栏「🔍 自动发现服务端」,工具会自动探测端口与 Token
2. **手动连接**:在「连接设置」页面填写主机、端口、Admin Token
默认配置:
- 主机:`127.0.0.1`
- 端口:`18111`
- Token:`local-admin-token`(或服务端 `instance.json` 中自动生成的 Token)
## 目录结构
```
gmtool/
├── main.py # FastAPI 后端入口
├── gm_client.py # GM API 客户端封装(含自动发现)
└── static/
├── index.html # 页面骨架
├── css/style.css # 样式
└── js/
├── api.js # API 客户端与工具函数
├── pages.js # 各功能页面
└── app.js # 应用初始化与路由
```
## 注意事项
- 工具通过本地回环地址通信,不暴露到公网
- 危险操作(清空背包、删除道具等)均有二次确认
- 服务端需在 `local` 环境运行,`/local/*` 兼容路由才可用
+413
View File
@@ -0,0 +1,413 @@
# -*- coding: utf-8 -*-
"""DNF90 GM 工具 —— 服务端 GM API 客户端封装。
负责:
1. 自动发现本地服务端(DNF90Server.exe)的 admin 端口与 token;
2. 封装全部 /dnf/gm/* 与 /local/* 接口为统一方法;
3. 处理 X-Admin-Token / X-Admin-Confirm / X-Admin-Idempotency-Key 等管理头。
"""
import json
import os
import re
import socket
import threading
import time
import uuid
import requests
DEFAULT_ADMIN_PORT = 18111
DEFAULT_TOKEN = "local-admin-token"
# 常见部署根目录,用于自动定位实例配置与 token
SEARCH_ROOTS = [
r"D:\90server",
r"D:\90CN",
r"Z:\90server",
r"C:\90server",
r"D:\90轮回源码",
]
def _is_port_open(host, port, timeout=0.35):
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
class GMClient:
"""与服务端本地 GM API 通信的客户端。"""
def __init__(self, host="127.0.0.1", port=DEFAULT_ADMIN_PORT, token=None):
self.host = host
self.port = port
self.token = token or DEFAULT_TOKEN
self.base_url = "http://%s:%d" % (host, port)
self._lock = threading.Lock()
# ------------------------------------------------------------------ 发现
@staticmethod
def discover():
"""尝试自动发现服务端地址与 token。返回 (base_url, token) 或 (None, None)。"""
# 1) 直接探测默认端口
if _is_port_open("127.0.0.1", DEFAULT_ADMIN_PORT):
token = GMClient._find_token()
if GMClient._probe("127.0.0.1", DEFAULT_ADMIN_PORT, token):
return "http://127.0.0.1:%d" % DEFAULT_ADMIN_PORT, token
# 2) 扫描配置里的 adminListen
for root in SEARCH_ROOTS:
if not os.path.isdir(root):
continue
for token in GMClient._tokens_from_root(root):
# 尝试从 token 配置旁推断端口
for port in GMClient._ports_from_root(root):
if _is_port_open("127.0.0.1", port) and GMClient._probe("127.0.0.1", port, token):
return "http://127.0.0.1:%d" % port, token
return None, None
@staticmethod
def _probe(host, port, token):
try:
r = requests.get("http://%s:%d/dnf/gm/status" % (host, port),
headers={"X-Admin-Token": token}, timeout=1.0)
return r.status_code in (200, 401, 403)
except Exception:
return False
@staticmethod
def _tokens_from_root(root):
"""从部署目录里搜集候选 token。"""
tokens = [DEFAULT_TOKEN]
for dirpath, _dirs, files in os.walk(root):
if len(tokens) > 40:
break
for fn in files:
lower = fn.lower()
if lower in ("instance.json", "server.json", "config.json", "dnfbridge.toml"):
p = os.path.join(dirpath, fn)
try:
txt = open(p, "r", encoding="utf-8", errors="ignore").read()
except OSError:
continue
# token = "..."
for m in re.finditer(r'(?:adminToken|token|admin_token|AdminToken)["\']?\s*[:=]\s*["\']([^"\']+)["\']', txt):
v = m.group(1).strip()
if v and v not in tokens:
tokens.append(v)
# [admin]\ntoken = "..."
for m in re.finditer(r'token\s*=\s*"([^"]+)"', txt):
v = m.group(1).strip()
if v and v not in tokens:
tokens.append(v)
return tokens
@staticmethod
def _ports_from_root(root):
ports = [DEFAULT_ADMIN_PORT]
for dirpath, _dirs, files in os.walk(root):
if len(ports) > 20:
break
for fn in files:
if fn.lower() in ("instance.json", "server.json", "dnfbridge.toml"):
p = os.path.join(dirpath, fn)
try:
txt = open(p, "r", encoding="utf-8", errors="ignore").read()
except OSError:
continue
for m in re.finditer(r'(?:adminListen|listen)["\']?\s*[:=]\s*["\']?127\.0\.0\.1:(\d+)', txt):
try:
port = int(m.group(1))
if port not in ports:
ports.append(port)
except ValueError:
pass
return ports
# ------------------------------------------------------------------ HTTP
def _headers(self, mutate=False, confirm=None):
h = {"X-Admin-Token": self.token, "Content-Type": "application/json"}
if mutate:
op_id = confirm or "op"
h["X-Admin-Confirm"] = op_id
h["X-Admin-Idempotency-Key"] = str(uuid.uuid4())
return h
def _request(self, method, path, **kwargs):
url = self.base_url + path
return requests.request(method, url, timeout=15, **kwargs)
def _get(self, path, params=None):
r = self._request("GET", path, params=params, headers=self._headers())
return self._parse(r)
def _post(self, path, payload=None, mutate=True):
r = self._request("POST", path, json=payload or {},
headers=self._headers(mutate=mutate))
return self._parse(r)
@staticmethod
def _parse(r):
try:
data = r.json()
except ValueError:
data = {"ok": False, "error": "非 JSON 响应 (%d)" % r.status_code, "raw": r.text[:200]}
# 统一归一化:服务端两套路由返回格式略有差异
if isinstance(data, dict):
if "success" in data and "data" in data:
return {"ok": data.get("success", False), "data": data.get("data"),
"error": data.get("error")}
if "ok" in data:
return data
return {"ok": r.status_code < 400, "data": data,
"error": None if r.status_code < 400 else "HTTP %d" % r.status_code}
# ------------------------------------------------------------------ 状态
def status(self):
return self._get("/dnf/gm/status")
def channels(self):
return self._get("/dnf/gm/channels")
def gameplay(self):
return self._get("/dnf/gm/gameplay")
# ------------------------------------------------------------------ 账号
def list_accounts(self, q="", limit=50):
return self._get("/dnf/gm/accounts", params={"q": q, "limit": limit})
def account(self, account_id):
return self._get("/dnf/gm/accounts/%s" % account_id)
def account_characters(self, account_id):
return self._get("/dnf/gm/accounts/%s/characters" % account_id)
def set_account_value(self, account_id, field, value):
return self._post("/dnf/gm/accounts/%s/value" % account_id,
{"field": field, "value": value})
# ------------------------------------------------------------------ 角色
def characters(self, account_id="", character_name="", username=""):
"""兼容旧路由 /local/dnf-gm/characters 的角色查询。"""
params = {}
if username:
params["username"] = username
elif account_id:
params["account_id"] = account_id
elif character_name:
params["character_name"] = character_name
return self._get("/local/dnf-gm/characters", params=params)
def character(self, character_id):
return self._get("/dnf/gm/characters/%s" % character_id)
def character_detail(self, character_id):
return self._get("/dnf/gm/characters/%s/detail" % character_id)
def set_gold(self, character_id, value):
return self._post("/dnf/gm/characters/%s/gold" % character_id, {"value": value})
def set_level(self, character_id, level):
return self._post("/dnf/gm/characters/%s/level" % character_id, {"level": level})
def set_skill_points(self, character_id, bonus_sp, bonus_tp):
return self._post("/dnf/gm/characters/%s/skill-points" % character_id,
{"bonus_sp": bonus_sp, "bonus_tp": bonus_tp})
def set_career(self, character_id, job, first, awakening):
return self._post("/dnf/gm/characters/%s/career" % character_id,
{"job": job, "first": first, "awakening": awakening})
# ------------------------------------------------------------------ 邮件
def mailbox(self, character_id):
return self._get("/dnf/gm/characters/%s/mailbox" % character_id)
def delete_mail(self, character_id, mail_ids, force=False):
return self._post("/dnf/gm/characters/%s/mailbox/delete" % character_id,
{"mail_ids": mail_ids, "force": force})
def send_mail(self, character_id, item_id, count, title, body,
expire_days=7, reinforce=0, amplify_type=0,
equipment_quality="", forging=0, emblem_item_ids=None,
enchant_bead_item_id=0):
payload = {
"item_id": item_id, "count": count, "title": title, "body": body,
"expire_days": expire_days, "reinforce": reinforce,
"amplify_type": amplify_type, "equipment_quality": equipment_quality,
"forging": forging, "emblem_item_ids": emblem_item_ids or [],
"enchant_bead_item_id": enchant_bead_item_id,
}
return self._post("/dnf/gm/characters/%s/mail" % character_id, payload)
def system_mail(self, character_name, title, body, gold=0, attachments=None):
payload = {"character_name": character_name, "title": title, "body": body,
"gold": gold, "attachments": attachments or []}
return self._post("/local/dnf-gm/mail", payload)
# ------------------------------------------------------------------ 道具
def search_items(self, q="", limit=30):
return self._get("/dnf/gm/items", params={"q": q, "limit": limit})
def item_page(self, **kwargs):
return self._get("/dnf/gm/items/page", params=kwargs)
def item_detail(self, item_id):
return self._get("/dnf/gm/items/%d/detail" % item_id)
def item_categories(self):
return self._get("/dnf/gm/items/categories")
def item_mail_options(self, item_id, mode="", q="", rarity=-1, limit=40, offset=0):
params = {"mode": mode, "q": q, "rarity": rarity, "limit": limit, "offset": offset}
return self._get("/dnf/gm/items/%d/mail-options" % item_id, params=params)
def grant_item(self, character_id, item_id, count=1, **opts):
return self._post("/local/dnf-gm/item/grant", {
"character_id": str(character_id), "item_id": item_id, "count": count, **opts})
def delete_item(self, character_id, item_key, count=1):
return self._post("/local/dnf-gm/item/delete", {
"character_id": str(character_id), "key": item_key, "count": count})
# ------------------------------------------------------------------ 背包
def inventory(self, character_id):
return self._get("/dnf/gm/characters/%s/inventory" % character_id)
def delete_inventory_item(self, character_id, container, key, count):
return self._post("/dnf/gm/characters/%s/inventory/delete" % character_id,
{"container": container, "key": key, "count": count})
def clear_inventory(self, character_id, container):
return self._post("/dnf/gm/characters/%s/inventory/clear" % character_id,
{"container": container})
# ------------------------------------------------------------------ 装备
def unequip(self, character_id, key):
return self._post("/dnf/gm/characters/%s/equipment/unequip" % character_id, {"key": key})
def delete_equipment(self, character_id, key):
return self._post("/dnf/gm/characters/%s/equipment/delete" % character_id, {"key": key})
def extra_equipment_slots(self, character_id):
return self._get("/dnf/gm/characters/%s/equipment-slots" % character_id)
def unlock_equipment_slots(self, character_id, slots):
return self._post("/dnf/gm/characters/%s/equipment-slots/unlock" % character_id,
{"slots": slots})
def equipment_move(self, character_id, source_key, target_slot):
return self._post("/local/equipment-move", {
"character_id": str(character_id), "key": source_key, "target": target_slot})
# ------------------------------------------------------------------ 任务
def quests(self, character_id):
return self._get("/dnf/gm/characters/%s/quests" % character_id)
def search_quests(self, character_id, q="", limit=40):
return self._get("/dnf/gm/characters/%s/quests/search" % character_id,
params={"q": q, "limit": limit})
def quest_detail(self, character_id, quest_id):
return self._get("/dnf/gm/characters/%s/quests/%d/detail" % (character_id, quest_id))
def quest_action(self, character_id, quest_id, action):
return self._post("/dnf/gm/characters/%s/quests/%d/%s" % (character_id, quest_id, action))
def quest_complete_batch(self, character_id, quest_ids):
return self._post("/dnf/gm/characters/%s/quests/complete-batch" % character_id,
{"quest_ids": quest_ids})
def quest_resync(self, character_id):
return self._post("/local/quest-resync", {"character_id": int(character_id)})
# ------------------------------------------------------------------ 副本
def dungeons(self, character_id):
return self._get("/dnf/gm/characters/%s/dungeons" % character_id)
def unlock_dungeon_difficulties(self, character_id):
return self._post("/dnf/gm/characters/%s/dungeons/unlock-difficulties" % character_id)
def unlock_hell(self, character_id):
return self._post("/dnf/gm/characters/%s/dungeons/unlock-hell" % character_id)
def dungeon_control(self, character_id):
return self._get("/local/dungeon-control", params={"character_id": int(character_id)})
def dungeon_return_town(self, character_id):
return self._post("/local/dungeon-control", {
"character_id": int(character_id), "action": "return_to_town"})
# ------------------------------------------------------------------ 钱包
def wallet_query(self, character_id):
return self._post("/local/dnf-gm/wallet/query", {"character_id": str(character_id)})
def wallet_update(self, character_id, field, value):
return self._post("/local/dnf-gm/wallet/update",
{"character_id": str(character_id), field: value})
def revive_coin(self, character_id, delta):
return self._post("/local/dnf-gm/character/revive-coin",
{"character_id": str(character_id), "delta": delta})
def account_cera(self, account_id, delta):
return self._post("/local/dnf-gm/account/cera",
{"account_id": str(account_id), "delta": delta})
def black_diamond_status(self, character_id="", name=""):
params = {}
if character_id:
params["id"] = character_id
if name:
params["name"] = name
return self._get("/local/dnf-gm/character/black-diamond", params=params)
def activate_black_diamond(self, character_id, days):
return self._post("/local/dnf-gm/character/black-diamond",
{"character_id": str(character_id), "days": days})
# ------------------------------------------------------------------ 玩法
def set_hell_drop_rate(self, percent):
return self._post("/dnf/gm/gameplay/hell-drop-rate", {"percent": percent})
def set_epic_potion_multiplier(self, percent):
return self._post("/dnf/gm/gameplay/epic-potion-test-multiplier", {"percent": percent})
def reload_channels(self):
return self._post("/dnf/gm/channels/reload")
# ------------------------------------------------------------------ 其他
def kick_sessions(self, ban_type, ban_value):
return self._post("/local/dnf-gm/kick-sessions",
{"ban_type": ban_type, "ban_value": ban_value})
def clone_character(self, source_character_id, name):
return self._post("/local/character-clone",
{"source_character_id": int(source_character_id), "name": name})
def record_meta(self):
return self._get("/dnf/gm/records/meta")
def query_records(self, **kwargs):
return self._post("/dnf/gm/records/query", kwargs)
# 全局单例(懒加载,首次调用时自动发现)
_CLIENT = None
_CLIENT_LOCK = threading.Lock()
def get_client():
global _CLIENT
with _CLIENT_LOCK:
if _CLIENT is None:
_CLIENT = GMClient()
return _CLIENT
def reconnect(host="127.0.0.1", port=DEFAULT_ADMIN_PORT, token=None):
global _CLIENT
with _CLIENT_LOCK:
_CLIENT = GMClient(host, port, token)
return _CLIENT
+412
View File
@@ -0,0 +1,412 @@
# -*- coding: utf-8 -*-
"""DNF90 GM 工具 —— 后端服务。
基于 FastAPI,作为浏览器前端与服务端本地 GM API 之间的代理,
同时提供服务端自动发现、状态轮询、以及静态资源托管。
"""
import os
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from gm_client import get_client, reconnect, GMClient
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, "static")
app = FastAPI(title="DNF90 GM 工具", version="2.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
def _ok(data):
return {"ok": True, "data": data}
def _unwrap(result):
"""把 gm_client 返回的 {'ok':.., 'data':..} 统一为 HTTP 响应。"""
if not isinstance(result, dict):
return JSONResponse({"ok": False, "error": "未知响应"}, status_code=502)
if result.get("ok"):
return JSONResponse({"ok": True, "data": result.get("data")})
err = result.get("error") or "操作失败"
return JSONResponse({"ok": False, "error": err}, status_code=400)
# ------------------------------------------------------------------ 系统
@app.get("/api/health")
def health():
client = get_client()
return {"ok": True, "base_url": client.base_url, "token_ok": bool(client.token)}
@app.post("/api/connect")
def connect(payload: dict):
host = payload.get("host", "127.0.0.1")
port = int(payload.get("port", 18111))
token = payload.get("token") or None
client = reconnect(host, port, token)
res = client.status()
if not res.get("ok"):
return JSONResponse({"ok": False, "error": "无法连接服务端:%s" % res.get("error")},
status_code=400)
return {"ok": True, "base_url": client.base_url, "status": res.get("data")}
@app.get("/api/discover")
def discover():
base_url, token = GMClient.discover()
if base_url:
# 成功发现则建立连接
host = base_url.split("://")[1].split(":")[0]
port = int(base_url.rsplit(":", 1)[1])
reconnect(host, port, token)
return {"ok": True, "base_url": base_url, "token": token}
return {"ok": False, "error": "未发现运行中的服务端"}
# ------------------------------------------------------------------ 状态 / 频道
@app.get("/api/status")
def status():
return _unwrap(get_client().status())
@app.get("/api/channels")
def channels():
return _unwrap(get_client().channels())
@app.post("/api/channels/reload")
def reload_channels():
return _unwrap(get_client().reload_channels())
@app.get("/api/gameplay")
def gameplay():
return _unwrap(get_client().gameplay())
# ------------------------------------------------------------------ 账号
@app.get("/api/accounts")
def accounts(q: str = "", limit: int = 50):
return _unwrap(get_client().list_accounts(q, limit))
@app.get("/api/accounts/{account_id}")
def account(account_id: str):
return _unwrap(get_client().account(account_id))
@app.get("/api/accounts/{account_id}/characters")
def account_characters(account_id: str):
return _unwrap(get_client().account_characters(account_id))
@app.post("/api/accounts/{account_id}/value")
def set_account_value(account_id: str, payload: dict):
return _unwrap(get_client().set_account_value(
account_id, payload.get("field"), payload.get("value")))
# ------------------------------------------------------------------ 角色
@app.get("/api/characters")
def characters(account_id: str = "", character_name: str = "", username: str = ""):
return _unwrap(get_client().characters(account_id, character_name, username))
@app.get("/api/characters/{character_id}")
def character(character_id: str):
return _unwrap(get_client().character(character_id))
@app.get("/api/characters/{character_id}/detail")
def character_detail(character_id: str):
return _unwrap(get_client().character_detail(character_id))
@app.post("/api/characters/{character_id}/gold")
def set_gold(character_id: str, payload: dict):
return _unwrap(get_client().set_gold(character_id, payload.get("value", 0)))
@app.post("/api/characters/{character_id}/level")
def set_level(character_id: str, payload: dict):
return _unwrap(get_client().set_level(character_id, payload.get("level", 1)))
@app.post("/api/characters/{character_id}/skill-points")
def set_skill(character_id: str, payload: dict):
return _unwrap(get_client().set_skill_points(
character_id, payload.get("bonus_sp", 0), payload.get("bonus_tp", 0)))
@app.post("/api/characters/{character_id}/career")
def set_career(character_id: str, payload: dict):
return _unwrap(get_client().set_career(
character_id, payload.get("job", 0), payload.get("first", 0),
payload.get("awakening", 0)))
# ------------------------------------------------------------------ 邮件
@app.get("/api/characters/{character_id}/mailbox")
def mailbox(character_id: str):
return _unwrap(get_client().mailbox(character_id))
@app.post("/api/characters/{character_id}/mailbox/delete")
def delete_mail(character_id: str, payload: dict):
return _unwrap(get_client().delete_mail(
character_id, payload.get("mail_ids", []), payload.get("force", False)))
@app.post("/api/characters/{character_id}/mail")
def send_mail(character_id: str, payload: dict):
return _unwrap(get_client().send_mail(
character_id,
payload.get("item_id", 0),
payload.get("count", 1),
payload.get("title", ""),
payload.get("body", ""),
expire_days=payload.get("expire_days", 7),
reinforce=payload.get("reinforce", 0),
amplify_type=payload.get("amplify_type", 0),
equipment_quality=payload.get("equipment_quality", ""),
forging=payload.get("forging", 0),
emblem_item_ids=payload.get("emblem_item_ids", []),
enchant_bead_item_id=payload.get("enchant_bead_item_id", 0),
))
@app.post("/api/system-mail")
def system_mail(payload: dict):
return _unwrap(get_client().system_mail(
payload.get("character_name", ""),
payload.get("title", ""),
payload.get("body", ""),
gold=payload.get("gold", 0),
attachments=payload.get("attachments", []),
))
# ------------------------------------------------------------------ 道具
@app.get("/api/items/search")
def search_items(q: str = "", limit: int = 30):
return _unwrap(get_client().search_items(q, limit))
@app.get("/api/items/page")
def item_page(q: str = "", kind: str = "", tag: str = "", segment: str = "",
group: str = "", job: str = "", min_level: int = 0, max_level: int = 0,
rarity: int = -1, expiration: str = "", limit: int = 100, offset: int = 0):
return _unwrap(get_client().item_page(
q=q, kind=kind, tag=tag, segment=segment, group=group, job=job,
min_level=min_level, max_level=max_level, rarity=rarity,
expiration=expiration, limit=limit, offset=offset))
@app.get("/api/items/{item_id}/detail")
def item_detail(item_id: int):
return _unwrap(get_client().item_detail(item_id))
@app.get("/api/items/categories")
def item_categories():
return _unwrap(get_client().item_categories())
@app.get("/api/items/{item_id}/mail-options")
def item_mail_options(item_id: int, mode: str = "", q: str = "", rarity: int = -1,
limit: int = 40, offset: int = 0):
return _unwrap(get_client().item_mail_options(item_id, mode, q, rarity, limit, offset))
@app.post("/api/characters/{character_id}/grant")
def grant_item(character_id: str, payload: dict):
return _unwrap(get_client().grant_item(
character_id, payload.get("item_id", 0), payload.get("count", 1)))
# ------------------------------------------------------------------ 背包 / 装备
@app.get("/api/characters/{character_id}/inventory")
def inventory(character_id: str):
return _unwrap(get_client().inventory(character_id))
@app.post("/api/characters/{character_id}/inventory/delete")
def delete_inventory_item(character_id: str, payload: dict):
return _unwrap(get_client().delete_inventory_item(
character_id, payload.get("container", ""), payload.get("key", ""),
payload.get("count", 1)))
@app.post("/api/characters/{character_id}/inventory/clear")
def clear_inventory(character_id: str, payload: dict):
return _unwrap(get_client().clear_inventory(character_id, payload.get("container", "")))
@app.get("/api/characters/{character_id}/equipment-slots")
def equipment_slots(character_id: str):
return _unwrap(get_client().extra_equipment_slots(character_id))
@app.post("/api/characters/{character_id}/equipment-slots/unlock")
def unlock_equipment_slots(character_id: str, payload: dict):
return _unwrap(get_client().unlock_equipment_slots(character_id, payload.get("slots", [])))
@app.post("/api/characters/{character_id}/equipment/unequip")
def unequip(character_id: str, payload: dict):
return _unwrap(get_client().unequip(character_id, payload.get("key", "")))
@app.post("/api/characters/{character_id}/equipment/delete")
def delete_equipment(character_id: str, payload: dict):
return _unwrap(get_client().delete_equipment(character_id, payload.get("key", "")))
# ------------------------------------------------------------------ 任务
@app.get("/api/characters/{character_id}/quests")
def quests(character_id: str):
return _unwrap(get_client().quests(character_id))
@app.get("/api/characters/{character_id}/quests/search")
def search_quests(character_id: str, q: str = "", limit: int = 40):
return _unwrap(get_client().search_quests(character_id, q, limit))
@app.get("/api/characters/{character_id}/quests/{quest_id}/detail")
def quest_detail(character_id: str, quest_id: int):
return _unwrap(get_client().quest_detail(character_id, quest_id))
@app.post("/api/characters/{character_id}/quests/{quest_id}/{action}")
def quest_action(character_id: str, quest_id: int, action: str):
allowed = {"accept", "ready", "give-up", "complete", "uncomplete"}
if action not in allowed:
raise HTTPException(400, "不支持的操作")
return _unwrap(get_client().quest_action(character_id, quest_id, action))
@app.post("/api/characters/{character_id}/quests/complete-batch")
def quest_batch(character_id: str, payload: dict):
return _unwrap(get_client().quest_complete_batch(
character_id, payload.get("quest_ids", [])))
@app.post("/api/characters/{character_id}/quest-resync")
def quest_resync(character_id: str):
return _unwrap(get_client().quest_resync(character_id))
# ------------------------------------------------------------------ 副本
@app.get("/api/characters/{character_id}/dungeons")
def dungeons(character_id: str):
return _unwrap(get_client().dungeons(character_id))
@app.post("/api/characters/{character_id}/dungeons/unlock-difficulties")
def unlock_difficulties(character_id: str):
return _unwrap(get_client().unlock_dungeon_difficulties(character_id))
@app.post("/api/characters/{character_id}/dungeons/unlock-hell")
def unlock_hell(character_id: str):
return _unwrap(get_client().unlock_hell(character_id))
@app.get("/api/characters/{character_id}/dungeon-control")
def dungeon_control(character_id: str):
return _unwrap(get_client().dungeon_control(character_id))
@app.post("/api/characters/{character_id}/dungeon-return")
def dungeon_return(character_id: str):
return _unwrap(get_client().dungeon_return_town(character_id))
# ------------------------------------------------------------------ 钱包
@app.post("/api/characters/{character_id}/wallet/query")
def wallet_query(character_id: str):
return _unwrap(get_client().wallet_query(character_id))
@app.post("/api/characters/{character_id}/wallet/update")
def wallet_update(character_id: str, payload: dict):
return _unwrap(get_client().wallet_update(
character_id, payload.get("field"), payload.get("value")))
@app.post("/api/characters/{character_id}/revive-coin")
def revive_coin(character_id: str, payload: dict):
return _unwrap(get_client().revive_coin(character_id, payload.get("delta", 0)))
@app.get("/api/black-diamond")
def black_diamond(id: str = "", name: str = ""):
return _unwrap(get_client().black_diamond_status(id, name))
@app.post("/api/black-diamond/activate")
def activate_black_diamond(payload: dict):
return _unwrap(get_client().activate_black_diamond(
payload.get("character_id", ""), payload.get("days", 30)))
# ------------------------------------------------------------------ 玩法
@app.post("/api/gameplay/hell-drop-rate")
def hell_drop_rate(payload: dict):
return _unwrap(get_client().set_hell_drop_rate(payload.get("percent", 0)))
@app.post("/api/gameplay/epic-potion-multiplier")
def epic_potion(payload: dict):
return _unwrap(get_client().set_epic_potion_multiplier(payload.get("percent", 0)))
# ------------------------------------------------------------------ 其他
@app.post("/api/kick")
def kick(payload: dict):
return _unwrap(get_client().kick_sessions(
payload.get("ban_type", "account"), payload.get("ban_value", "")))
@app.post("/api/clone")
def clone(payload: dict):
return _unwrap(get_client().clone_character(
payload.get("source_character_id", 0), payload.get("name", "")))
@app.get("/api/records/meta")
def record_meta():
return _unwrap(get_client().record_meta())
@app.post("/api/records/query")
def record_query(payload: dict):
return _unwrap(get_client().query_records(**payload))
# ------------------------------------------------------------------ 静态资源
@app.get("/")
def index():
return FileResponse(os.path.join(STATIC_DIR, "index.html"))
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
if __name__ == "__main__":
import uvicorn
print("=" * 60)
print(" DNF90 GM 工具后端已启动")
print(" 访问: http://127.0.0.1:9527")
print("=" * 60)
uvicorn.run(app, host="127.0.0.1", port=9527, log_level="info")
+424
View File
@@ -0,0 +1,424 @@
/* ============================================================
DNF90 GM 工具 — 精美深色主题样式
============================================================ */
:root {
--bg-0: #0a0a12;
--bg-1: #10101c;
--bg-2: #16162a;
--bg-3: #1d1d36;
--panel: rgba(22, 22, 42, 0.72);
--panel-solid: #16162a;
--border: rgba(255, 255, 255, 0.07);
--border-strong: rgba(255, 255, 255, 0.14);
--text-0: #f2f3f8;
--text-1: #c3c6d8;
--text-2: #8a8ea8;
--text-3: #5c6078;
--accent: #7c5cff;
--accent-2: #b45cff;
--accent-glow: rgba(124, 92, 255, 0.35);
--cyan: #38d9f0;
--gold: #ffc44d;
--red: #ff5d6c;
--green: #35d07f;
--blue: #4d9fff;
--orange: #ff8a4d;
--rarity-0: #9aa0b5; /* 普通 */
--rarity-1: #5fd3ff; /* 高级 */
--rarity-2: #6ea8ff; /* 稀有 */
--rarity-3: #c37bff; /* 神器 */
--rarity-4: #ffb84d; /* 传说 */
--rarity-5: #ff7a59; /* 史诗 */
--rarity-6: #ff5d6c; /* 神话 */
--radius: 14px;
--radius-sm: 9px;
--shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
--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-0);
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-0);
background-attachment: fixed;
overflow: hidden;
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.10);
border-radius: 8px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); background-clip: padding-box; }
button { font-family: var(--font); cursor: pointer; }
input, select, textarea {
font-family: var(--font);
color: var(--text-0);
}
/* ---------------- 布局 ---------------- */
.app {
display: flex;
height: 100vh;
}
/* 侧边栏 */
.sidebar {
width: 220px;
flex-shrink: 0;
background: linear-gradient(180deg, rgba(22, 22, 42, 0.9), rgba(13, 13, 26, 0.95));
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 20px 12px;
gap: 4px;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 10px 20px;
}
.logo-mark {
width: 38px; height: 38px;
border-radius: 11px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 18px; color: #fff;
box-shadow: 0 0 22px var(--accent-glow);
}
.logo-title { font-size: 17px; font-weight: 700; letter-spacing: 0.5px; }
.logo-sub { font-size: 11px; color: var(--text-3); margin-top: 2px; }
.nav-group {
font-size: 10.5px;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 1.5px;
padding: 16px 12px 6px;
font-weight: 600;
}
.nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 10px 12px;
border-radius: 10px;
color: var(--text-1);
font-size: 14px;
cursor: pointer;
transition: all 0.18s;
border: 1px solid transparent;
user-select: none;
}
.nav-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--text-0); }
.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);
}
.nav-item .ico { width: 20px; text-align: center; font-size: 16px; }
.sidebar-foot {
margin-top: auto;
padding: 12px;
font-size: 11px;
color: var(--text-3);
display: flex;
flex-direction: column;
gap: 6px;
}
.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(--red); box-shadow: 0 0 8px var(--red); }
/* 主内容 */
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.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);
}
.topbar h1 { font-size: 17px; font-weight: 700; }
.topbar .crumb { color: var(--text-3); font-size: 12px; }
.topbar-spacer { flex: 1; }
.content {
flex: 1;
overflow-y: auto;
padding: 22px;
}
/* ---------------- 通用组件 ---------------- */
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.card + .card { margin-top: 16px; }
.card-title {
font-size: 15px; font-weight: 700; margin-bottom: 14px;
display: flex; align-items: center; gap: 8px;
}
.card-title .dot { width: 4px; height: 16px; border-radius: 2px; background: linear-gradient(180deg, var(--accent), var(--accent-2)); }
.grid { display: grid; gap: 16px; }
.grid-2 { grid-template-columns: 1fr 1fr; }
.grid-3 { grid-template-columns: 1fr 1fr 1fr; }
.grid-4 { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 1100px) { .grid-3, .grid-4 { grid-template-columns: 1fr 1fr; } }
@media (max-width: 720px) { .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; } }
/* 统计卡片 */
.stat-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
position: relative;
overflow: hidden;
}
.stat-card .label { font-size: 12.5px; color: var(--text-2); }
.stat-card .value { font-size: 28px; font-weight: 800; margin-top: 6px; letter-spacing: -0.5px; }
.stat-card .unit { font-size: 13px; color: var(--text-2); margin-left: 3px; font-weight: 500; }
.stat-card .ico-bg {
position: absolute; right: -10px; bottom: -14px;
font-size: 70px; opacity: 0.08;
}
.stat-card.accent { border-color: rgba(124, 92, 255, 0.4); }
.stat-card.accent .value { color: var(--accent); }
/* 按钮 */
.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: var(--bg-3);
color: var(--text-0);
font-size: 13.5px;
font-weight: 600;
transition: all 0.16s;
white-space: nowrap;
}
.btn:hover { background: var(--bg-2); border-color: rgba(255,255,255,0.25); transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
.btn-primary {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
border-color: transparent;
box-shadow: 0 4px 18px var(--accent-glow);
}
.btn-primary:hover { filter: brightness(1.12); background: linear-gradient(135deg, var(--accent), var(--accent-2)); }
.btn-danger { background: linear-gradient(135deg, #ff5d6c, #ff3d5a); border-color: transparent; }
.btn-danger:hover { filter: brightness(1.1); background: linear-gradient(135deg, #ff5d6c, #ff3d5a); }
.btn-ghost { background: transparent; }
.btn-sm { padding: 6px 12px; font-size: 12.5px; }
.btn-xs { padding: 4px 9px; font-size: 11.5px; border-radius: 7px; }
/* 输入 */
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 12.5px; color: var(--text-2); font-weight: 600; }
.input, .select, .textarea {
background: var(--bg-1);
border: 1px solid var(--border-strong);
border-radius: 9px;
padding: 9px 12px;
font-size: 13.5px;
color: var(--text-0);
outline: none;
transition: all 0.16s;
width: 100%;
}
.input:focus, .select:focus, .textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(124, 92, 255, 0.15);
}
.input::placeholder, .textarea::placeholder { color: var(--text-3); }
.select option { background: var(--bg-2); color: var(--text-0); }
.textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
/* 表格 */
.table-wrap { overflow-x: auto; border-radius: 10px; border: 1px solid var(--border); }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
text-align: left; padding: 11px 14px;
background: var(--bg-2);
color: var(--text-2);
font-weight: 600; font-size: 12px;
letter-spacing: 0.3px;
position: sticky; top: 0;
white-space: nowrap;
}
tbody td { padding: 11px 14px; border-top: 1px solid var(--border); color: var(--text-1); }
tbody tr { transition: background 0.12s; }
tbody tr:hover { background: rgba(255, 255, 255, 0.035); }
/* 标签 */
.tag {
display: inline-flex; align-items: center;
padding: 3px 9px; border-radius: 20px;
font-size: 11.5px; font-weight: 600;
white-space: nowrap;
}
.tag-green { background: rgba(53, 208, 127, 0.15); color: var(--green); }
.tag-red { background: rgba(255, 93, 108, 0.15); color: var(--red); }
.tag-gray { background: rgba(255, 255, 255, 0.08); color: var(--text-2); }
.tag-gold { background: rgba(255, 196, 77, 0.15); color: var(--gold); }
.tag-purple { background: rgba(124, 92, 255, 0.16); color: #b79bff; }
/* 稀有度 */
.rarity-0 { color: var(--rarity-0); }
.rarity-1 { color: var(--rarity-1); }
.rarity-2 { color: var(--rarity-2); }
.rarity-3 { color: var(--rarity-3); }
.rarity-4 { color: var(--rarity-4); }
.rarity-5 { color: var(--rarity-5); }
.rarity-6 { color: var(--rarity-6); text-shadow: 0 0 10px rgba(255, 93, 108, 0.5); }
/* Toast */
.toast-wrap {
position: fixed; top: 20px; right: 20px; z-index: 999;
display: flex; flex-direction: column; gap: 10px;
}
.toast {
min-width: 240px; max-width: 360px;
padding: 13px 16px;
border-radius: 11px;
background: var(--bg-2);
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);
}
.toast.ok { border-left: 3px solid var(--green); }
.toast.err { border-left: 3px solid var(--red); }
.toast.info { border-left: 3px solid var(--blue); }
@keyframes slideIn { from { transform: translateX(30px); opacity: 0; } to { transform: none; opacity: 1; } }
/* Modal */
.modal-mask {
position: fixed; inset: 0; z-index: 900;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.2s;
}
.modal {
width: 560px; max-width: 92vw; max-height: 86vh;
background: var(--panel-solid);
border: 1px solid var(--border-strong);
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
display: flex; flex-direction: column;
animation: popIn 0.24s cubic-bezier(0.2, 0.8, 0.3, 1);
overflow: hidden;
}
.modal.wide { width: 760px; }
.modal-head {
padding: 18px 22px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
font-weight: 700; font-size: 16px;
}
.modal-head .close {
background: none; border: none; color: var(--text-2);
font-size: 22px; line-height: 1; cursor: pointer;
}
.modal-head .close:hover { color: var(--text-0); }
.modal-body { padding: 22px; overflow-y: auto; }
.modal-foot {
padding: 16px 22px; border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: 10px;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes popIn { from { transform: scale(0.95); opacity: 0; } to { transform: none; opacity: 1; } }
/* 加载 */
.loading {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 60px 0; gap: 16px; color: var(--text-2);
}
.spinner {
width: 34px; height: 34px;
border: 3px solid rgba(255,255,255,0.12);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty {
text-align: center; padding: 50px 20px; color: var(--text-3);
font-size: 14px;
}
/* 徽章数值 */
.num-gold { color: var(--gold); font-weight: 700; }
.num-cyan { color: var(--cyan); font-weight: 700; }
/* 搜索栏 */
.search-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.search-row .input { flex: 1; min-width: 160px; }
/* 角色头像占位 */
.avatar {
width: 40px; height: 40px; border-radius: 10px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; font-size: 16px;
flex-shrink: 0;
}
/* 工具条 */
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
/* 空状态提示 */
.hint { font-size: 12px; color: var(--text-3); margin-top: 6px; }
/* 连接面板 */
.conn-panel {
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
padding: 14px 16px; border-radius: 12px;
background: var(--bg-1); border: 1px solid var(--border);
}
.conn-panel .input { width: auto; }
/* 双列表单 */
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.form-row .full { grid-column: 1 / -1; }
@media (max-width: 620px) { .form-row { grid-template-columns: 1fr; } }
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DNF90 GM 管理工具</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="app">
<!-- 侧边栏 -->
<aside class="sidebar">
<div class="logo">
<div class="logo-mark">D</div>
<div>
<div class="logo-title">DNF90 GM</div>
<div class="logo-sub">九零轮回 · 管理工具</div>
</div>
</div>
<div class="nav-group">总览</div>
<div class="nav-item active" data-page="dashboard">
<span class="ico">📊</span>控制台
</div>
<div class="nav-item" data-page="characters">
<span class="ico">👥</span>角色管理
</div>
<div class="nav-group">资源</div>
<div class="nav-item" data-page="items">
<span class="ico">🎁</span>道具发放
</div>
<div class="nav-item" data-page="mail">
<span class="ico">✉️</span>邮件系统
</div>
<div class="nav-item" data-page="inventory">
<span class="ico">🎒</span>背包装备
</div>
<div class="nav-group">进度</div>
<div class="nav-item" data-page="quests">
<span class="ico">📜</span>任务管理
</div>
<div class="nav-item" data-page="dungeons">
<span class="ico">⚔️</span>副本控制
</div>
<div class="nav-group">系统</div>
<div class="nav-item" data-page="wallet">
<span class="ico">💰</span>钱包点券
</div>
<div class="nav-item" data-page="gameplay">
<span class="ico">🎮</span>玩法配置
</div>
<div class="nav-item" data-page="channels">
<span class="ico">📡</span>频道管理
</div>
<div class="nav-item" data-page="settings">
<span class="ico">⚙️</span>连接设置
</div>
<div class="sidebar-foot">
<div><span class="conn-dot off"></span><span class="conn-text">未连接服务端</span></div>
<div style="color:#4a4e66">版本 v2.0.0</div>
</div>
</aside>
<!-- 主内容 -->
<div class="main">
<header class="topbar">
<h1 id="pageTitle">控制台</h1>
<span class="crumb" id="pageCrumb">/ 服务端总览</span>
<div class="topbar-spacer"></div>
<button class="btn btn-sm btn-ghost" id="btnDiscover">🔍 自动发现服务端</button>
<button class="btn btn-sm btn-primary" id="btnRefresh">↻ 刷新</button>
</header>
<div class="content" id="content"></div>
</div>
</div>
<script src="/static/js/api.js"></script>
<script src="/static/js/pages.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
+113
View File
@@ -0,0 +1,113 @@
// ============================================================
// DNF90 GM 工具 — API 客户端与通用工具
// ============================================================
const API = {
async request(method, path, body) {
const opts = { method, headers: {} };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
let data;
try { data = await res.json(); }
catch (e) { data = { ok: false, error: '响应解析失败' }; }
if (!res.ok || data.ok === false) {
const err = new Error(data.error || `请求失败 (${res.status})`);
err.data = data;
throw err;
}
return data.data;
},
get(path) { return this.request('GET', path); },
post(path, body) { return this.request('POST', path, body ?? {}); },
};
// ---------- Toast ----------
function toast(msg, type = 'info', ms = 3200) {
let wrap = document.querySelector('.toast-wrap');
if (!wrap) {
wrap = document.createElement('div');
wrap.className = 'toast-wrap';
document.body.appendChild(wrap);
}
const el = document.createElement('div');
el.className = 'toast ' + type;
const icon = type === 'ok' ? '✓' : type === 'err' ? '✕' : 'ℹ';
el.innerHTML = `<span style="font-weight:700">${icon}</span><span>${escapeHtml(msg)}</span>`;
wrap.appendChild(el);
setTimeout(() => {
el.style.transition = 'opacity 0.3s, transform 0.3s';
el.style.opacity = '0';
el.style.transform = 'translateX(30px)';
setTimeout(() => el.remove(), 300);
}, ms);
}
function escapeHtml(s) {
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function fmt(n) {
if (n === null || n === undefined || n === '') return '—';
const num = Number(n);
if (isNaN(num)) return String(n);
return num.toLocaleString('zh-CN');
}
function rarityColor(r) { return `rarity-${r}`; }
function rarityName(r) {
const names = ['普通', '高级', '稀有', '神器', '传说', '史诗', '神话'];
return names[r] || '未知';
}
// ---------- Modal ----------
function openModal({ title, body, foot, wide = false }) {
const mask = document.createElement('div');
mask.className = 'modal-mask';
mask.innerHTML = `
<div class="modal ${wide ? 'wide' : ''}">
<div class="modal-head">
<span>${escapeHtml(title)}</span>
<button class="close" data-close>×</button>
</div>
<div class="modal-body"></div>
<div class="modal-foot"></div>
</div>`;
const modal = mask.querySelector('.modal');
const bodyEl = mask.querySelector('.modal-body');
const footEl = mask.querySelector('.modal-foot');
if (typeof body === 'string') bodyEl.innerHTML = body;
else if (body) bodyEl.appendChild(body);
if (typeof foot === 'string') footEl.innerHTML = foot;
else if (foot) footEl.appendChild(foot);
const close = () => mask.remove();
mask.addEventListener('click', e => { if (e.target === mask) close(); });
mask.querySelector('[data-close]').addEventListener('click', close);
document.body.appendChild(mask);
return { mask, modal, bodyEl, footEl, close };
}
// ---------- Loading ----------
function loadingHtml(text = '加载中…') {
return `<div class="loading"><div class="spinner"></div><div>${escapeHtml(text)}</div></div>`;
}
// 连接状态
let CONN = { connected: false, base_url: '' };
function updateConnDot() {
const dots = document.querySelectorAll('.conn-dot');
dots.forEach(d => {
d.className = 'conn-dot ' + (CONN.connected ? 'on' : 'off');
});
const txt = document.querySelector('.conn-text');
if (txt) txt.textContent = CONN.connected
? `已连接 ${CONN.base_url || ''}`
: '未连接服务端';
}
+74
View File
@@ -0,0 +1,74 @@
// ============================================================
// DNF90 GM 工具 — 应用初始化与路由
// ============================================================
const PAGE_TITLES = {
dashboard: ['控制台', '/ 服务端总览'],
characters: ['角色管理', '/ 查询与编辑角色'],
items: ['道具发放', '/ 搜索并发放道具'],
mail: ['邮件系统', '/ 发送系统邮件'],
inventory: ['背包装备', '/ 查看与清理背包'],
quests: ['任务管理', '/ 查询与操作任务'],
dungeons: ['副本控制', '/ 副本状态与回城'],
wallet: ['钱包点券', '/ 钱包与黑钻'],
gameplay: ['玩法配置', '/ 掉率与倍率'],
channels: ['频道管理', '/ 频道列表与热重载'],
settings: ['连接设置', '/ 服务端连接'],
};
let currentPage = 'dashboard';
function renderPage(page) {
currentPage = page;
const [title, crumb] = PAGE_TITLES[page] || [page, ''];
document.getElementById('pageTitle').textContent = title;
document.getElementById('pageCrumb').textContent = crumb;
document.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.page === page));
const content = document.getElementById('content');
if (Pages[page]) {
Pages[page](content);
} else {
content.innerHTML = '<div class="empty">页面不存在</div>';
}
}
function renderCurrentPage() {
renderPage(currentPage);
}
// ---------- 导航绑定 ----------
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => renderPage(item.dataset.page));
});
// ---------- 顶部按钮 ----------
document.getElementById('btnRefresh').addEventListener('click', () => renderCurrentPage());
document.getElementById('btnDiscover').addEventListener('click', doDiscover);
// ---------- 启动 ----------
async function boot() {
// 尝试自动连接
try {
const health = await API.get('/api/health');
if (health.base_url) {
CONN.connected = true;
CONN.base_url = health.base_url;
}
} catch (e) { /* 忽略 */ }
// 尝试自动发现(若未连接)
if (!CONN.connected) {
try {
const r = await API.get('/api/discover');
if (r.ok) {
CONN.connected = true;
CONN.base_url = r.base_url;
}
} catch (e) { /* 忽略 */ }
}
updateConnDot();
renderPage('dashboard');
}
boot();
+844
View File
@@ -0,0 +1,844 @@
// ============================================================
// DNF90 GM 工具 — 页面模块
// ============================================================
const Pages = {};
// 当前选中的角色(跨页面共享)
let STATE = {
currentCharacter: null, // {character_id, name, ...}
};
function setCurrentCharacter(c) {
STATE.currentCharacter = c;
const hint = document.getElementById('selectedCharHint');
if (hint) {
hint.innerHTML = c
? `当前角色:<span style="color:#b79bff;font-weight:700">${escapeHtml(c.name)}</span> (ID ${c.character_id})`
: '未选择角色';
}
}
// ============================================================ 控制台
Pages.dashboard = async (el) => {
el.innerHTML = loadingHtml('正在获取服务端状态…');
try {
const [status, gameplay, channels] = await Promise.all([
API.get('/api/status').catch(() => null),
API.get('/api/gameplay').catch(() => null),
API.get('/api/channels').catch(() => null),
]);
CONN.connected = !!status;
updateConnDot();
const s = status || {};
el.innerHTML = `
<div class="grid grid-4">
<div class="stat-card accent">
<div class="label">在线角色</div>
<div class="value">${s.online_characters ?? 0}</div>
<div class="ico-bg">👥</div>
</div>
<div class="stat-card">
<div class="label">等级上限</div>
<div class="value">${s.level_cap ?? '—'}</div>
<div class="ico-bg">⬆️</div>
</div>
<div class="stat-card">
<div class="label">频道总数</div>
<div class="value">${channels?.total_channels ?? '—'}</div>
<div class="ico-bg">📡</div>
</div>
<div class="stat-card">
<div class="label">仓库状态</div>
<div class="value" style="font-size:20px">${s.repository_ready ? '✅ 就绪' : '⚠️ 异常'}</div>
<div class="ico-bg">🗄️</div>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="card-title"><span class="dot"></span>服务端健康</div>
<div class="grid grid-2">
${kv('仓库仓储', s.repository_ready ? '就绪' : '不可用', s.repository_ready)}
${kv('道具目录', s.item_catalog_ready ? '就绪' : '不可用', s.item_catalog_ready)}
${kv('服务端地址', CONN.base_url || '未知', true)}
${kv('等级上限', s.level_cap ?? '—', true)}
</div>
${s.error ? `<div class="hint" style="color:var(--red)">⚠ ${escapeHtml(s.error)}</div>` : ''}
</div>
<div class="card" style="margin-top:16px">
<div class="card-title"><span class="dot"></span>玩法配置</div>
${gameplay ? `
<div class="grid grid-2">
${kv('深渊史诗掉率', (gameplay.hell_drop_rate ?? 0) + '%', true)}
${kv('史诗药剂测试倍率', (gameplay.epic_potion_test_multiplier ?? 0) + '%', true)}
</div>` : '<div class="empty">暂无玩法配置数据</div>'}
</div>
<div class="card" style="margin-top:16px">
<div class="card-title"><span class="dot"></span>快速入口</div>
<div class="toolbar">
<button class="btn btn-primary" onclick="Pages.goto('characters')">👥 角色管理</button>
<button class="btn" onclick="Pages.goto('items')">🎁 道具发放</button>
<button class="btn" onclick="Pages.goto('mail')">✉️ 邮件系统</button>
<button class="btn" onclick="Pages.goto('quests')">📜 任务管理</button>
<button class="btn" onclick="Pages.goto('dungeons')">⚔️ 副本控制</button>
<button class="btn" onclick="Pages.goto('wallet')">💰 钱包点券</button>
</div>
</div>
`;
} catch (e) {
el.innerHTML = connectPanel();
}
};
function kv(label, value, ok) {
const color = ok ? 'var(--text-0)' : 'var(--red)';
return `<div style="padding:10px 0;border-bottom:1px solid var(--border)">
<span style="color:var(--text-2);font-size:13px">${escapeHtml(label)}</span>
<span style="float:right;color:${color};font-weight:600;font-size:13.5px">${escapeHtml(value)}</span>
</div>`;
}
function connectPanel() {
return `
<div class="card" style="max-width:640px;margin:40px auto">
<div class="card-title"><span class="dot"></span>连接服务端</div>
<p style="color:var(--text-2);font-size:13.5px;margin-bottom:18px;line-height:1.6">
无法连接到本地 DNF90 服务端。请确认服务端已启动,或手动填写连接信息。
</p>
<div class="conn-panel">
<div class="field" style="flex:1;min-width:120px">
<label>主机</label>
<input class="input" id="connHost" value="127.0.0.1">
</div>
<div class="field" style="width:110px">
<label>端口</label>
<input class="input" id="connPort" value="18111">
</div>
<div class="field" style="flex:1;min-width:160px">
<label>Admin Token</label>
<input class="input" id="connToken" placeholder="local-admin-token">
</div>
</div>
<div class="toolbar" style="margin-top:16px;margin-bottom:0">
<button class="btn btn-primary" onclick="doConnect()">连接</button>
<button class="btn" onclick="doDiscover()">🔍 自动发现</button>
</div>
</div>`;
}
async function doConnect() {
const host = document.getElementById('connHost').value.trim() || '127.0.0.1';
const port = parseInt(document.getElementById('connPort').value) || 18111;
const token = document.getElementById('connToken').value.trim();
try {
await API.post('/api/connect', { host, port, token });
toast('连接成功', 'ok');
CONN.connected = true;
updateConnDot();
renderCurrentPage();
} catch (e) {
toast(e.message, 'err');
}
}
async function doDiscover() {
toast('正在自动发现服务端…', 'info');
try {
const r = await API.get('/api/discover');
toast('发现服务端:' + r.base_url, 'ok');
CONN.connected = true;
updateConnDot();
renderCurrentPage();
} catch (e) {
toast(e.message, 'err');
}
}
// ============================================================ 角色管理
Pages.characters = async (el) => {
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>查询角色</div>
<div class="search-row">
<select class="select" id="charSearchType" style="width:130px">
<option value="account">按账号</option>
<option value="name">按角色名</option>
<option value="username">按登录用户名</option>
</select>
<input class="input" id="charSearch" placeholder="输入账号 / 角色名 / 用户名,留空查全部">
<button class="btn btn-primary" id="btnCharSearch">🔍 查询</button>
</div>
</div>
<div id="charList" class="card" style="margin-top:16px"></div>
`;
const doSearch = async () => {
const type = document.getElementById('charSearchType').value;
const q = document.getElementById('charSearch').value.trim();
const listEl = document.getElementById('charList');
listEl.innerHTML = loadingHtml('查询角色中…');
try {
const params = {};
if (q) {
if (type === 'account') params.account_id = q;
else if (type === 'name') params.character_name = q;
else params.username = q;
}
const qs = new URLSearchParams(params).toString();
const chars = await API.get('/api/characters' + (qs ? '?' + qs : ''));
renderCharList(listEl, chars);
} catch (e) {
listEl.innerHTML = `<div class="empty">查询失败:${escapeHtml(e.message)}</div>`;
}
};
document.getElementById('btnCharSearch').addEventListener('click', doSearch);
document.getElementById('charSearch').addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
// 默认加载
doSearch();
};
function renderCharList(el, chars) {
const list = Array.isArray(chars) ? chars : (chars?.data || []);
if (!list.length) {
el.innerHTML = '<div class="empty">没有找到角色</div>';
return;
}
el.innerHTML = `
<div class="card-title"><span class="dot"></span>角色列表 (${list.length})</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>角色</th><th>ID</th><th>账号ID</th><th>等级</th><th>金币</th><th>点券</th><th>状态</th><th>操作</th>
</tr></thead>
<tbody>
${list.map(c => `
<tr>
<td><div style="display:flex;align-items:center;gap:10px">
<div class="avatar">${escapeHtml((c.name || '?')[0])}</div>
<span style="font-weight:600">${escapeHtml(c.name)}</span>
</div></td>
<td>${c.character_id}</td>
<td style="color:var(--text-2)">${escapeHtml(c.account_id || '—')}</td>
<td><span class="tag tag-purple">Lv.${c.level ?? 0}</span></td>
<td class="num-gold">${fmt(c.gold)}</td>
<td class="num-cyan">${fmt(c.cera)}</td>
<td>${c.online ? '<span class="tag tag-green">在线</span>' : '<span class="tag tag-gray">离线</span>'}</td>
<td><div style="display:flex;gap:6px">
<button class="btn btn-xs btn-primary" onclick="openCharDetail('${c.character_id}')">详情</button>
<button class="btn btn-xs" onclick="openCharEdit('${c.character_id}','${escapeHtml(c.name||'')}',${c.level||0},${c.gold||0})">编辑</button>
</div></td>
</tr>`).join('')}
</tbody>
</table>
</div>`;
}
async function openCharDetail(id) {
openModal({ title: '角色详情', wide: true, body: loadingHtml('加载角色详情…') });
const bodyEl = document.querySelector('.modal:last-of-type .modal-body');
try {
const [detail, inv] = await Promise.all([
API.get(`/api/characters/${id}/detail`),
API.get(`/api/characters/${id}/inventory`).catch(() => null),
]);
const c = detail.character || detail;
bodyEl.innerHTML = `
<div class="grid grid-4" style="margin-bottom:18px">
${statMini('等级', c.level)}
${statMini('金币', fmt(c.gold))}
${statMini('职业', c.profession_name || c.job || '—')}
${statMini('状态', c.online ? '在线' : '离线')}
</div>
<div class="form-row">
${kv('角色ID', c.character_id)}
${kv('账号ID', c.account_id || '—')}
${kv('角色名', c.name)}
${kv('成长类型', c.grow_type ?? '—')}
</div>
<pre style="margin-top:16px;background:var(--bg-1);padding:16px;border-radius:10px;overflow:auto;font-size:12px;color:var(--text-2);max-height:300px">${escapeHtml(JSON.stringify(detail, null, 2))}</pre>`;
} catch (e) {
bodyEl.innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
}
}
function statMini(label, val) {
return `<div class="stat-card" style="padding:12px 16px">
<div class="label">${escapeHtml(label)}</div>
<div class="value" style="font-size:20px">${escapeHtml(String(val ?? '—'))}</div>
</div>`;
}
async function openCharEdit(id, name, level, gold) {
openModal({
title: `编辑角色 · ${name}`,
body: `
<div class="form-row">
<div class="field">
<label>等级</label>
<input class="input" id="editLevel" type="number" value="${level}" min="1" max="110">
</div>
<div class="field">
<label>金币</label>
<input class="input" id="editGold" type="number" value="${gold}">
</div>
<div class="field">
<label>技能点加成 (SP)</label>
<input class="input" id="editSp" type="number" value="0">
</div>
<div class="field">
<label>技能点加成 (TP)</label>
<input class="input" id="editTp" type="number" value="0">
</div>
</div>
<div class="hint">修改后角色需重新上线才完全生效。</div>`,
foot: `<button class="btn" data-close>取消</button>
<button class="btn btn-primary" onclick="saveCharEdit('${id}')">保存</button>`,
});
}
async function saveCharEdit(id) {
const level = parseInt(document.getElementById('editLevel').value);
const gold = parseInt(document.getElementById('editGold').value);
const sp = parseInt(document.getElementById('editSp').value);
const tp = parseInt(document.getElementById('editTp').value);
try {
if (level > 0) await API.post(`/api/characters/${id}/level`, { level });
if (!isNaN(gold)) await API.post(`/api/characters/${id}/gold`, { value: gold });
if (sp || tp) await API.post(`/api/characters/${id}/skill-points`, { bonus_sp: sp, bonus_tp: tp });
toast('修改成功', 'ok');
document.querySelector('.modal:last-of-type').remove();
renderCurrentPage();
} catch (e) {
toast(e.message, 'err');
}
}
// ============================================================ 道具发放
Pages.items = async (el) => {
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>道具搜索</div>
<div class="search-row">
<input class="input" id="itemSearch" placeholder="输入道具名称或 ID 搜索">
<button class="btn btn-primary" id="btnItemSearch">🔍 搜索</button>
</div>
<div id="itemSelected" class="hint" style="margin-top:10px"></div>
</div>
<div id="itemList" class="card" style="margin-top:16px"></div>
`;
let selectedItem = null;
const selEl = document.getElementById('itemSelected');
function renderSelected() {
if (!selectedItem) { selEl.innerHTML = '未选择道具'; return; }
selEl.innerHTML = `
<span style="color:var(--text-1)">已选择:</span>
<span class="${rarityColor(selectedItem.rarity)}" style="font-weight:700">
${escapeHtml(selectedItem.name || selectedItem.item_id)}
</span>
<span class="tag tag-gray" style="margin-left:8px">ID ${selectedItem.item_id}</span>`;
}
const doSearch = async () => {
const q = document.getElementById('itemSearch').value.trim();
const listEl = document.getElementById('itemList');
listEl.innerHTML = loadingHtml('搜索道具中…');
try {
const items = await API.get('/api/items/search?q=' + encodeURIComponent(q) + '&limit=50');
const list = Array.isArray(items) ? items : (items?.items || items?.data || []);
if (!list.length) { listEl.innerHTML = '<div class="empty">没有找到道具</div>'; return; }
listEl.innerHTML = `
<div class="card-title"><span class="dot"></span>搜索结果 (${list.length})</div>
<div class="table-wrap"><table>
<thead><tr><th>道具</th><th>ID</th><th>稀有度</th><th>类型</th><th>等级</th><th>操作</th></tr></thead>
<tbody>${list.map(it => `
<tr>
<td><span class="${rarityColor(it.rarity)}" style="font-weight:600">${escapeHtml(it.name || it.item_id)}</span></td>
<td>${it.item_id}</td>
<td><span class="${rarityColor(it.rarity)}">${rarityName(it.rarity)}</span></td>
<td style="color:var(--text-2)">${escapeHtml(it.kind || it.type || '—')}</td>
<td>${it.level ?? '—'}</td>
<td><button class="btn btn-xs btn-primary" onclick="selectItem('${it.item_id}')">选择</button></td>
</tr>`).join('')}
</tbody>
</table></div>`;
} catch (e) {
listEl.innerHTML = `<div class="empty">搜索失败:${escapeHtml(e.message)}</div>`;
}
};
window.selectItem = (id) => {
// 简单方式:重新查一次详情
API.get(`/api/items/${id}/detail`).then(detail => {
selectedItem = detail.item || detail || { item_id: id, name: '道具#' + id, rarity: 0 };
renderSelected();
toast('已选择道具', 'ok');
}).catch(() => {
selectedItem = { item_id: id, name: '道具#' + id, rarity: 0 };
renderSelected();
});
};
document.getElementById('btnItemSearch').addEventListener('click', doSearch);
document.getElementById('itemSearch').addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
};
// ============================================================ 邮件系统
Pages.mail = async (el) => {
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>发送邮件</div>
<div class="form-row">
<div class="field">
<label>收件角色名</label>
<input class="input" id="mailName" placeholder="角色名">
</div>
<div class="field">
<label>邮件标题</label>
<input class="input" id="mailTitle" placeholder="标题">
</div>
<div class="field full">
<label>邮件正文</label>
<textarea class="textarea" id="mailBody" placeholder="正文内容"></textarea>
</div>
<div class="field">
<label>附带金币</label>
<input class="input" id="mailGold" type="number" value="0">
</div>
<div class="field">
<label>附件道具ID(可选)</label>
<input class="input" id="mailItemId" type="number" placeholder="道具ID">
</div>
<div class="field">
<label>道具数量</label>
<input class="input" id="mailItemCount" type="number" value="1">
</div>
<div class="field">
<label>有效期(天)</label>
<input class="input" id="mailExpire" type="number" value="7">
</div>
</div>
<div class="toolbar" style="margin-top:16px;margin-bottom:0">
<button class="btn btn-primary" onclick="sendSystemMail()">📨 发送系统邮件</button>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="card-title"><span class="dot"></span>指定角色发件</div>
<div class="search-row">
<input class="input" id="mailCharId" placeholder="角色ID">
<button class="btn btn-primary" onclick="sendCharMail()">发送到角色</button>
</div>
<div class="hint">指定角色发件使用上方填写的标题、正文、道具与有效期。</div>
</div>
`;
};
async function sendSystemMail() {
const payload = {
character_name: document.getElementById('mailName').value.trim(),
title: document.getElementById('mailTitle').value.trim(),
body: document.getElementById('mailBody').value.trim(),
gold: parseInt(document.getElementById('mailGold').value) || 0,
};
const itemId = parseInt(document.getElementById('mailItemId').value);
if (itemId) {
payload.attachments = [{
item_id: itemId,
count: parseInt(document.getElementById('mailItemCount').value) || 1,
bind: false,
}];
}
if (!payload.character_name) { toast('请输入收件角色名', 'err'); return; }
try {
await API.post('/api/system-mail', payload);
toast('邮件已发送', 'ok');
} catch (e) { toast(e.message, 'err'); }
}
async function sendCharMail() {
const cid = document.getElementById('mailCharId').value.trim();
if (!cid) { toast('请输入角色ID', 'err'); return; }
const payload = {
item_id: parseInt(document.getElementById('mailItemId').value) || 0,
count: parseInt(document.getElementById('mailItemCount').value) || 1,
title: document.getElementById('mailTitle').value.trim(),
body: document.getElementById('mailBody').value.trim(),
expire_days: parseInt(document.getElementById('mailExpire').value) || 7,
};
try {
await API.post(`/api/characters/${cid}/mail`, payload);
toast('邮件已发送', 'ok');
} catch (e) { toast(e.message, 'err'); }
}
// ============================================================ 背包装备
Pages.inventory = async (el) => {
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>查看背包</div>
<div class="search-row">
<input class="input" id="invCharId" placeholder="输入角色ID">
<button class="btn btn-primary" id="btnInvLoad">查看</button>
<button class="btn btn-danger" id="btnInvClear">清空背包</button>
</div>
</div>
<div id="invList" class="card" style="margin-top:16px"></div>
`;
document.getElementById('btnInvLoad').addEventListener('click', loadInventory);
document.getElementById('btnInvClear').addEventListener('click', () => {
const cid = document.getElementById('invCharId').value.trim();
if (!cid) { toast('请输入角色ID', 'err'); return; }
if (!confirm('确定要清空该角色背包吗?此操作不可逆!')) return;
API.post(`/api/characters/${cid}/inventory/clear`, { container: 'inventory' })
.then(() => { toast('背包已清空', 'ok'); loadInventory(); })
.catch(e => toast(e.message, 'err'));
});
};
async function loadInventory() {
const cid = document.getElementById('invCharId').value.trim();
if (!cid) { toast('请输入角色ID', 'err'); return; }
const listEl = document.getElementById('invList');
listEl.innerHTML = loadingHtml('加载背包…');
try {
const inv = await API.get(`/api/characters/${cid}/inventory`);
const items = inv.items || inv.data || [];
listEl.innerHTML = `
<div class="card-title"><span class="dot"></span>背包内容 (${items.length})</div>
<div class="table-wrap"><table>
<thead><tr><th>道具</th><th>ID</th><th>数量</th><th>稀有度</th><th>操作</th></tr></thead>
<tbody>${items.map(it => `
<tr>
<td style="font-weight:600">${escapeHtml(it.name || it.item_name || it.item_id)}</td>
<td>${it.item_id}</td>
<td>${it.count ?? 1}</td>
<td><span class="${rarityColor(it.rarity)}">${rarityName(it.rarity)}</span></td>
<td><button class="btn btn-xs btn-danger" onclick="delInvItem('${cid}','${escapeHtml(it.key||it.item_id)}')">删除</button></td>
</tr>`).join('')}
</tbody>
</table></div>`;
} catch (e) {
listEl.innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
}
}
async function delInvItem(cid, key) {
if (!confirm('确定删除该道具?')) return;
try {
await API.post(`/api/characters/${cid}/inventory/delete`, { container: 'inventory', key, count: 1 });
toast('已删除', 'ok');
loadInventory();
} catch (e) { toast(e.message, 'err'); }
}
// ============================================================ 任务管理
Pages.quests = async (el) => {
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>任务查询</div>
<div class="search-row">
<input class="input" id="questCharId" placeholder="角色ID">
<input class="input" id="questSearch" placeholder="任务名/ID(可选)">
<button class="btn btn-primary" id="btnQuestLoad">查询</button>
</div>
</div>
<div id="questList" class="card" style="margin-top:16px"></div>
`;
document.getElementById('btnQuestLoad').addEventListener('click', loadQuests);
};
async function loadQuests() {
const cid = document.getElementById('questCharId').value.trim();
if (!cid) { toast('请输入角色ID', 'err'); return; }
const q = document.getElementById('questSearch').value.trim();
const listEl = document.getElementById('questList');
listEl.innerHTML = loadingHtml('加载任务…');
try {
let quests;
if (q) {
quests = await API.get(`/api/characters/${cid}/quests/search?q=${encodeURIComponent(q)}&limit=100`);
} else {
quests = await API.get(`/api/characters/${cid}/quests`);
}
const active = quests.active || [];
const completed = quests.completed || [];
listEl.innerHTML = `
<div class="card-title"><span class="dot"></span>进行中任务 (${active.length})</div>
<div class="table-wrap"><table>
<thead><tr><th>任务</th><th>ID</th><th>类型</th><th>等级</th><th>操作</th></tr></thead>
<tbody>${active.map(q => `
<tr>
<td style="font-weight:600">${escapeHtml(q.name)}</td>
<td>${q.quest_id}</td>
<td style="color:var(--text-2)">${escapeHtml(q.type || '—')}</td>
<td>${q.level_min ?? '—'}</td>
<td><div style="display:flex;gap:5px">
<button class="btn btn-xs btn-primary" onclick="questAct('${cid}',${q.quest_id},'complete')">完成</button>
<button class="btn btn-xs" onclick="questAct('${cid}',${q.quest_id},'give-up')">放弃</button>
</div></td>
</tr>`).join('')}
</tbody>
</table></div>
${completed.length ? `
<div class="card-title" style="margin-top:20px"><span class="dot"></span>已完成任务 (${completed.length})</div>
<div class="table-wrap"><table>
<thead><tr><th>任务</th><th>ID</th><th>类型</th><th>操作</th></tr></thead>
<tbody>${completed.map(q => `
<tr>
<td>${escapeHtml(q.name)}</td>
<td>${q.quest_id}</td>
<td style="color:var(--text-2)">${escapeHtml(q.type || '—')}</td>
<td><button class="btn btn-xs" onclick="questAct('${cid}',${q.quest_id},'uncomplete')">撤销完成</button></td>
</tr>`).join('')}
</tbody>
</table></div>` : ''}`;
} catch (e) {
listEl.innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
}
}
async function questAct(cid, qid, action) {
const labels = { complete: '完成', 'give-up': '放弃', uncomplete: '撤销完成', accept: '接受', ready: '就绪' };
if (!confirm(`确定要「${labels[action]}」该任务吗?`)) return;
try {
await API.post(`/api/characters/${cid}/quests/${qid}/${action}`);
toast('操作成功', 'ok');
loadQuests();
} catch (e) { toast(e.message, 'err'); }
}
// ============================================================ 副本控制
Pages.dungeons = async (el) => {
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>副本控制</div>
<div class="search-row">
<input class="input" id="dungeonCharId" placeholder="角色ID">
<button class="btn btn-primary" id="btnDungeonLoad">查看副本状态</button>
<button class="btn" id="btnDungeonUnlockDiff">解锁全部难度</button>
<button class="btn" id="btnDungeonUnlockHell">解锁深渊模式</button>
<button class="btn btn-danger" id="btnDungeonReturn">安全回城</button>
</div>
</div>
<div id="dungeonInfo" class="card" style="margin-top:16px"></div>
`;
const cid = () => document.getElementById('dungeonCharId').value.trim();
const load = async () => {
if (!cid()) { toast('请输入角色ID', 'err'); return; }
const infoEl = document.getElementById('dungeonInfo');
infoEl.innerHTML = loadingHtml('加载副本状态…');
try {
const [dungeons, control] = await Promise.all([
API.get(`/api/characters/${cid()}/dungeons`),
API.get(`/api/characters/${cid()}/dungeon-control`).catch(() => null),
]);
const st = control?.state || {};
infoEl.innerHTML = `
<div class="card-title"><span class="dot"></span>副本状态</div>
<div class="grid grid-3">
${statMini('是否在线', st.online ? '在线' : '离线')}
${statMini('是否在副本', st.in_dungeon ? '是' : '否')}
${statMini('运行状态', st.run_status || '—')}
</div>
${st.in_dungeon ? `
<div class="form-row" style="margin-top:16px">
${kv('副本ID', st.dungeon_id ?? '—')}
${kv('地图ID', st.map_id ?? '—')}
${kv('当前房间', st.current_room ?? '—')}
${kv('已访问房间', st.visited_room_count ?? '—')}
${kv('已清怪数', st.defeated_object_count ?? '—')}
${kv('可回城', st.can_return_to_town ? '是' : '否')}
</div>` : ''}
<pre style="margin-top:16px;background:var(--bg-1);padding:16px;border-radius:10px;overflow:auto;font-size:12px;color:var(--text-2);max-height:260px">${escapeHtml(JSON.stringify(dungeons, null, 2))}</pre>`;
} catch (e) {
infoEl.innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
}
};
document.getElementById('btnDungeonLoad').addEventListener('click', load);
document.getElementById('btnDungeonUnlockDiff').addEventListener('click', async () => {
if (!cid()) return toast('请输入角色ID', 'err');
try { await API.post(`/api/characters/${cid()}/dungeons/unlock-difficulties`); toast('已解锁全部难度', 'ok'); } catch (e) { toast(e.message, 'err'); }
});
document.getElementById('btnDungeonUnlockHell').addEventListener('click', async () => {
if (!cid()) return toast('请输入角色ID', 'err');
try { await API.post(`/api/characters/${cid()}/dungeons/unlock-hell`); toast('已解锁深渊模式', 'ok'); } catch (e) { toast(e.message, 'err'); }
});
document.getElementById('btnDungeonReturn').addEventListener('click', async () => {
if (!cid()) return toast('请输入角色ID', 'err');
if (!confirm('确定要将该角色安全送回城镇吗?')) return;
try { await API.post(`/api/characters/${cid()}/dungeon-return`); toast('已请求回城', 'ok'); load(); } catch (e) { toast(e.message, 'err'); }
});
};
// ============================================================ 钱包点券
Pages.wallet = async (el) => {
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>钱包点券</div>
<div class="search-row">
<input class="input" id="walletCharId" placeholder="角色ID">
<button class="btn btn-primary" id="btnWalletQuery">查询钱包</button>
<button class="btn" onclick="bdStatus()">黑钻状态</button>
<button class="btn btn-primary" onclick="bdActivate()">开通黑钻</button>
</div>
</div>
<div id="walletInfo" class="card" style="margin-top:16px"></div>
`;
document.getElementById('btnWalletQuery').addEventListener('click', queryWallet);
};
async function queryWallet() {
const cid = document.getElementById('walletCharId').value.trim();
if (!cid) { toast('请输入角色ID', 'err'); return; }
const infoEl = document.getElementById('walletInfo');
infoEl.innerHTML = loadingHtml('查询钱包…');
try {
const wallet = await API.post(`/api/characters/${cid}/wallet/query`);
infoEl.innerHTML = `
<div class="card-title"><span class="dot"></span>钱包信息</div>
<pre style="background:var(--bg-1);padding:16px;border-radius:10px;overflow:auto;font-size:13px;color:var(--text-1)">${escapeHtml(JSON.stringify(wallet, null, 2))}</pre>`;
} catch (e) {
infoEl.innerHTML = `<div class="empty">查询失败:${escapeHtml(e.message)}</div>`;
}
}
async function bdStatus() {
const cid = document.getElementById('walletCharId').value.trim();
try {
const r = await API.get('/api/black-diamond' + (cid ? '?id=' + cid : ''));
const infoEl = document.getElementById('walletInfo');
infoEl.innerHTML = `<div class="card-title"><span class="dot"></span>黑钻状态</div>
<pre style="background:var(--bg-1);padding:16px;border-radius:10px;overflow:auto;font-size:13px;color:var(--text-1)">${escapeHtml(JSON.stringify(r, null, 2))}</pre>`;
} catch (e) { toast(e.message, 'err'); }
}
async function bdActivate() {
const cid = document.getElementById('walletCharId').value.trim();
if (!cid) { toast('请输入角色ID', 'err'); return; }
const days = prompt('开通黑钻天数(默认30天)', '30');
if (days === null) return;
try {
await API.post('/api/black-diamond/activate', { character_id: cid, days: parseInt(days) || 30 });
toast('黑钻已开通', 'ok');
bdStatus();
} catch (e) { toast(e.message, 'err'); }
}
// ============================================================ 玩法配置
Pages.gameplay = async (el) => {
el.innerHTML = `<div class="card"><div class="card-title"><span class="dot"></span>玩法配置</div></div>`;
const card = el.querySelector('.card');
try {
const gp = await API.get('/api/gameplay');
card.innerHTML = `
<div class="card-title"><span class="dot"></span>玩法配置</div>
<div class="form-row">
<div class="field">
<label>深渊史诗掉率(%)</label>
<input class="input" id="gpHellRate" type="number" value="${gp.hell_drop_rate ?? 0}">
</div>
<div class="field">
<label>史诗药剂测试倍率(%)</label>
<input class="input" id="gpPotion" type="number" value="${gp.epic_potion_test_multiplier ?? 0}">
</div>
</div>
<div class="toolbar" style="margin-top:16px;margin-bottom:0">
<button class="btn btn-primary" onclick="saveGameplay()">保存配置</button>
</div>`;
} catch (e) {
card.innerHTML = `<div class="card-title"><span class="dot"></span>玩法配置</div>
<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
}
};
async function saveGameplay() {
const hellRate = parseInt(document.getElementById('gpHellRate').value) || 0;
const potion = parseInt(document.getElementById('gpPotion').value) || 0;
try {
await API.post('/api/gameplay/hell-drop-rate', { percent: hellRate });
await API.post('/api/gameplay/epic-potion-multiplier', { percent: potion });
toast('配置已保存', 'ok');
} catch (e) { toast(e.message, 'err'); }
}
// ============================================================ 频道管理
Pages.channels = async (el) => {
el.innerHTML = loadingHtml('加载频道…');
try {
const channels = await API.get('/api/channels');
const list = channels.channels || [];
el.innerHTML = `
<div class="card">
<div class="card-title"><span class="dot"></span>频道总览</div>
<div class="grid grid-4">
${statMini('频道总数', channels.total_channels)}
${statMini('已启用', channels.enabled_channels)}
${statMini('游戏端口', channels.game_ports)}
${statMini('单机频道', (channels.single_player_ids || []).join(', ') || '—')}
</div>
<div class="toolbar" style="margin-top:16px;margin-bottom:0">
<button class="btn btn-primary" onclick="reloadChannels()">🔄 热重载频道</button>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="card-title"><span class="dot"></span>频道列表</div>
<div class="table-wrap"><table>
<thead><tr><th>ID</th><th>名称</th><th>分组</th><th>端口</th><th>类型</th><th>状态</th></tr></thead>
<tbody>${list.map(c => `
<tr>
<td>${c.id}</td>
<td style="font-weight:600">${escapeHtml(c.name || '—')}</td>
<td style="color:var(--text-2)">${escapeHtml(c.group || '—')}</td>
<td>${c.port ?? '—'}</td>
<td>${c.type === 0 ? '普通' : '特殊'}</td>
<td>${c.enabled ? '<span class="tag tag-green">启用</span>' : '<span class="tag tag-gray">禁用</span>'}</td>
</tr>`).join('')}
</tbody>
</table></div>
</div>`;
} catch (e) {
el.innerHTML = `<div class="card"><div class="empty">加载失败:${escapeHtml(e.message)}</div></div>`;
}
};
async function reloadChannels() {
try {
await API.post('/api/channels/reload');
toast('频道已热重载', 'ok');
renderCurrentPage();
} catch (e) { toast(e.message, 'err'); }
}
// ============================================================ 连接设置
Pages.settings = async (el) => {
el.innerHTML = `
<div class="card" style="max-width:640px">
<div class="card-title"><span class="dot"></span>连接设置</div>
<div class="conn-panel">
<div class="field" style="flex:1;min-width:120px">
<label>主机</label>
<input class="input" id="connHost" value="127.0.0.1">
</div>
<div class="field" style="width:110px">
<label>端口</label>
<input class="input" id="connPort" value="18111">
</div>
<div class="field" style="flex:1;min-width:160px">
<label>Admin Token</label>
<input class="input" id="connToken" placeholder="local-admin-token">
</div>
</div>
<div class="toolbar" style="margin-top:16px;margin-bottom:0">
<button class="btn btn-primary" onclick="doConnect()">连接</button>
<button class="btn" onclick="doDiscover()">🔍 自动发现</button>
</div>
<div class="hint" style="margin-top:14px">当前后端地址:${CONN.base_url || '未连接'}</div>
</div>`;
};
Pages.goto = (page) => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.page === page));
renderPage(page);
};