"""
幸福工厂服务器管理器 - Web 控制端
提供 HTTP API 和 Web 界面，支持远程管理服务器
未登录只能查看运行概览，登录后才能操作所有功能
"""
import os, sys, json, time, threading, subprocess, shutil, psutil, hashlib, secrets, socket, zipfile, io, requests
from datetime import datetime, timedelta
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from urllib.parse import urlparse, parse_qs

# 全局设置
LOCAL_VERSION = "1.0.0"

# 更新通道：稳定版 / 测试版
UPDATE_CHANNELS = {
    "stable": {
        "name": "稳定版",
        "version_url": "http://nas.sxtvip.top:5050/Release/version.json",
        "changelog_url": "http://nas.sxtvip.top:5050/Release/changelog.json",
        "package_url": "http://nas.sxtvip.top:5050/Release/幸福工厂服务器管理器.zip",
    },
    "test": {
        "name": "测试版",
        "version_url": "http://nas.sxtvip.top:5050/test/version.json",
        "changelog_url": "http://nas.sxtvip.top:5050/test/changelog.json",
        "package_url": "http://nas.sxtvip.top:5050/test/幸福工厂服务器管理器.zip",
    },
}

def _get_channel_urls():
    """获取当前更新通道的 URL 配置"""
    ch = load_config().get("update_channel", "stable")
    return UPDATE_CHANNELS.get(ch, UPDATE_CHANNELS["stable"])

# version.json 指定的更新包地址（检查更新时刷新）
_remote_pkg_url = ""
TEMP_DOWNLOAD_DIR = "temp_update"
UPDATE_ZIP_NAME = "update_package.zip"

# ==================== 配置 ====================
WEB_PORT = 8899
# 使用绝对路径，确保与主程序读写同一个配置文件
_CONFIG_DIR = os.path.dirname(os.path.abspath(__file__)) if not getattr(sys, 'frozen', False) else os.path.dirname(os.path.abspath(sys.executable))
CONFIG_FILE = os.path.join(_CONFIG_DIR, "server_config.json")

# 主程序控制器引用（由主程序设置，用于共享服务器状态）
_controller_ref = None

# CPU/内存历史数据（用于 Web 端折线图）
_perf_history = []          # [{"t": "HH:MM", "cpu": x, "mem": y}]
_perf_history_lock = threading.Lock()
_PERF_HISTORY_MAX = 360     # 最多保留 360 点（5秒/点 ≈ 30分钟）


def _perf_sampler():
    """后台线程：每 5 秒采集一次 CPU/内存占用写入历史"""
    while True:
        try:
            cpu = psutil.cpu_percent(interval=None)
            mem = psutil.virtual_memory().percent
            point = {"t": datetime.now().strftime("%H:%M"), "cpu": round(cpu, 1), "mem": round(mem, 1)}
            with _perf_history_lock:
                _perf_history.append(point)
                if len(_perf_history) > _PERF_HISTORY_MAX:
                    del _perf_history[:len(_perf_history) - _PERF_HISTORY_MAX]
        except Exception:
            pass
        time.sleep(5)

def set_controller(ctrl):
    """设置主程序控制器引用，用于共享服务器运行状态"""
    global _controller_ref
    _controller_ref = ctrl

def get_server_running():
    """获取服务器真实运行状态（基于 API 响应）"""
    if _controller_ref is not None:
        try:
            return bool(_controller_ref.server_state)
        except Exception:
            pass
    # 无主程序引用时直接查询 API
    cfg = load_config()
    state = query_server_state(cfg.get('monitor_host', '127.0.0.1') or '127.0.0.1', cfg.get('game_port', 7777), cfg.get('api_token', ''))
    return bool(state)

# ==================== 日志 ====================
web_logs = []
_logs_lock = threading.Lock()
_last_log_cleanup = ""

def get_log_dir():
    """获取操作日志目录: install_path/manager_logs/"""
    try:
        root = load_config().get("install_path", "")
    except Exception:
        root = ""
    if not root:
        return ""
    log_dir = os.path.join(root, "manager_logs")
    try:
        os.makedirs(log_dir, exist_ok=True)
    except Exception:
        return ""
    return log_dir

def write_operation_log(line):
    """将操作日志持久化到安装目录下的日志文件（按天分文件）"""
    log_dir = get_log_dir()
    if not log_dir:
        return
    try:
        fname = f"operation_{datetime.now().strftime('%Y%m%d')}.log"
        with open(os.path.join(log_dir, fname), 'a', encoding='utf-8') as f:
            f.write(line + "\n")
    except Exception:
        pass
    # 每天清理一次过期日志
    global _last_log_cleanup
    today = datetime.now().strftime("%Y%m%d")
    if _last_log_cleanup != today:
        _last_log_cleanup = today
        cleanup_old_logs()

def cleanup_old_logs():
    """清理超过保留期的操作日志（默认7天）"""
    log_dir = get_log_dir()
    if not log_dir:
        return
    try:
        retain_days = load_config().get("log_retain_days", 7)
    except Exception:
        retain_days = 7
    cutoff_str = (datetime.now() - timedelta(days=retain_days)).strftime("%Y%m%d")
    try:
        for fname in os.listdir(log_dir):
            if fname.startswith("operation_") and fname.endswith(".log"):
                date_part = fname[len("operation_"):-len(".log")]
                if date_part < cutoff_str:
                    os.remove(os.path.join(log_dir, fname))
    except Exception:
        pass

def read_all_operation_logs(limit=500):
    """读取所有操作日志（合并所有按天分文件，返回最近 limit 条）"""
    log_dir = get_log_dir()
    if not log_dir or not os.path.isdir(log_dir):
        return []
    lines = []
    for fname in sorted(os.listdir(log_dir)):
        if fname.startswith("operation_") and fname.endswith(".log"):
            try:
                date_part = fname[len("operation_"):-len(".log")]
                with open(os.path.join(log_dir, fname), 'r', encoding='utf-8') as f:
                    for ln in f:
                        ln = ln.rstrip("\n")
                        if ln:
                            lines.append(f"[{date_part}] {ln}")
            except Exception:
                pass
    return lines[-limit:]

def wlog(msg):
    ts = datetime.now().strftime("%H:%M:%S")
    line = f"[{ts}] {msg}"
    with _logs_lock:
        web_logs.append(line)
        if len(web_logs) > 500:
            web_logs.pop(0)
    write_operation_log(line)
    print(line)

# ==================== 配置管理 ====================
def load_config():
    default = {
        "install_path": "", "max_players": 4, "auto_restart": True, "auto_start": False,
        "branch": "public", "game_port": 7777, "beacon_port": 8888, "autosave_count": 5,
        "backup_interval": 30, "backup_retain": 10, "enable_auto_backup": False,
        "monitor_host": "localhost",
        "update_channel": "stable",
        "backup_locations": [{"type": "local", "path": "", "enabled": True}],
        "schedule_enabled": False, "use_unified_schedule": True,
        "custom_schedule_days": {d: {"enabled": True, "start": "17:00", "end": "02:00"}
                                 for d in ['monday','tuesday','wednesday','thursday','friday','saturday','sunday']},
        "unified_start_time": "17:00", "unified_end_time": "02:00",
        "save_game_path": os.path.join(os.getenv('LOCALAPPDATA', ''), "FactoryGame", "Saved", "SaveGames"),
        "saved_autosaves": 10, "custom_startup_params": "",
        "appearance_mode": "Dark", "color_theme": "blue",
        "layout_key": "classic_sidebar",
        "web_enabled": False, "web_port": 8899, "web_password": "123456",
    }
    if os.path.exists(CONFIG_FILE):
        try:
            with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
                cfg = json.load(f)
            for k, v in default.items():
                if k not in cfg:
                    cfg[k] = v
            return cfg
        except Exception:
            pass
    return default

def save_config(cfg):
    with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
        json.dump(cfg, f, indent=4, ensure_ascii=False)

def get_web_password():
    """获取Web登录密码（从配置文件读取）"""
    cfg = load_config()
    return cfg.get("web_password", "123456")

# ==================== 会话管理 ====================
sessions = {}  # token -> expire_time
_sessions_lock = threading.Lock()
SESSION_TTL = 3600  # 1小时过期

def create_session():
    token = secrets.token_hex(32)
    with _sessions_lock:
        sessions[token] = time.time() + SESSION_TTL
        # 清理过期会话
        now = time.time()
        for t in list(sessions.keys()):
            if sessions[t] < now:
                del sessions[t]
    return token

def verify_session(token):
    if not token:
        return False
    with _sessions_lock:
        exp = sessions.get(token)
        if not exp:
            return False
        if exp < time.time():
            del sessions[token]
            return False
        # 续期
        sessions[token] = time.time() + SESSION_TTL
    return True

# ==================== 服务器进程管理 ====================
class ServerManager:
    def __init__(self):
        self.process = None
        self.lock = threading.Lock()
        self.running = False
        self._stop_requested = False

    @property
    def is_running(self):
        if self.process is None:
            return False
        return self.process.poll() is None

    def start(self, cfg):
        with self.lock:
            if self.is_running:
                return {"success": False, "message": "服务器已在运行中"}
            root = cfg.get("install_path", "")
            if not root:
                return {"success": False, "message": "请先设置安装根目录"}
            sd = os.path.join(root, "server")
            exe = os.path.join(sd, "FactoryServer.exe")
            if not os.path.exists(exe):
                return {"success": False, "message": f"服务器文件不存在: {exe}"}
            self._stop_requested = False
            args = [exe,
                    f"-Port={cfg.get('game_port', 7777)}",
                    f"-BeaconPort={cfg.get('beacon_port', 8888)}",
                    f"-ini:Game:[/Script/Engine.GameSession]:MaxPlayers={cfg.get('max_players', 4)}"]
            cp = cfg.get("custom_startup_params", "").strip()
            if cp:
                args.extend(cp.split())
            wlog(f"启动服务器: {' '.join(args)}")
            try:
                self.process = subprocess.Popen(args, cwd=sd,
                                                stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                                                bufsize=1)
                self.running = True
                threading.Thread(target=self._read_output, daemon=True).start()
                return {"success": True, "message": "服务器启动中..."}
            except Exception as e:
                return {"success": False, "message": f"启动失败: {e}"}

    def stop(self):
        with self.lock:
            if not self.is_running:
                return {"success": False, "message": "服务器未运行"}
            self._stop_requested = True
            wlog("正在停止服务器...")
            try:
                self.process.terminate()
                try:
                    self.process.wait(timeout=10)
                except subprocess.TimeoutExpired:
                    self.process.kill()
                    self.process.wait(timeout=5)
                self.running = False
                self.process = None
                wlog("服务器已停止")
                return {"success": True, "message": "服务器已停止"}
            except Exception as e:
                return {"success": False, "message": f"停止失败: {e}"}

    def restart(self, cfg):
        self.stop()
        time.sleep(2)
        return self.start(cfg)

    def _read_output(self):
        try:
            while self.process and self.process.stdout:
                line = self.process.stdout.readline()
                if not line and self.process.poll() is not None:
                    break
                if line:
                    try:
                        text = line.decode('utf-8', errors='replace').strip()
                        if text:
                            wlog(f"[Server] {text}")
                    except Exception:
                        pass
        except Exception:
            pass
        self.running = False
        wlog("服务器进程已结束")

# ==================== 备份管理 ====================
def create_backup(cfg):
    root = cfg.get("install_path", "")
    if not root:
        return {"success": False, "message": "未设置安装路径"}
    sd = os.path.join(root, "server")
    backup_dir = os.path.join(root, "backups")
    os.makedirs(backup_dir, exist_ok=True)
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    backup_name = f"backup_{ts}"
    backup_path = os.path.join(backup_dir, backup_name)
    try:
        save_path = cfg.get("save_game_path", "")
        if save_path and os.path.exists(save_path):
            shutil.copytree(save_path, os.path.join(backup_path, "SaveGames"))
        server_ini = os.path.join(sd, "FactoryServer", "Saved", "Config", "WindowsServer")
        if os.path.exists(server_ini):
            shutil.copytree(server_ini, os.path.join(backup_path, "Config"))
        wlog(f"备份完成: {backup_name}")
        return {"success": True, "message": f"备份完成: {backup_name}", "name": backup_name}
    except Exception as e:
        return {"success": False, "message": f"备份失败: {e}"}

def get_backup_dirs(cfg):
    """获取所有备份根目录（主程序自动备份位置 + 默认 backups 目录）"""
    dirs = []
    for loc in cfg.get("backup_locations", []):
        if loc.get("enabled") and loc.get("type") == "local":
            p = loc.get("path", "")
            if p and os.path.isdir(p) and p not in dirs:
                dirs.append(p)
    root = cfg.get("install_path", "")
    if root:
        default_bp = os.path.join(root, "backups")
        if os.path.isdir(default_bp) and default_bp not in dirs:
            dirs.append(default_bp)
    return dirs

def list_backups(cfg):
    backups = []
    seen = set()
    for backup_dir in get_backup_dirs(cfg):
        try:
            names = os.listdir(backup_dir)
        except Exception:
            continue
        for name in names:
            if name in seen:
                continue
            path = os.path.join(backup_dir, name)
            if os.path.isdir(path):
                stat = os.stat(path)
                size = sum(os.path.getsize(os.path.join(dp, f)) for dp, dn, fn in os.walk(path) for f in fn)
                backups.append({"name": name, "time": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"), "size_mb": round(size / (1024 * 1024), 1)})
                seen.add(name)
    backups.sort(key=lambda x: x["time"], reverse=True)
    return backups

def delete_backup(cfg, name):
    safe_name = os.path.basename(name)
    for d in get_backup_dirs(cfg):
        backup_dir = os.path.join(d, safe_name)
        if os.path.isdir(backup_dir):
            shutil.rmtree(backup_dir)
            wlog(f"删除备份: {safe_name}")
            return {"success": True, "message": f"已删除: {safe_name}"}
    return {"success": False, "message": "备份不存在"}

# ==================== 蓝图管理 ====================
def get_blueprint_dir(cfg, custom_dir=''):
    r"""获取蓝图目录，优先使用配置保存的路径"""
    if custom_dir and os.path.isdir(custom_dir):
        return custom_dir
    # 配置中保存的路径（与主程序同步）
    saved = cfg.get("blueprint_path", "")
    if saved and os.path.isdir(saved):
        return saved
    # 默认路径
    local_appdata = os.environ.get("LOCALAPPDATA", "")
    if not local_appdata:
        return ""
    bp_dir = os.path.join(local_appdata, "FactoryGame", "Saved", "SaveGames", "blueprints")
    os.makedirs(bp_dir, exist_ok=True)
    return bp_dir

def list_blueprints(cfg, custom_dir=''):
    """列出所有蓝图文件"""
    bp_dir = get_blueprint_dir(cfg, custom_dir)
    if not bp_dir or not os.path.exists(bp_dir):
        return []
    blueprints = []
    for name in sorted(os.listdir(bp_dir)):
        path = os.path.join(bp_dir, name)
        if os.path.isfile(path):
            stat = os.stat(path)
            blueprints.append({
                "name": name,
                "time": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
                "size_kb": round(stat.st_size / 1024, 1)
            })
    return blueprints

def upload_blueprint(cfg, file_name, file_data_b64, custom_dir=''):
    """上传蓝图文件"""
    import base64
    bp_dir = get_blueprint_dir(cfg, custom_dir)
    if not bp_dir:
        return {"success": False, "message": "未设置安装路径"}
    # 安全检查：防止路径遍历
    safe_name = os.path.basename(file_name)
    if not safe_name or '..' in safe_name:
        return {"success": False, "message": "无效的文件名"}
    if not (safe_name.endswith('.sbp') or safe_name.endswith('.sbpcfg')):
        return {"success": False, "message": "只支持 .sbp 或 .sbpcfg 格式的蓝图文件"}
    try:
        file_data = base64.b64decode(file_data_b64)
        dest = os.path.join(bp_dir, safe_name)
        # 如果文件已存在，添加时间戳
        if os.path.exists(dest):
            ts = datetime.now().strftime("%Y%m%d_%H%M%S")
            base, ext = os.path.splitext(safe_name)
            safe_name = f"{base}_{ts}{ext}"
            dest = os.path.join(bp_dir, safe_name)
        with open(dest, 'wb') as f:
            f.write(file_data)
        wlog(f"蓝图上传成功: {safe_name} ({len(file_data)} bytes)")
        return {"success": True, "message": f"蓝图已上传: {safe_name}", "name": safe_name}
    except Exception as e:
        return {"success": False, "message": f"上传失败: {e}"}

def delete_blueprint(cfg, name, custom_dir=''):
    """删除蓝图文件"""
    bp_dir = get_blueprint_dir(cfg, custom_dir)
    if not bp_dir:
        return {"success": False, "message": "未设置安装路径"}
    safe_name = os.path.basename(name)
    path = os.path.join(bp_dir, safe_name)
    if os.path.exists(path):
        os.remove(path)
        wlog(f"删除蓝图: {safe_name}")
        return {"success": True, "message": f"已删除: {safe_name}"}
    return {"success": False, "message": "蓝图不存在"}

# ==================== 存档管理 ====================
def list_saves(cfg):
    save_path = cfg.get("save_game_path", "")
    if not save_path or not os.path.exists(save_path):
        return []
    saves = []
    for name in sorted(os.listdir(save_path), reverse=True):
        path = os.path.join(save_path, name)
        if os.path.isdir(path):
            stat = os.stat(path)
            size = sum(os.path.getsize(os.path.join(dp, f)) for dp, dn, fn in os.walk(path) for f in fn)
            saves.append({"name": name, "time": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"), "size_mb": round(size / (1024 * 1024), 1)})
    return saves

# ==================== 系统信息 ====================
def query_server_players(host='127.0.0.1', game_port=7777, api_token=''):
    """通过 Satisfactory HTTPS API 查询实时玩家数 (QueryServerState)"""
    data = query_server_state(host, game_port, api_token)
    gs = data.get("serverGameState", {}) if data else {}
    return gs.get("numConnectedPlayers", 0)

def query_server_state(host='127.0.0.1', game_port=7777, api_token=''):
    """通过 Satisfactory HTTPS API 查询服务器完整状态，返回完整 data 字典"""
    try:
        import urllib3
        urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
    except ImportError:
        pass
    try:
        url = f"https://{host}:{game_port}/api/v1"
        payload = {"function": "QueryServerState", "data": {}}
        headers = {}
        if api_token:
            headers["Authorization"] = f"Bearer {api_token}"
        resp = requests.post(url, json=payload, headers=headers, timeout=3, verify=False)
        if resp.status_code == 200:
            result = resp.json()
            return result.get("data", {})
        return {}
    except Exception:
        return {}

def get_system_info():
    cpu = psutil.cpu_percent(interval=0.5)
    mem = psutil.virtual_memory()
    return {
        "cpu_percent": cpu, "cpu_count": psutil.cpu_count(),
        "memory_total_gb": round(mem.total / (1024**3), 1),
        "memory_used_gb": round(mem.used / (1024**3), 1),
        "memory_percent": mem.percent,
        "uptime": _get_uptime(),
    }

def _get_uptime():
    try:
        boot_ts = psutil.boot_time()
        delta = datetime.now() - datetime.fromtimestamp(boot_ts)
        return f"{delta.days}天{delta.seconds//3600}小时{(delta.seconds%3600)//60}分钟"
    except Exception:
        return "未知"

# ==================== 更新管理 ====================
LOCAL_VERSION = "v1.0.0"

def _parse_version(v):
    try:
        return [int(x) for x in v.replace("v", "").split(".")]
    except Exception:
        return [0, 0]

def _check_remote_version():
    """检查远程版本，返回 (has_update, remote_ver, changelog, manual_required, manual_url)"""
    global _remote_pkg_url
    urls = _get_channel_urls()
    try:
        data = requests.get(urls["version_url"], timeout=10).json()
        rv = data.get("version", "0.0")
        # 解析远程附加字段：更新包地址 / 支持自动更新的最低版本 / 手动下载地址
        _remote_pkg_url = data.get("package_url", "") or urls["package_url"]
        manual_url = data.get("manual_url", "") or _remote_pkg_url
        manual_below = data.get("manual_below", "") or ""
        manual_required = bool(manual_below) and (_parse_version(LOCAL_VERSION) < _parse_version(manual_below))
        has = _parse_version(rv) > _parse_version(LOCAL_VERSION)
        changelog = ""
        if has:
            try:
                d = requests.get(urls["changelog_url"], timeout=10).json()
                changelog = d.get(rv, d.get("default", "暂无更新日志"))
            except Exception:
                changelog = "无法获取更新日志"
        return has, rv, changelog, manual_required, manual_url
    except Exception as e:
        return False, None, str(e), False, ""

def _download_and_apply_update():
    """下载并解压更新包，返回解压路径"""
    try:
        os.makedirs(TEMP_DOWNLOAD_DIR, exist_ok=True)
        dp = os.path.join(TEMP_DOWNLOAD_DIR, UPDATE_ZIP_NAME)
        # 下载（优先使用 version.json 指定的更新包地址）
        resp = requests.get(_remote_pkg_url or _get_channel_urls()["package_url"], stream=True, timeout=60)
        resp.raise_for_status()
        total = int(resp.headers.get('content-length', 0))
        dl = 0
        with open(dp, 'wb') as f:
            for c in resp.iter_content(8192):
                if c:
                    f.write(c)
                    dl += len(c)
        wlog(f"更新包下载完成: {dl} bytes")
        # 解压
        ext = os.path.join(TEMP_DOWNLOAD_DIR, "ext")
        if os.path.exists(ext):
            shutil.rmtree(ext)
        os.makedirs(ext)
        with zipfile.ZipFile(dp, 'r') as z:
            z.extractall(ext)
        src = ext
        items = os.listdir(src)
        if len(items) == 1 and os.path.isdir(os.path.join(src, items[0])):
            src = os.path.join(src, items[0])
        return src
    except Exception as e:
        wlog(f"更新失败: {e}")
        return None

def _restart_app(update_src=None):
    """重启应用程序"""
    if _controller_ref:
        try:
            _controller_ref._restart_app(update_src)
            return
        except Exception:
            pass
    # 独立运行回退：批处理按 PID 等待旧进程退出，复制文件（带重试）后启动新进程，全程无窗口
    exe_path = os.path.abspath(sys.argv[0])
    exe_dir = os.path.dirname(exe_path)
    exe_name = os.path.basename(exe_path)
    pid = os.getpid()
    bat_path = os.path.join(exe_dir, "_restart.bat")
    log_path = os.path.join(exe_dir, "_restart.log")
    temp_root = os.path.abspath(TEMP_DOWNLOAD_DIR)

    # 生成批处理脚本：严格按步骤执行
    # 等待旧进程退出(PID) → 静置3秒 → 复制文件(重试) → 静置3秒 → 启动新版本(验活重试3次) → 清理自身
    # 注意：延时全部使用 ping 实现，timeout 在隐藏窗口下可能静默失效导致等待被跳过
    bat_lines = [
        '@echo off',
        f'echo [%date% %time%] 重启流程开始 > "{log_path}"',
        'set /a cnt=0',
        ':wait',
        f'tasklist /FI "PID eq {pid}" 2>NUL | find "{pid}" >NUL',
        'if errorlevel 1 goto proceed',
        'set /a cnt+=1',
        'if %cnt% geq 30 goto proceed',
        'ping 127.0.0.1 -n 2 >nul',
        'goto wait',
        ':proceed',
        f'echo [%date% %time%] 旧进程已退出，静置3秒 >> "{log_path}"',
        'ping 127.0.0.1 -n 4 >nul',
    ]

    if update_src:
        bat_lines.extend([
            'set /a cc=0',
            ':copytry',
            f'xcopy "{update_src}\\*" "{exe_dir}" /E /I /H /R /Y >> "{log_path}" 2>&1',
            'if not errorlevel 1 goto copydone',
            'set /a cc+=1',
            f'echo [%date% %time%] 复制失败，第 %cc% 次重试 >> "{log_path}"',
            'if %cc% geq 10 goto copydone',
            'ping 127.0.0.1 -n 3 >nul',
            'goto copytry',
            ':copydone',
            f'echo [%date% %time%] 文件复制完成 >> "{log_path}"',
            f'rd /s /q "{temp_root}" 2>nul',
        ])

    bat_lines.extend([
        # 确保 _internal 目录存在（onedir 打包的运行时目录），缺失时自动创建
        f'if exist "{exe_dir}\\_internal" goto hasinternal',
        f'mkdir "{exe_dir}\\_internal"',
        f'echo [%date% %time%] 未检测到 _internal 目录，已自动创建 >> "{log_path}"',
        ':hasinternal',
        # 检查运行时文件是否存在，便于排查更新包不完整问题
        f'if exist "{exe_dir}\\_internal\\python311.dll" goto runtimeok',
        f'if exist "{exe_dir}\\python311.dll" goto runtimeok',
        f'echo [%date% %time%] 警告: 未找到 python311.dll，更新包可能不完整 >> "{log_path}"',
        ':runtimeok',
        # 预热读取 exe：强制完成杀毒扫描与文件缓存，避免启动时加载失败
        f'copy /b "{exe_path}" nul >nul 2>&1',
        f'echo [%date% %time%] 静置3秒后启动当前目录下的 {exe_name} >> "{log_path}"',
        'ping 127.0.0.1 -n 4 >nul',
        'set /a lt=0',
        ':launchtry',
        'set /a ln=lt+1',
        f'echo [%date% %time%] 启动 {exe_path}（第 %ln% 次） >> "{log_path}"',
        # 用 PowerShell 启动并捕获新进程真实 PID（避免中文进程名匹配失败导致验活误判）
        'set NEWPID=',
        f'for /f "usebackq delims=" %%p in (`powershell -NoProfile -Command "(Start-Process -FilePath \'{exe_path}\' -WorkingDirectory \'{exe_dir}\' -PassThru).Id"`) do set NEWPID=%%p',
        'if defined NEWPID goto gotpid',
        f'echo [%date% %time%] 启动命令未返回进程ID >> "{log_path}"',
        'goto retrygate',
        ':gotpid',
        'ping 127.0.0.1 -n 9 >nul',
        'tasklist /FI "PID eq %NEWPID%" 2>NUL | find "%NEWPID%" >NUL',
        'if not errorlevel 1 goto launched',
        f'echo [%date% %time%] 进程 PID %NEWPID% 未存活，准备重试 >> "{log_path}"',
        ':retrygate',
        'set /a lt+=1',
        'if %lt% lss 3 goto launchtry',
        ':launched',
        f'echo [%date% %time%] 启动流程结束 >> "{log_path}"',
        'ping 127.0.0.1 -n 3 >nul',
        'del "%~f0"',
    ])

    with open(bat_path, 'w', encoding='gbk') as f:
        f.write('\r\n'.join(bat_lines) + '\r\n')

    # 通过 VBS 包装器启动批处理，确保全程无控制台窗口
    vbs_path = os.path.join(exe_dir, "_restart.vbs")
    vbs_content = (
        'On Error Resume Next\r\n'
        f'CreateObject("WScript.Shell").Run """{bat_path}""", 0, False\r\n'
        'WScript.Sleep 500\r\n'
        'CreateObject("Scripting.FileSystemObject").DeleteFile WScript.ScriptFullName\r\n'
    )
    try:
        with open(vbs_path, 'w', encoding='gbk') as f:
            f.write(vbs_content)
        subprocess.Popen(
            ['wscript.exe', '//nologo', vbs_path],
            cwd=exe_dir,
            creationflags=subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS
        )
    except Exception:
        subprocess.Popen(
            ['cmd.exe', '/c', bat_path],
            cwd=exe_dir,
            creationflags=subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS
        )
    os._exit(0)

# ==================== 全局实例 ====================
server_mgr = ServerManager()  # 独立运行时的备用实例

# ==================== HTTP 处理器 ====================
class WebHandler(BaseHTTPRequestHandler):
    def log_message(self, format, *args):
        pass

    def _get_about_info(self):
        """获取关于信息"""
        import platform
        info = {
            "app_name": "幸福工厂服务器管理器",
            "web_version": "v1.0.0",
            "app_version": "v1.0.0",
            "author": "冰霜蘑菇",
            "website": "www.sxtvip.top",
            "qq_group": "264127585",
            "python_version": platform.python_version(),
            "platform": platform.system() + " " + platform.release(),
        }
        # 尝试从控制器获取主程序版本
        if _controller_ref:
            try:
                info["app_version"] = _controller_ref.config.get("app_version", "v1.0.0")
            except Exception:
                pass
        return info

    def _send_json(self, data, status=200):
        body = json.dumps(data, ensure_ascii=False).encode('utf-8')
        self.send_response(status)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        self.send_header('Access-Control-Allow-Origin', '*')
        self.end_headers()
        self.wfile.write(body)

    def _read_body(self):
        length = int(self.headers.get('Content-Length', 0))
        if length:
            return json.loads(self.rfile.read(length).decode('utf-8'))
        return {}

    def _get_token(self):
        """从请求头或参数中获取token"""
        token = self.headers.get('X-Auth-Token', '')
        if not token:
            parsed = urlparse(self.path)
            params = parse_qs(parsed.query)
            token = params.get('token', [''])[0]
        return token

    def _require_auth(self):
        """验证登录状态，返回是否已认证"""
        pwd = get_web_password()
        if not pwd:
            return True  # 未设置密码，无需认证
        token = self._get_token()
        return verify_session(token)

    def do_OPTIONS(self):
        self.send_response(200)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type, X-Auth-Token')
        self.end_headers()

    def _serve_backup_download(self, backup_name):
        """下载备份（打包为ZIP）"""
        if not backup_name or '/' in backup_name or '\\' in backup_name:
            self._send_json({"error": "无效的备份名称"}, 400)
            return
        cfg = load_config()
        # 在所有备份目录中查找
        backup_dir = None
        for d in get_backup_dirs(cfg):
            candidate = os.path.join(d, backup_name)
            if os.path.isdir(candidate):
                backup_dir = candidate
                break
        if not backup_dir:
            self._send_json({"error": "备份不存在"}, 404)
            return
        try:
            buf = io.BytesIO()
            with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
                for rp, dirs, files in os.walk(backup_dir):
                    for f in files:
                        full = os.path.join(rp, f)
                        arcname = os.path.relpath(full, os.path.dirname(backup_dir))
                        zf.write(full, arcname)
            buf.seek(0)
            data = buf.read()
            filename = f"{backup_name}.zip"
            self.send_response(200)
            self.send_header('Content-Type', 'application/zip')
            self.send_header('Content-Disposition', f'attachment; filename="{filename}"')
            self.send_header('Content-Length', str(len(data)))
            self.end_headers()
            self.wfile.write(data)
            wlog(f"备份下载: {backup_name}")
        except Exception as e:
            self._send_json({"error": f"打包失败: {e}"}, 500)

    def _serve_save_download(self, save_name):
        """下载存档（打包为ZIP）"""
        if not save_name or '/' in save_name or '\\' in save_name:
            self._send_json({"error": "无效的存档名称"}, 400)
            return
        cfg = load_config()
        save_path = cfg.get("save_game_path", "")
        save_dir = os.path.join(save_path, save_name)
        if not save_path or not os.path.isdir(save_dir):
            self._send_json({"error": "存档不存在"}, 404)
            return
        try:
            buf = io.BytesIO()
            with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
                for root, dirs, files in os.walk(save_dir):
                    for f in files:
                        full = os.path.join(root, f)
                        arcname = os.path.relpath(full, save_path)
                        zf.write(full, arcname)
            buf.seek(0)
            data = buf.read()
            filename = f"{save_name}.zip"
            self.send_response(200)
            self.send_header('Content-Type', 'application/zip')
            self.send_header('Content-Disposition', f'attachment; filename="{filename}"')
            self.send_header('Content-Length', str(len(data)))
            self.end_headers()
            self.wfile.write(data)
            wlog(f"存档下载: {save_name}")
        except Exception as e:
            self._send_json({"error": f"打包失败: {e}"}, 500)

    def do_GET(self):
        parsed = urlparse(self.path)
        path = parsed.path
        params = parse_qs(parsed.query)

        if path == '/' or path == '/index.html':
            self._serve_html()
        elif path == '/api/status':
            # 公开接口：任何人都可以查看运行状态
            cfg = load_config()
            # 计算下次备份时间（始终优先从主程序获取，确保与主程序显示一致）
            next_backup = ""
            backup_enabled = cfg.get("enable_auto_backup", False)
            nbt = None
            if _controller_ref is not None:
                try:
                    nbt = getattr(_controller_ref, 'next_backup_time', None)
                except Exception:
                    nbt = None
            if nbt is not None:
                next_backup = nbt.strftime("%Y-%m-%d %H:%M")
            elif backup_enabled:
                interval = cfg.get("backup_interval", 30)
                next_backup = f"约{interval}分钟后"
            # 定时开关机状态
            schedule_on = cfg.get("schedule_enabled", False)
            schedule_text = "已启用" if schedule_on else "已禁用"
            next_restart = "--:--"
            countdown = "未启用定时任务"
            if schedule_on:
                start_t = cfg.get("unified_start_time", "17:00")
                end_t = cfg.get("unified_end_time", "02:00")
                schedule_text = f"{start_t} ~ {end_t}"
                # 计算下次重启时间和倒计时
                try:
                    now = datetime.now()
                    day_names = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday']
                    use_unified = cfg.get("use_unified_schedule", True)
                    custom_days = cfg.get("custom_schedule_days", {})
                    candidates = []
                    for offset in range(8):
                        check_date = now + timedelta(days=offset)
                        day_key = day_names[check_date.weekday()]
                        dc = custom_days.get(day_key, {})
                        if not dc.get("enabled", False):
                            continue
                        start_str = start_t if use_unified else dc.get("start", "17:00")
                        h, m = map(int, start_str.split(':'))
                        candidate = check_date.replace(hour=h, minute=m, second=0, microsecond=0)
                        if candidate > now:
                            candidates.append(candidate)
                    if candidates:
                        next_time = min(candidates)
                        next_restart = next_time.strftime("%m-%d %H:%M")
                        delta = next_time - now
                        total_sec = int(delta.total_seconds())
                        hrs, rem = divmod(total_sec, 3600)
                        mins, secs = divmod(rem, 60)
                        if hrs > 24:
                            countdown = f"{hrs//24}天 {hrs%24:02d}:{mins:02d}:{secs:02d}"
                        else:
                            countdown = f"{hrs:02d}:{mins:02d}:{secs:02d}"
                    else:
                        next_restart = "无有效计划"
                        countdown = "--"
                except Exception:
                    next_restart = "计算失败"
                    countdown = "--"
            # 获取服务器完整状态：优先从主程序获取，其次直接查询
            game_state = {}
            player_count = 0
            if _controller_ref is not None:
                try:
                    game_state = getattr(_controller_ref, 'server_state', {})
                    player_count = getattr(_controller_ref, 'player_count', 0)
                except Exception:
                    pass
            if not game_state:
                monitor_host = cfg.get('monitor_host', '127.0.0.1') or '127.0.0.1'
                data = query_server_state(monitor_host, cfg.get('game_port', 7777), cfg.get('api_token', ''))
                game_state = data.get('serverGameState', {})
                player_count = game_state.get('numConnectedPlayers', 0)
            self._send_json({
                "server_running": get_server_running(),
                "player_count": player_count,
                "max_players": cfg.get("max_players", 4),
                "game_state": game_state,
                "system": get_system_info(),
                "config": {
                    "game_port": cfg.get("game_port", 7777),
                    "beacon_port": cfg.get("beacon_port", 8888),
                    "max_players": cfg.get("max_players", 4),
                    "branch": cfg.get("branch", "public"),
                },
                "automation": {
                    "next_backup": next_backup,
                    "backup_enabled": backup_enabled,
                    "schedule_text": schedule_text,
                    "schedule_enabled": schedule_on,
                    "next_restart": next_restart,
                    "countdown": countdown,
                },
                "logs": web_logs[-50:],
                "auth_required": bool(get_web_password()),
                "version": LOCAL_VERSION,
                "appearance_mode": cfg.get("appearance_mode", "Light"),
                "history": list(_perf_history),
            })
        elif path == '/api/system':
            # 公开接口：系统信息
            self._send_json(get_system_info())
        elif path == '/api/about':
            # 公开接口：关于信息
            self._send_json(self._get_about_info())
        elif path == '/api/update/check':
            # 公开接口：检查更新
            has, rv, changelog, manual_required, manual_url = _check_remote_version()
            ch = load_config().get("update_channel", "stable")
            self._send_json({"has_update": has, "remote_version": rv, "changelog": changelog,
                             "local_version": LOCAL_VERSION, "channel": ch,
                             "channel_name": UPDATE_CHANNELS.get(ch, UPDATE_CHANNELS["stable"])["name"],
                             "manual_required": manual_required, "manual_url": manual_url})
        elif path == '/api/saves':
            # 公开接口：存档列表（无需登录）
            self._send_json(list_saves(load_config()))
        elif path == '/api/saves/download':
            # 公开接口：存档下载（无需登录）
            save_name = params.get('name', [''])[0]
            self._serve_save_download(save_name)
        elif path == '/api/blueprints':
            # 公开接口：蓝图列表（无需登录）
            self._send_json(list_blueprints(load_config()))
        elif path == '/api/backups':
            # 公开接口：备份列表（无需登录）
            self._send_json(list_backups(load_config()))
        elif path == '/api/backups/download':
            # 公开接口：备份下载（无需登录）
            backup_name = params.get('name', [''])[0]
            self._serve_backup_download(backup_name)
        elif not self._require_auth():
            self._send_json({"error": "未登录，请先登录"}, 401)
        elif path == '/api/logs':
            limit = int(params.get('limit', [500])[0])
            # 优先读取持久化的所有操作日志，无文件日志时回退到内存日志
            file_logs = read_all_operation_logs(limit)
            logs = file_logs if file_logs else web_logs[-limit:]
            self._send_json({"logs": logs})
        else:
            self._send_json({"error": "未知接口"}, 404)

    def do_POST(self):
        parsed = urlparse(self.path)
        path = parsed.path

        try:
            body = self._read_body()
        except Exception:
            body = {}

        if path == '/api/login':
            pwd = body.get("password", "")
            if pwd and pwd == get_web_password():
                token = create_session()
                wlog("用户登录成功")
                self._send_json({"success": True, "token": token, "message": "登录成功"})
            else:
                wlog("登录失败：密码错误")
                self._send_json({"success": False, "message": "密码错误"}, 401)
            return

        # 以下接口均需登录
        if not self._require_auth():
            self._send_json({"error": "未登录，请先登录"}, 401)
            return

        if path == '/api/start':
            if _controller_ref:
                # 使用主程序启动服务器
                import threading as _th
                _th.Thread(target=_controller_ref.start_server, daemon=True).start()
                self._send_json({"success": True, "message": "服务器启动中..."})
            else:
                result = server_mgr.start(load_config())
                self._send_json(result)
        elif path == '/api/stop':
            if _controller_ref:
                _controller_ref.after(0, _controller_ref.stop_server)
                self._send_json({"success": True, "message": "服务器停止中..."})
            else:
                result = server_mgr.stop()
                self._send_json(result)
        elif path == '/api/restart':
            if _controller_ref:
                import threading as _th
                _th.Thread(target=_controller_ref.restart_server, daemon=True).start()
                self._send_json({"success": True, "message": "服务器重启中..."})
            else:
                result = server_mgr.restart(load_config())
                self._send_json(result)
        elif path == '/api/backup':
            result = create_backup(load_config())
            self._send_json(result)
        elif path == '/api/backup/delete':
            result = delete_backup(load_config(), body.get("name", ""))
            self._send_json(result)
        elif path == '/api/blueprints/upload':
            cfg = load_config()
            if not cfg.get("blueprint_upload_enabled", True):
                self._send_json({"success": False, "message": "蓝图上传功能已禁用"})
            else:
                file_name = body.get("file_name", "")
                file_data = body.get("file_data", "")
                if not file_name or not file_data:
                    self._send_json({"success": False, "message": "缺少文件数据"}, 400)
                else:
                    result = upload_blueprint(cfg, file_name, file_data)
                    self._send_json(result)
        elif path == '/api/blueprints/delete':
            result = delete_blueprint(load_config(), body.get("name", ""))
            self._send_json(result)
        elif path == '/api/update/channel':
            # 切换更新通道（稳定版/测试版）
            ch = body.get("channel", "")
            if ch not in UPDATE_CHANNELS:
                self._send_json({"success": False, "message": "未知的更新通道"})
            else:
                cfg = load_config()
                cfg["update_channel"] = ch
                save_config(cfg)
                if _controller_ref is not None:
                    try:
                        _controller_ref.config["update_channel"] = ch
                    except Exception:
                        pass
                name = UPDATE_CHANNELS[ch]["name"]
                wlog(f"更新通道已切换为: {name}")
                self._send_json({"success": True, "message": f"已切换到{name}通道"})
        elif path == '/api/update/apply':
            # 下载并应用更新
            self._send_json({"success": True, "message": "开始下载更新..."})
            def _do_update():
                src = _download_and_apply_update()
                if src:
                    wlog("更新包已准备，即将重启并应用更新")
                    time.sleep(1)
                    _restart_app(src)
                else:
                    wlog("更新失败")
            threading.Thread(target=_do_update, daemon=True).start()
        else:
            self._send_json({"error": "未知接口"}, 404)

    def _serve_html(self):
        html = _get_html_page()
        body = html.encode('utf-8')
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

# ==================== Web 前端页面 ====================
def _get_html_page():
    return r'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>幸福工厂服务器管理 - Web端</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0d1520;--card:#151f2e;--card2:#1c2b3e;--accent:#38bdf8;--accent2:#7dd3fc;--text:#e2e8f0;--text2:#7a8ba0;--success:#22c55e;--warning:#f59e0b;--danger:#ef4444;--border:#1e3048;--shadow:0 4px 16px rgba(0,0,0,.35);--radius:12px}
body{font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;line-height:1.6}
body::before{content:'';position:fixed;top:0;left:0;right:0;bottom:0;background:radial-gradient(ellipse at 20% 0%,rgba(56,189,248,.04) 0%,transparent 60%),radial-gradient(ellipse at 80% 100%,rgba(34,197,94,.03) 0%,transparent 60%);pointer-events:none;z-index:0}
.header{background:linear-gradient(135deg,#111b2a 0%,#182640 100%);padding:16px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);box-shadow:0 2px 20px rgba(0,0,0,.4);position:relative;z-index:10}
.header h1{font-size:20px;color:#fff;font-weight:700;letter-spacing:.5px;display:flex;align-items:center;gap:10px}
.header h1 .logo-icon{font-size:26px;filter:drop-shadow(0 0 8px rgba(56,189,248,.4))}
.header .ver{font-size:11px;color:var(--text2);margin-top:3px;letter-spacing:.3px}
.header-right{display:flex;align-items:center;gap:16px}
.nav{background:var(--card);display:flex;gap:4px;border-bottom:1px solid var(--border);padding:0 28px;overflow-x:auto;position:relative;z-index:5}
.nav button{background:none;border:none;color:var(--text2);padding:14px 20px;cursor:pointer;font-size:13px;border-bottom:2px solid transparent;transition:all .25s;white-space:nowrap;font-weight:500;position:relative}
.nav button:hover{color:var(--text);background:rgba(56,189,248,.06)}
.nav button.active{color:var(--accent);border-bottom-color:var(--accent);font-weight:600}
.nav button.active::after{content:'';position:absolute;bottom:-1px;left:20%;right:20%;height:2px;background:var(--accent);border-radius:2px;filter:blur(1px);box-shadow:0 0 8px var(--accent)}
.nav button.hidden{display:none}
.main{padding:28px 32px;max-width:1400px;margin:0 auto;position:relative;z-index:1}
.page{display:none}.page.active{display:block;animation:fadeIn .35s ease-out}
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
@keyframes pulse{0%,100%{box-shadow:0 0 8px rgba(34,197,94,.4)}50%{box-shadow:0 0 20px rgba(34,197,94,.7)}}
@keyframes pulseRed{0%,100%{box-shadow:0 0 8px rgba(239,68,68,.3)}50%{box-shadow:0 0 16px rgba(239,68,68,.6)}}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:16px;margin-bottom:24px}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px 22px;transition:all .3s;box-shadow:var(--shadow);position:relative;overflow:hidden}
.card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--accent),transparent);opacity:0;transition:opacity .3s}
.card:hover{border-color:rgba(56,189,248,.3);transform:translateY(-3px);box-shadow:0 8px 30px rgba(56,189,248,.08)}
.card:hover::before{opacity:1}
.card .label{font-size:11px;color:var(--text2);margin-bottom:8px;text-transform:uppercase;letter-spacing:.8px;font-weight:600}
.card .value{font-size:28px;font-weight:700;color:var(--text);line-height:1.2}
.card .value.small{font-size:16px;font-weight:600}
.card .unit{font-size:13px;color:var(--text2);font-weight:400}
.btn-row{display:flex;gap:10px;margin-bottom:24px;flex-wrap:wrap}
.btn{padding:10px 22px;border:none;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;transition:all .25s;display:inline-flex;align-items:center;gap:6px;letter-spacing:.3px;position:relative;overflow:hidden}
.btn::after{content:'';position:absolute;inset:0;background:linear-gradient(180deg,rgba(255,255,255,.08) 0%,transparent 60%);pointer-events:none}
.btn:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.25)}
.btn:active{transform:translateY(0);box-shadow:none}
.btn-primary{background:linear-gradient(135deg,#22c55e,#16a34a);color:#fff}
.btn-primary:hover{box-shadow:0 6px 20px rgba(34,197,94,.3)}
.btn-danger{background:linear-gradient(135deg,#ef4444,#dc2626);color:#fff}
.btn-danger:hover{box-shadow:0 6px 20px rgba(239,68,68,.3)}
.btn-warning{background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff}
.btn-warning:hover{box-shadow:0 6px 20px rgba(245,158,11,.3)}
.btn-outline{background:var(--card2);border:1px solid var(--border);color:var(--text)}
.btn-outline:hover{background:rgba(56,189,248,.08);border-color:var(--accent);color:var(--accent);box-shadow:0 4px 16px rgba(56,189,248,.1)}
.btn-outline.disabled{opacity:.4;cursor:not-allowed;pointer-events:none}
.btn.disabled{opacity:.35;cursor:not-allowed;pointer-events:none;filter:grayscale(.5)}
.panel{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:22px 24px;margin-bottom:24px;box-shadow:var(--shadow)}
.panel h3{font-size:15px;margin-bottom:16px;color:var(--text);font-weight:600;display:flex;align-items:center;gap:8px;padding-bottom:12px;border-bottom:1px solid var(--border)}
.panel h3 .emoji{font-size:18px}
.form-row{display:flex;align-items:center;gap:12px;margin-bottom:12px}
.form-row label{width:120px;font-size:13px;color:var(--text2);text-align:right}
.form-row input,.form-row select{flex:1;max-width:300px;padding:9px 14px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font-size:14px;transition:all .25s}
.form-row input:focus,.form-row select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(56,189,248,.12)}
.log-box{background:#080e16;border:1px solid var(--border);border-radius:10px;padding:14px 16px;max-height:400px;overflow-y:auto;font-family:Consolas,'Cascadia Code','Courier New',monospace;font-size:12px;line-height:1.8}
.log-box::-webkit-scrollbar{width:6px}.log-box::-webkit-scrollbar-track{background:transparent}.log-box::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.log-box .log-line{white-space:pre-wrap;word-break:break-all;padding:2px 0;border-bottom:1px solid rgba(30,48,72,.3)}
.log-box .log-line:last-child{border-bottom:none}
.log-box .log-line.info{color:#38bdf8}
table{width:100%;border-collapse:separate;border-spacing:0}
th,td{padding:12px 16px;text-align:left;font-size:13px}
th{color:var(--text2);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.6px;background:rgba(0,0,0,.2);border-bottom:1px solid var(--border)}
th:first-child{border-radius:8px 0 0 0}
th:last-child{border-radius:0 8px 0 0}
td{border-bottom:1px solid rgba(30,48,72,.4)}
tr:hover td{background:rgba(56,189,248,.03)}
tr:last-child td{border-bottom:none}
.status-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:7px}
.status-dot.on{background:var(--success);box-shadow:0 0 10px var(--success);animation:pulse 2s infinite}
.status-dot.off{background:var(--danger);box-shadow:0 0 6px var(--danger);animation:pulseRed 3s infinite}
.progress-bar{height:6px;background:rgba(30,48,72,.6);border-radius:4px;overflow:hidden;margin-top:10px}
.progress-bar .fill{height:100%;border-radius:4px;transition:width .6s ease}
.fill-green{background:linear-gradient(90deg,#22c55e,#4ade80)}.fill-yellow{background:linear-gradient(90deg,#f59e0b,#fbbf24)}.fill-red{background:linear-gradient(90deg,#ef4444,#f87171)}
.toast{position:fixed;top:24px;right:24px;padding:14px 28px;border-radius:var(--radius);color:#fff;font-size:14px;font-weight:500;z-index:999;animation:slideIn .35s;box-shadow:0 8px 30px rgba(0,0,0,.35);backdrop-filter:blur(8px)}
.toast.success{background:rgba(34,197,94,.9)}.toast.error{background:rgba(239,68,68,.9)}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
.empty{text-align:center;color:var(--text2);padding:48px 20px;font-size:14px}
.locked-banner{background:linear-gradient(135deg,rgba(28,43,62,.9),rgba(21,31,46,.9));border:1px solid var(--border);border-radius:var(--radius);padding:14px 22px;margin-bottom:24px;display:flex;align-items:center;justify-content:space-between;box-shadow:var(--shadow);backdrop-filter:blur(4px)}
.locked-banner span{font-size:13px;color:var(--text2)}
.login-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.75);display:flex;align-items:center;justify-content:center;z-index:1000;backdrop-filter:blur(8px)}
.login-overlay.hidden{display:none}
.login-box{background:linear-gradient(180deg,var(--card2),var(--card));border:1px solid var(--border);border-radius:20px;padding:40px;width:380px;text-align:center;box-shadow:0 12px 50px rgba(0,0,0,.6)}
.login-box h2{margin-bottom:8px;color:var(--accent);font-size:22px}
.login-box p{font-size:13px;color:var(--text2);margin-bottom:24px}
.login-box input{width:100%;padding:13px 16px;border:1px solid var(--border);border-radius:10px;background:var(--bg);color:var(--text);font-size:15px;margin-bottom:18px;text-align:center;transition:all .25s}
.login-box input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(56,189,248,.15)}
.login-box .btn{width:100%;justify-content:center;padding:13px;font-size:14px}
.login-box .err{color:var(--danger);font-size:13px;margin-top:8px;min-height:20px}
.login-btn{background:none;border:1px solid var(--border);color:var(--text2);padding:7px 16px;border-radius:8px;cursor:pointer;font-size:12px;transition:all .25s;font-weight:500}
.login-btn:hover{color:var(--accent);border-color:var(--accent);background:rgba(56,189,248,.05)}
.auto-status-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.auto-item{background:var(--card2);border:1px solid var(--border);border-radius:10px;padding:14px 18px;display:flex;flex-direction:column;gap:4px;transition:all .25s}
.auto-item:hover{border-color:rgba(56,189,248,.2);background:rgba(28,43,62,.8)}
.auto-item .auto-label{font-size:11px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;font-weight:600}
.auto-item .auto-value{font-size:15px;font-weight:700}
@media(max-width:768px){.cards{grid-template-columns:1fr 1fr}.form-row{flex-direction:column;align-items:flex-start}.form-row label{text-align:left;width:auto}.main{padding:16px}.auto-status-grid{grid-template-columns:1fr}.nav{padding:0 12px}.header{padding:12px 16px}}
/* ===== 浅色外观模式 ===== */
body.light{--bg:#f0f4f8;--card:#ffffff;--card2:#e9f0f7;--accent:#0284c7;--accent2:#0369a1;--text:#1e293b;--text2:#64748b;--success:#16a34a;--warning:#d97706;--danger:#dc2626;--border:#d8e2ec;--shadow:0 4px 16px rgba(15,40,80,.08)}
body.light::before{background:radial-gradient(ellipse at 20% 0%,rgba(2,132,199,.05) 0%,transparent 60%),radial-gradient(ellipse at 80% 100%,rgba(22,163,74,.04) 0%,transparent 60%)}
body.light .header{background:linear-gradient(135deg,#ffffff 0%,#e6f0fa 100%);box-shadow:0 2px 12px rgba(15,40,80,.08)}
body.light .header h1{color:#0f2a43}
body.light .header h1 .logo-icon{filter:none}
body.light .log-box{background:#f6f9fc}
body.light .log-box .log-line{border-bottom-color:rgba(0,0,0,.05)}
body.light .log-box .log-line.info{color:#0284c7}
body.light .log-box::-webkit-scrollbar-thumb{background:#c4d2e0}
body.light th{background:rgba(2,132,199,.06)}
body.light td{border-bottom-color:rgba(0,0,0,.06)}
body.light .progress-bar{background:rgba(0,0,0,.08)}
body.light .login-overlay{background:rgba(15,40,80,.35)}
body.light .login-box{background:linear-gradient(180deg,#ffffff,#f2f7fc);box-shadow:0 12px 50px rgba(15,40,80,.25)}
body.light .card:hover{box-shadow:0 8px 30px rgba(2,132,199,.12)}
body.light .btn:hover{box-shadow:0 6px 20px rgba(15,40,80,.15)}
body.light .locked-banner{background:linear-gradient(135deg,rgba(255,255,255,.95),rgba(233,240,247,.95))}
</style>
</head>
<body>
<!-- 登录弹窗 -->
<div id="loginOverlay" class="login-overlay hidden">
  <div class="login-box">
    <h2>🔐 身份验证</h2>
    <p>请输入管理密码以使用全部功能</p>
    <input type="password" id="loginPwd" placeholder="管理密码" onkeydown="if(event.key==='Enter')doLogin()">
    <button class="btn btn-primary" onclick="doLogin()">登 录</button>
    <div class="err" id="loginErr"></div>
  </div>
</div>

<div class="header">
  <div><h1><span class="logo-icon">🏭</span> 幸福工厂服务器管理</h1><span class="ver" id="headerVersion">Web 控制台</span></div>
  <div class="header-right">
    <div id="headerStatus"><span class="status-dot off"></span><span id="headerStatusText">未运行</span></div>
    <span id="logoutCountdown" style="display:none;font-size:12px;color:var(--text2)"></span>
    <select id="refreshSel" class="login-btn" onchange="changeRefreshRate()" title="数据刷新频率">
      <option value="3000">⚡ 实时刷新</option>
      <option value="5000" selected>🔄 每5秒刷新</option>
      <option value="60000">⏱️ 每分钟刷新</option>
    </select>
    <button class="login-btn" id="themeBtn" onclick="toggleTheme()">🌙 深色模式</button>
    <button class="login-btn" id="loginBtn" onclick="toggleLogin()"> 登录</button>
  </div>
</div>
<div class="nav">
  <button class="active" onclick="showPage('dashboard',this)">📊 运行概览</button>
  <button onclick="showPage('backup',this)">💾 备份管理</button>
  <button onclick="showPage('saves',this)">📁 存档管理</button>
  <button onclick="showPage('blueprints',this)">🔧 蓝图管理</button>
  <button class="auth-only hidden" onclick="showPage('logs',this)">📋 操作日志</button>
  <button onclick="showPage('about',this);loadAbout()" style="margin-left:auto">ℹ️ 关于</button>
</div>
<div class="main">
  <!-- 未登录提示 -->
  <div id="lockedBanner" class="locked-banner" style="display:none">
    <span>🔒 当前为访客模式，可查看运行状态和存档。<a href="javascript:showLogin()" style="color:var(--accent)">点击登录</a>解锁全部功能。</span>
  </div>
  <!-- 运行概览 -->
  <div id="page-dashboard" class="page active">
    <!-- 服务器运行状态 -->
    <div class="panel" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;padding:16px 24px">
      <div style="display:flex;align-items:center;gap:14px">
        <div id="srvStatusIcon" style="width:48px;height:48px;border-radius:50%;background:var(--danger);display:flex;align-items:center;justify-content:center;font-size:22px;box-shadow:0 0 12px rgba(239,68,68,.4)">⏻</div>
        <div>
          <div style="font-size:18px;font-weight:700" id="srvStatusText">服务器已停止</div>
          <div style="font-size:12px;color:var(--text2);margin-top:2px" id="srvStatusDetail">端口:-- | 分支:--</div>
        </div>
      </div>
    </div>
    <div class="cards">
      <div class="card"><div class="label">CPU 使用率</div><div class="value" id="cpuVal">--<span class="unit">%</span></div><div class="progress-bar"><div class="fill fill-green" id="cpuBar" style="width:0%"></div></div></div>
      <div class="card"><div class="label">内存使用</div><div class="value" id="memVal">--<span class="unit">%</span></div><div class="progress-bar"><div class="fill fill-green" id="memBar" style="width:0%"></div></div></div>
      <div class="card"><div class="label">在线玩家</div><div class="value" id="playerVal">0<span class="unit"> / --</span></div></div>
      <div class="card"><div class="label">系统运行时间</div><div class="value small" id="uptimeVal">--</div></div>
    </div>
    <!-- 游戏状态 -->
    <div class="cards">
      <div class="card"><div class="label">游戏阶段</div><div class="value small" id="gamePhaseVal">--</div></div>
      <div class="card"><div class="label">游戏时长</div><div class="value small" id="gameDurationVal">--</div></div>
      <div class="card"><div class="label">当前里程碑</div><div class="value small" id="milestoneVal">--</div></div>
      <div class="card"><div class="label">科技等级</div><div class="value" id="techTierVal">--</div></div>
    </div>
    <!-- 在线玩家列表 -->
    <div class="panel" style="margin-top:12px">
      <h3><span class="emoji">👥</span> 在线玩家 <span id="playerCountBadge" style="font-size:12px;background:var(--danger);color:#fff;padding:2px 8px;border-radius:10px;margin-left:8px">0</span></h3>
      <div id="playerList" style="color:var(--text2);font-size:13px;padding:4px 0">暂无数据</div>
    </div>
    <div class="btn-row">
      <button class="btn btn-primary auth-only-btn" onclick="apiPost('/api/start',{},'启动服务器')">▶ 启动</button>
      <button class="btn btn-danger auth-only-btn" onclick="apiPost('/api/stop',{},'停止服务器')">⏹ 停止</button>
      <button class="btn btn-warning auth-only-btn" onclick="apiPost('/api/restart',{},'重启服务器')">🔄 重启</button>
      <button class="btn btn-outline auth-only-btn" onclick="apiPost('/api/backup',{},'创建备份')"> 立即备份</button>
    </div>
    <!-- CPU/内存趋势图 -->
    <div class="panel"><h3><span class="emoji">📈</span> CPU / 内存占用趋势</h3>
      <canvas id="perfChart" style="width:100%;height:180px;display:block"></canvas>
    </div>
    <!-- 自动化状态 -->
    <div class="panel"><h3><span class="emoji">⏰</span> 自动化状态</h3>
      <div class="auto-status-grid">
        <div class="auto-item"><span class="auto-label">💾 下次备份</span><span class="auto-value" id="autoNextBackup" style="color:var(--warning)">未计划</span></div>
        <div class="auto-item"><span class="auto-label">⚡ 定时开关机</span><span class="auto-value" id="autoSchedule" style="color:var(--text2)">已禁用</span></div>
        <div class="auto-item"><span class="auto-label">🔄 下次重启</span><span class="auto-value" id="autoNextRestart" style="color:var(--accent)">--:--</span></div>
        <div class="auto-item"><span class="auto-label">⏳ 剩余时间</span><span class="auto-value" id="autoCountdown" style="color:var(--text2)">未启用定时任务</span></div>
      </div>
    </div>
    <div class="panel"><h3><span class="emoji">📋</span> 最近日志</h3><div class="log-box" id="dashLogs"><div class="empty">加载中...</div></div></div>
  </div>
  <!-- 备份管理 -->
  <div id="page-backup" class="page">
    <div class="btn-row"><button class="btn btn-primary auth-only hidden" onclick="apiPost('/api/backup',{},'创建备份')">💾 创建新备份</button><button class="btn btn-outline" onclick="loadBackups()">🔄 刷新列表</button></div>
    <div class="panel"><h3><span class="emoji">💾</span> 备份列表</h3><div id="backupList"><div class="empty">加载中...</div></div></div>
  </div>
  <!-- 存档管理 -->
  <div id="page-saves" class="page">
    <div class="btn-row"><button class="btn btn-outline" onclick="loadSaves()">🔄 刷新列表</button></div>
    <div class="panel"><h3><span class="emoji">📁</span> 存档列表</h3><div id="saveList"><div class="empty">加载中...</div></div></div>
  </div>
  <!-- 蓝图管理 -->
  <div id="page-blueprints" class="page">
    <div class="btn-row">
      <button class="btn btn-primary" onclick="document.getElementById('bpUpload').click()">📤 上传蓝图</button>
      <button class="btn btn-outline" onclick="loadBlueprints()">🔄 刷新列表</button>
      <input type="file" id="bpUpload" accept=".sbp,.sbpcfg" style="display:none" onchange="uploadBlueprint(this)">
    </div>
    <div class="panel"><h3><span class="emoji">🔧</span> 蓝图列表</h3><div id="blueprintList"><div class="empty">加载中...</div></div></div>
  </div>
  <!-- 系统日志 -->
  <div id="page-logs" class="page">
    <div class="btn-row"><button class="btn btn-outline" onclick="loadLogs()">🔄 刷新日志</button></div>
    <div class="panel"><h3><span class="emoji">📋</span> 操作日志</h3><div class="log-box" id="fullLogs" style="max-height:600px"><div class="empty">加载中...</div></div></div>
  </div>
  <!-- 关于 -->
  <div id="page-about" class="page">
    <div style="max-width:520px;margin:40px auto;text-align:center">
      <div class="panel" style="padding:40px 32px">
        <div style="font-size:64px;margin-bottom:12px;filter:drop-shadow(0 0 16px rgba(56,189,248,.3))">🏭</div>
        <div style="font-size:24px;font-weight:700;color:#fff">幸福工厂服务器管理器</div>
        <div id="aboutVersion" style="font-size:13px;color:var(--text2);margin-top:6px;letter-spacing:.3px">加载中...</div>
        <div style="height:1px;background:var(--border);margin:20px 0"></div>
        <!-- 更新区域 -->
        <div style="margin-bottom:20px">
          <div style="display:flex;align-items:center;justify-content:center;gap:10px;margin-bottom:12px">
            <span style="font-size:13px;color:var(--text2)">更新通道：</span>
            <select id="channelSel" class="login-btn" style="font-size:12px">
              <option value="stable">🔵 稳定版</option>
              <option value="test">🧪 测试版</option>
            </select>
            <button class="btn btn-outline auth-only-btn" style="padding:6px 14px;font-size:12px" onclick="switchChannel()">切换</button>
          </div>
          <button class="btn btn-primary" id="checkUpdateBtn" onclick="checkUpdate()" style="width:100%">🔍 检查更新</button>
          <div id="updateStatus" style="margin-top:12px;font-size:13px;color:var(--text2)"></div>
          <div id="updateChangelog" style="display:none;margin-top:12px;text-align:left;background:var(--bg);border-radius:8px;padding:14px;font-size:12px;color:var(--text);max-height:200px;overflow-y:auto;white-space:pre-wrap;border:1px solid var(--border)"></div>
          <button class="btn btn-primary auth-only-btn" id="applyUpdateBtn" onclick="applyUpdate()" style="display:none;margin-top:12px;width:100%">📥 下载并更新</button>
        </div>
        <div style="height:1px;background:var(--border);margin:20px 0"></div>
        <div style="font-size:14px;margin-bottom:16px">作者：<b style="color:var(--accent)">冰霜蘑菇</b></div>
        <div style="display:flex;flex-direction:column;gap:12px;font-size:13px">
          <div>🌐 网站：<a href="https://www.sxtvip.top" target="_blank" style="color:var(--accent);text-decoration:none;border-bottom:1px dashed var(--accent)">www.sxtvip.top</a></div>
          <div>💬 反馈QQ群：<a href="https://qm.qq.com/cgi-bin/qm/qr?k=&group_code=264127585" target="_blank" style="color:var(--accent);text-decoration:none;border-bottom:1px dashed var(--accent)">264127585（点击加入）</a></div>
        </div>
        <div style="height:1px;background:var(--border);margin:20px 0"></div>
        <div id="aboutSysInfo" style="font-size:12px;color:var(--text2);line-height:1.8"></div>
      </div>
      <div style="font-size:11px;color:var(--text2);margin-top:16px">感谢使用本工具，如有问题或建议请加群反馈！</div>
    </div>
  </div>
</div>
<script>
const API = '';
let authToken = localStorage.getItem('sf_token') || '';
let authRequired = false;
let refreshTimer;
const AUTO_LOGOUT_SECONDS = 3600;  // 登录后自动退出时间
let logoutTimer = null;
let logoutRemaining = 0;

// ==================== 外观模式切换 ====================
let themeUserSet = !!localStorage.getItem('sf_theme');  // 用户是否手动选择过
function applyTheme(t){
  document.body.classList.toggle('light', t === 'light');
  const btn = document.getElementById('themeBtn');
  if(btn) btn.textContent = t === 'light' ? '🌙 深色模式' : '☀️ 浅色模式';
}
function toggleTheme(){
  const next = document.body.classList.contains('light') ? 'dark' : 'light';
  localStorage.setItem('sf_theme', next);
  themeUserSet = true;
  applyTheme(next);
  drawPerfChart(lastHistory);  // 主题切换后重绘折线图
}
function resolveSystemTheme(){
  try{ return (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) ? 'light' : 'dark'; }
  catch(e){ return 'light'; }
}
// 初始化：用户手动选择 > 默认浅色（主程序设置将在状态接口返回后同步）
applyTheme(localStorage.getItem('sf_theme') || 'light');

// ==================== 刷新频率选择 ====================
function getRefreshInterval(){
  const v = parseInt(localStorage.getItem('sf_refresh') || '5000', 10);
  return [3000, 5000, 60000].indexOf(v) >= 0 ? v : 5000;
}
function changeRefreshRate(){
  const v = document.getElementById('refreshSel').value;
  localStorage.setItem('sf_refresh', v);
  clearInterval(refreshTimer);
  refreshTimer = setInterval(refreshStatus, parseInt(v, 10));
  refreshStatus();
  toast('刷新频率已更新', 'success');
}
(function(){
  const sel = document.getElementById('refreshSel');
  const v = String(getRefreshInterval());
  if(sel) sel.value = v;
})();

// ==================== CPU/内存折线图 ====================
let lastHistory = [];
function drawPerfChart(hist){
  lastHistory = hist || [];
  const cv = document.getElementById('perfChart');
  if(!cv || !lastHistory.length) return;
  const light = document.body.classList.contains('light');
  const dpr = window.devicePixelRatio || 1;
  const w = cv.clientWidth || cv.parentElement.clientWidth || 600;
  const h = 180;
  cv.width = w * dpr; cv.height = h * dpr;
  const ctx = cv.getContext('2d');
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.clearRect(0, 0, w, h);
  const padL = 38, padR = 10, padT = 16, padB = 20;
  const pw = w - padL - padR, ph = h - padT - padB;
  const grid = light ? '#d8e2ec' : '#243447';
  const txt = light ? '#64748b' : '#7a8ba0';
  ctx.font = '10px "Segoe UI", sans-serif';
  ctx.lineWidth = 1;
  for(let p = 0; p <= 100; p += 25){
    const y = padT + ph * (1 - p / 100);
    ctx.strokeStyle = grid;
    ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(w - padR, y); ctx.stroke();
    ctx.fillStyle = txt; ctx.textAlign = 'right';
    ctx.fillText(p + '%', padL - 6, y + 3);
  }
  function line(key, color){
    ctx.strokeStyle = color; ctx.lineWidth = 2;
    ctx.beginPath();
    lastHistory.forEach((pt, i) => {
      const x = padL + pw * (lastHistory.length === 1 ? 0 : i / (lastHistory.length - 1));
      const v = Math.min(Math.max(pt[key] || 0, 0), 100);
      const y = padT + ph * (1 - v / 100);
      i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    });
    ctx.stroke();
  }
  line('cpu', light ? '#0284c7' : '#38bdf8');
  line('mem', light ? '#d97706' : '#f59e0b');
  // 图例
  ctx.textAlign = 'left';
  ctx.fillStyle = light ? '#0284c7' : '#38bdf8'; ctx.fillRect(padL + 6, padT - 12, 12, 6);
  ctx.fillStyle = txt; ctx.fillText('CPU', padL + 22, padT - 6);
  ctx.fillStyle = light ? '#d97706' : '#f59e0b'; ctx.fillRect(padL + 56, padT - 12, 12, 6);
  ctx.fillStyle = txt; ctx.fillText('内存', padL + 72, padT - 6);
  // 时间轴：起点/终点
  ctx.fillStyle = txt;
  ctx.fillText(lastHistory[0].t, padL, h - 5);
  ctx.textAlign = 'right';
  ctx.fillText(lastHistory[lastHistory.length - 1].t, w - padR, h - 5);
}

function showLogin(){
  document.getElementById('loginOverlay').classList.remove('hidden');
  setTimeout(()=>document.getElementById('loginPwd').focus(), 100);
}
function hideLogin(){
  document.getElementById('loginOverlay').classList.add('hidden');
  document.getElementById('loginErr').textContent = '';
  document.getElementById('loginPwd').value = '';
}
function toggleLogin(){
  if(authToken){ doLogout(); } else { showLogin(); }
}
function doLogout(){
  authToken = '';
  localStorage.removeItem('sf_token');
  stopLogoutCountdown();
  applyAuth();
  toast('已退出登录', 'success');
}

function startLogoutCountdown(){
  stopLogoutCountdown();
  logoutRemaining = AUTO_LOGOUT_SECONDS;
  const el = document.getElementById('logoutCountdown');
  el.style.display = 'inline';
  logoutTimer = setInterval(()=>{
    logoutRemaining--;
    if(logoutRemaining <= 0){
      doLogout();
      toast('登录已超时，自动退出', 'error');
      return;
    }
    const m = Math.floor(logoutRemaining/60), s = logoutRemaining%60;
    el.textContent = m+'分'+String(s).padStart(2,'0')+'秒后自动退出';
  }, 1000);
  const m = Math.floor(logoutRemaining/60), s = logoutRemaining%60;
  el.textContent = m+'分'+String(s).padStart(2,'0')+'秒后自动退出';
}

function stopLogoutCountdown(){
  if(logoutTimer){ clearInterval(logoutTimer); logoutTimer = null; }
  document.getElementById('logoutCountdown').style.display = 'none';
}

async function doLogin(){
  const pwd = document.getElementById('loginPwd').value;
  if(!pwd){ document.getElementById('loginErr').textContent='请输入密码'; return; }
  try{
    const r = await fetch(API+'/api/login', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({password:pwd})});
    const res = await r.json();
    if(res.success){
      authToken = res.token;
      localStorage.setItem('sf_token', authToken);
      hideLogin();
      applyAuth();
      startLogoutCountdown();
      toast('登录成功', 'success');
      refreshStatus();
    } else {
      document.getElementById('loginErr').textContent = res.message || '密码错误';
    }
  }catch(e){ document.getElementById('loginErr').textContent = '请求失败'; }
}

function applyAuth(){
  const loggedIn = !!authToken;
  // 显示/隐藏需要认证的导航按钮
  document.querySelectorAll('.auth-only').forEach(el=>{
    el.classList.toggle('hidden', !loggedIn);
  });
  // 显示/隐藏需要认证的操作按钮
  document.querySelectorAll('.auth-only-btn').forEach(el=>{
    el.classList.toggle('disabled', !loggedIn);
  });
  // 登录按钮文字
  document.getElementById('loginBtn').textContent = loggedIn ? '🚪 退出登录' : '🔐 登录';
  // 未登录提示横幅
  document.getElementById('lockedBanner').style.display = (authRequired && !loggedIn) ? 'flex' : 'none';
  // 刷新蓝图列表（登录状态影响删除按钮显示）
  if(document.getElementById('page-blueprints').classList.contains('active')){ loadBlueprints(); }
  // 刷新备份列表（登录状态影响删除按钮显示）
  if(document.getElementById('page-backup').classList.contains('active')){ loadBackups(); }
}

function showPage(name, btn){
  if(!authToken && btn && btn.classList.contains('auth-only')){ showLogin(); return; }
  document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
  document.querySelectorAll('.nav button').forEach(b=>b.classList.remove('active'));
  document.getElementById('page-'+name).classList.add('active');
  if(btn) btn.classList.add('active');
  if(name==='backup') loadBackups();
  if(name==='saves') loadSaves();
  if(name==='blueprints') loadBlueprints();
  if(name==='logs') loadLogs();
}

async function apiGet(url){
  const headers = authToken ? {'X-Auth-Token': authToken} : {};
  const r = await fetch(API+url, {headers});
  if(r.status === 401){ authToken=''; localStorage.removeItem('sf_token'); applyAuth(); }
  return r.json();
}
async function apiPost(url, data, msg){
  if(!authToken){ toast('请先登录', 'error'); showLogin(); return; }
  try{
    const r = await fetch(API+url, {method:'POST', headers:{'Content-Type':'application/json','X-Auth-Token':authToken}, body: JSON.stringify(data)});
    if(r.status === 401){ authToken=''; localStorage.removeItem('sf_token'); applyAuth(); toast('登录已过期，请重新登录', 'error'); return; }
    const res = await r.json();
    toast(res.message || msg || '操作完成', res.success !== false ? 'success' : 'error');
    refreshStatus();
    return res;
  }catch(e){ toast('请求失败: '+e, 'error'); }
}

function toast(msg, type){
  const t = document.createElement('div');
  t.className = 'toast '+type;
  t.textContent = msg;
  document.body.appendChild(t);
  setTimeout(()=>t.remove(), 3000);
}

function fmtPct(v){
  const cls = v<60?'fill-green':v<85?'fill-yellow':'fill-red';
  return {text: v.toFixed(1)+'%', cls};
}

async function refreshStatus(){
  try{
    const d = await apiGet('/api/status');
    authRequired = d.auth_required || false;
    // 同步主程序外观模式（仅在用户未手动选择过时生效）
    if(!themeUserSet && d.appearance_mode){
      const m = d.appearance_mode;
      applyTheme(m === 'System' ? resolveSystemTheme() : (m === 'Dark' ? 'dark' : 'light'));
    }
    // 更新 CPU/内存趋势折线图
    if(d.history && d.history.length){ drawPerfChart(d.history); }
    // 更新标题栏版本号
    if(d.version){
      document.getElementById('headerVersion').textContent = 'Web 控制台 ' + d.version;
    }
    const dot = document.querySelector('#headerStatus .status-dot');
    const txt = document.getElementById('headerStatusText');
    if(d.server_running){ dot.className='status-dot on'; txt.textContent='运行中'; }
    else { dot.className='status-dot off'; txt.textContent='未运行'; }
    // 更新服务器状态卡片
    const icon = document.getElementById('srvStatusIcon');
    const stxt = document.getElementById('srvStatusText');
    const sdetail = document.getElementById('srvStatusDetail');
    if(d.server_running){
      icon.style.background='var(--success)'; icon.style.boxShadow='0 0 16px rgba(34,197,94,.5)'; icon.textContent='⏻'; icon.style.animation='pulse 2s infinite';
      stxt.textContent='服务器运行中'; stxt.style.color='var(--success)';
    } else {
      icon.style.background='var(--danger)'; icon.style.boxShadow='0 0 12px rgba(239,68,68,.4)'; icon.textContent='⏻'; icon.style.animation='pulseRed 3s infinite';
      stxt.textContent='服务器已停止'; stxt.style.color='var(--danger)';
    }
    if(d.config){
      sdetail.textContent='端口:'+d.config.game_port+' | 分支:'+d.config.branch;
    }
    if(d.player_count !== undefined){
      document.getElementById('playerVal').innerHTML = d.player_count + '<span class="unit"> / ' + (d.max_players || '--') + '</span>';
      // 更新玩家列表
      const badge = document.getElementById('playerCountBadge');
      const list = document.getElementById('playerList');
      badge.textContent = d.player_count;
      badge.style.background = d.player_count > 0 ? 'var(--success)' : 'var(--danger)';
      const gs2 = d.game_state || {};
      const players = gs2.connectedPlayers || [];
      if(players.length > 0){
        let html = '<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px">';
        players.forEach((p, i) => {
          const name = p.name || p.playerName || '未知';
          html += '<span style="background:var(--card2);padding:4px 12px;border-radius:6px;font-size:13px">' + (i+1) + '. ' + name + '</span>';
        });
        html += '</div>';
        list.innerHTML = html;
      } else if(d.player_count > 0){
        list.innerHTML = '<span style="color:var(--text2)">' + d.player_count + ' 名玩家在线（API未返回名称）</span>';
      } else {
        list.innerHTML = '<span style="color:var(--text2)">暂无在线玩家</span>';
      }
    }
    // 更新游戏状态
    const gs = d.game_state || {};
    if(gs.gamePhase){
      const phase = gs.gamePhase;
      let phaseText = '--';
      if(phase.includes('Phase_1')) phaseText = 'Phase 1 - 发射平台';
      else if(phase.includes('Phase_2')) phaseText = 'Phase 2 - 建造坞';
      else if(phase.includes('Phase_3')) phaseText = 'Phase 3 - 太空电梯';
      else { const n = phase.split('/').pop().replace(/GP_/g,'').replace(/_/g,' '); phaseText = n; }
      document.getElementById('gamePhaseVal').textContent = phaseText;
    } else { document.getElementById('gamePhaseVal').textContent = '--'; }
    if(gs.totalGameDuration){
      const sec = parseInt(gs.totalGameDuration);
      const d2 = Math.floor(sec/86400), h = Math.floor((sec%86400)/3600), m = Math.floor((sec%3600)/60);
      let dur = '';
      if(d2) dur += d2+'天'; if(h) dur += h+'小时'; if(m) dur += m+'分';
      document.getElementById('gameDurationVal').textContent = dur || sec+'秒';
    } else { document.getElementById('gameDurationVal').textContent = '--'; }
    if(gs.activeSchematic){
      const sch = gs.activeSchematic;
      const m2 = sch.match(/Schematic_(\d+)-(\d+)/);
      document.getElementById('milestoneVal').textContent = m2 ? 'T'+m2[1]+'-'+m2[2] : sch.split('/').pop().replace(/_/g,' ').replace('.C','');
    } else { document.getElementById('milestoneVal').textContent = '--'; }
    document.getElementById('techTierVal').textContent = gs.techTier ? 'T'+gs.techTier : '--';
    const s = d.system;
    if(s){
      const c = fmtPct(s.cpu_percent); document.getElementById('cpuVal').innerHTML=c.text; document.getElementById('cpuBar').className='fill '+c.cls; document.getElementById('cpuBar').style.width=s.cpu_percent+'%';
      const m = fmtPct(s.memory_percent); document.getElementById('memVal').innerHTML=s.memory_used_gb+'/'+s.memory_total_gb+' GB ('+m.text+')'; document.getElementById('memBar').className='fill '+m.cls; document.getElementById('memBar').style.width=s.memory_percent+'%';
      document.getElementById('uptimeVal').textContent = s.uptime;
    }
    if(d.logs && d.logs.length){
      document.getElementById('dashLogs').innerHTML = d.logs.slice(-20).map(l=>'<div class="log-line info">'+escHtml(l)+'</div>').join('');
    }
    // 自动化状态
    if(d.automation){
      const nb = document.getElementById('autoNextBackup');
      nb.textContent = d.automation.next_backup || '未计划';
      nb.style.color = d.automation.backup_enabled ? 'var(--warning)' : 'var(--text2)';
      const sc = document.getElementById('autoSchedule');
      sc.textContent = d.automation.schedule_text || '已禁用';
      sc.style.color = d.automation.schedule_enabled ? 'var(--success)' : 'var(--text2)';
      const nr = document.getElementById('autoNextRestart');
      nr.textContent = d.automation.next_restart || '--:--';
      nr.style.color = d.automation.schedule_enabled ? 'var(--accent)' : 'var(--text2)';
      const cd = document.getElementById('autoCountdown');
      cd.textContent = d.automation.countdown || '未启用定时任务';
      cd.style.color = d.automation.schedule_enabled ? 'var(--success)' : 'var(--text2)';
    }
    applyAuth();
  }catch(e){}
}

async function loadBackups(){
  const list = await apiGet('/api/backups');
  const el = document.getElementById('backupList');
  if(!list.length){ el.innerHTML='<div class="empty">暂无备份</div>'; return; }
  const canDelete = !!authToken;
  el.innerHTML = '<table><tr><th>名称</th><th>时间</th><th>大小</th><th>操作</th></tr>'+
    list.map(b=>'<tr><td>'+b.name+'</td><td>'+b.time+'</td><td>'+b.size_mb+' MB</td><td style="display:flex;gap:6px"><button class="btn btn-outline" style="padding:4px 12px;font-size:12px" onclick="downloadBackup(\''+b.name+'\')">📥 下载</button>'+(canDelete?'<button class="btn btn-danger" style="padding:4px 12px;font-size:12px" onclick="delBackup(\''+b.name+'\')">删除</button>':'')+'</td></tr>').join('')+'</table>';
}

function downloadBackup(name){
  window.open(API+'/api/backups/download?name='+encodeURIComponent(name), '_blank');
}

async function delBackup(name){
  if(!confirm('确定删除备份 '+name+' ?')) return;
  await apiPost('/api/backup/delete', {name}, '删除备份');
  loadBackups();
}

async function loadSaves(){
  const list = await apiGet('/api/saves');
  const el = document.getElementById('saveList');
  if(!list.length){ el.innerHTML='<div class="empty">暂无存档</div>'; return; }
  el.innerHTML = '<table><tr><th>名称</th><th>修改时间</th><th>大小</th><th>操作</th></tr>'+
    list.map(s=>'<tr><td>'+s.name+'</td><td>'+s.time+'</td><td>'+s.size_mb+' MB</td><td><button class="btn btn-outline" style="padding:4px 12px;font-size:12px" onclick="downloadSave(\''+s.name+'\')">📥 下载</button></td></tr>').join('')+'</table>';
}

function downloadSave(name){
  window.open(API+'/api/saves/download?name='+encodeURIComponent(name), '_blank');
}

// ==================== 蓝图管理 ====================
async function loadBlueprints(){
  const list = await apiGet('/api/blueprints');
  const el = document.getElementById('blueprintList');
  if(!list.length){ el.innerHTML='<div class="empty">暂无蓝图文件，点击上方按钮上传</div>'; return; }
  const canDelete = !!authToken;
  el.innerHTML = '<table><tr><th>名称</th><th>修改时间</th><th>大小</th>'+(canDelete?'<th>操作</th>':'')+'</tr>'+
    list.map(b=>'<tr><td>'+escHtml(b.name)+'</td><td>'+b.time+'</td><td>'+b.size_kb+' KB</td>'+(canDelete?'<td><button class="btn btn-danger" style="padding:4px 12px;font-size:12px" onclick="deleteBlueprint(\''+escHtml(b.name).replace(/'/g,"\\'")+'\')">🗑️ 删除</button></td>':'')+'</tr>').join('')+'</table>';
}

function uploadBlueprint(input){
  const file = input.files[0];
  if(!file) return;
  const reader = new FileReader();
  reader.onload = async function(e){
    const base64 = e.target.result.split(',')[1];
    const result = await apiPost('/api/blueprints/upload', {
      file_name: file.name,
      file_data: base64
    }, '上传蓝图');
    if(result && result.success){
      loadBlueprints();
    }
    input.value = '';
  };
  reader.readAsDataURL(file);
}

async function deleteBlueprint(name){
  confirmAction('删除蓝图', '确定要删除蓝图 "'+escHtml(name)+'" 吗？此操作不可恢复。', async function(){
    await apiPost('/api/blueprints/delete', {name}, '删除蓝图');
    loadBlueprints();
  });
}

async function loadLogs(){
  const d = await apiGet('/api/logs?limit=1000');
  const el = document.getElementById('fullLogs');
  if(!d.logs.length){ el.innerHTML='<div class="empty">暂无日志</div>'; return; }
  el.innerHTML = d.logs.map(l=>'<div class="log-line info">'+escHtml(l)+'</div>').join('');
  el.scrollTop = el.scrollHeight;
}

function escHtml(s){ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }

// 初始化
applyAuth();
refreshStatus();
refreshTimer = setInterval(refreshStatus, getRefreshInterval());

async function loadAbout(){
  try{
    const d = await apiGet('/api/about');
    document.getElementById('aboutVersion').textContent = '版本 ' + (d.app_version || d.web_version || '');
    const sysInfo = document.getElementById('aboutSysInfo');
    sysInfo.innerHTML = 'Web 控制台: ' + d.web_version + '<br>' +
      'Python: ' + (d.python_version || '--') + '<br>' +
      '系统: ' + (d.platform || '--');
  }catch(e){}
}

async function checkUpdate(){
  const btn = document.getElementById('checkUpdateBtn');
  const status = document.getElementById('updateStatus');
  const changelog = document.getElementById('updateChangelog');
  const applyBtn = document.getElementById('applyUpdateBtn');
  btn.disabled = true;
  btn.textContent = '检查中...';
  status.textContent = '正在检查远程版本...';
  status.style.color = 'var(--text2)';
  changelog.style.display = 'none';
  applyBtn.style.display = 'none';
  applyBtn.textContent = '📥 下载并更新';
  window._manualUpdateUrl = null;
  try{
    const d = await apiGet('/api/update/check');
    if(d.channel){ document.getElementById('channelSel').value = d.channel; }
    const chTip = d.channel_name ? '（' + d.channel_name + '通道）' : '';
    if(d.has_update){
      if(d.changelog){
        changelog.textContent = d.changelog;
        changelog.style.display = 'block';
      }
      if(d.manual_required){
        // 大版本更新：打包格式变更，不支持自动更新，引导手动下载解压
        status.innerHTML = '发现大版本更新' + chTip + ': <b style="color:var(--warning)">' + d.remote_version + '</b> (当前: ' + d.local_version + ')<br>本次更新更换了打包格式，无法自动更新，请手动下载解压覆盖。';
        status.style.color = 'var(--warning)';
        window._manualUpdateUrl = d.manual_url || '';
        applyBtn.textContent = '📥 手动下载更新包';
        applyBtn.style.display = 'block';
        return;
      }
      status.innerHTML = '发现新版本' + chTip + ': <b style="color:var(--success)">' + d.remote_version + '</b> (当前: ' + d.local_version + ')';
      status.style.color = 'var(--success)';
      if(authToken) applyBtn.style.display = 'block';
    } else {
      status.innerHTML = '当前已是最新版本' + chTip + ' (' + d.local_version + ')';
      status.style.color = 'var(--text2)';
      if(d.remote_version && d.remote_version !== 'null'){
        status.innerHTML += '<br>远程版本: ' + d.remote_version;
      }
    }
  }catch(e){
    status.textContent = '检查失败: ' + e.message;
    status.style.color = 'var(--danger)';
  }finally{
    btn.disabled = false;
    btn.textContent = '🔍 检查更新';
  }
}

async function switchChannel(){
  if(!authToken){ toast('请先登录', 'error'); showLogin(); return; }
  const ch = document.getElementById('channelSel').value;
  if(ch === 'test' && !confirm('测试版包含最新功能但可能不稳定，仅建议尝鲜用户使用。\n确定切换到测试版通道吗？')){
    return;
  }
  const res = await apiPost('/api/update/channel', {channel: ch}, '切换更新通道');
  if(res && res.success){
    checkUpdate();  // 切换后重新检查新通道的版本
  }
}

async function applyUpdate(){
  // 大版本手动更新模式：直接打开下载链接
  if(window._manualUpdateUrl){
    window.open(window._manualUpdateUrl, '_blank');
    toast('已打开下载页面，解压后将全部文件覆盖到程序目录即可', 'success');
    return;
  }
  if(!authToken){ toast('请先登录', 'error'); showLogin(); return; }
  if(!confirm('确定要下载并安装更新吗？更新后程序将自动重启。')) return;
  const applyBtn = document.getElementById('applyUpdateBtn');
  const status = document.getElementById('updateStatus');
  applyBtn.disabled = true;
  applyBtn.textContent = '更新中...';
  status.innerHTML = '正在下载更新包，请稍候...';
  status.style.color = 'var(--warning)';
  try{
    const d = await apiPost('/api/update/apply', {}, null);
    status.innerHTML = '更新已应用，程序即将重启...';
    status.style.color = 'var(--success)';
  }catch(e){
    status.innerHTML = '更新失败: ' + (e.message || '未知错误');
    status.style.color = 'var(--danger)';
    applyBtn.disabled = false;
    applyBtn.textContent = '📥 下载并更新';
  }
}
</script>
</body>
</html>'''

# ==================== 启动 Web 服务器 ====================
_http_server = None  # 保存HTTP服务器引用，用于停止

class DualStackHTTPServer(ThreadingMixIn, HTTPServer):
    """支持 IPv4 + IPv6 双栈 + 多线程的 HTTP 服务器"""
    address_family = socket.AF_INET6
    daemon_threads = True  # 主线程退出时自动关闭请求线程
    def server_bind(self):
        self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
        super().server_bind()

def shutdown_web_server():
    """安全关闭 Web 服务器"""
    global _http_server
    if _http_server:
        try:
            _http_server.shutdown()
            _http_server.server_close()
            wlog("Web 服务器已关闭")
        except Exception:
            pass
        _http_server = None

def run_web_server(port=WEB_PORT, config_file=None, controller=None):
    global CONFIG_FILE, _http_server
    if config_file:
        CONFIG_FILE = config_file
    if controller:
        set_controller(controller)
    # 使用双栈绑定，同时支持 IPv4 和 IPv6
    server = DualStackHTTPServer(('::', port), WebHandler)
    _http_server = server
    pwd = get_web_password()
    wlog(f"Web 服务器启动在 http://[::]:{port}")
    wlog(f"本地访问: http://localhost:{port} 或 http://127.0.0.1:{port}")
    wlog(f"配置文件: {CONFIG_FILE}")
    if pwd:
        wlog("登录认证: 已启用")
    else:
        wlog("登录认证: 未设置密码（所有功能开放）")
    if _controller_ref:
        wlog("控制器: 已连接主程序")
    # 启动 CPU/内存历史采集线程
    threading.Thread(target=_perf_sampler, daemon=True).start()
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        wlog("Web 服务器已停止")
        server.server_close()
    except OSError as e:
        wlog(f"Web 服务器启动失败: {e}")
    finally:
        _http_server = None

if __name__ == "__main__":
    print("=" * 50)
    print("  幸福工厂服务器管理器 - Web 控制端")
    print("=" * 50)
    run_web_server()

