Files
fnos-gitea-sync/GiteaHubSync/app/backend/panel.py
T
cbb 58197fd35b fix: v1.5.5 — 「打开 Gitea」远程地址推断三重保险
根因:gitea_ui() 有个判断 bug——当请求 Host 是 127.0.0.1:8899
(CGI 内部连接面板的本地地址)时,面板把它当作"远程主机名"
去替换 gitea_host 中的 127.0.0.1,结果还是 127.0.0.1:3000。

修复:
1. 修判断 bug:只有推断出的真实主机名不是 loopback 时才替换
   gitea_host 中的 127.0.0.1(本地直连保持原样,不再被错误"替换")
2. gitea_ui 新增 Referer 兜底:即使 CGI 没转发 Host/X-Forwarded-Host
   (fnOS CGI 环境里 $HTTP_HOST 可能为空),也能从浏览器自带的
   Referer 头里推断出真实主机名
3. CGI 代理兜底转发:从 X-Forwarded-Host / HTTP_HOST / Referer
   多源提取真实主机,作为 Host 和 X-Forwarded-Host 转发
4. 调用方传入 X-Forwarded-Host / Host / Referer 三个头

实测 6 种场景(远程 CGI / 远程直连 / 只有 Referer / 本地直连 /
全部为空 / 远程 IP)全部生成正确地址
manifest 1.5.4 → 1.5.5
2026-10-15 01:23:57 +08:00

2062 lines
98 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# ============================================================
# GiteaHubSync — Gitea → GitHub 镜像同步守护进程 + 控制面板
# 纯 Python 标准库。随 fnOS 应用安装,由 cmd/main 以
# setsid nohup 方式拉起;数据目录 = TRIM_PKGVAR(@appdata/GiteaHubSync)
# 页面: / 控制面板(开关/立即同步/快捷入口)
# /status 项目状态(每个仓库的详细同步状态)
# /manage 项目管理(从 Gitea 快捷添加/移除同步项目)
# /settings 设置(GitHub 多账号/切换用户、Gitea 用户名、自动创建仓库)
# 多账号: accounts.json 保存多个 GitHub 账号(用户名 + Personal Access Token),
# 可随时切换「当前账号」;每个账号独立记录同步进度。带 Token 的账号走
# HTTPS 推送并支持自动创建 GitHub 仓库;无 Token 的旧账号走 SSH 私钥推送。
# ============================================================
import atexit
import json
import os
import re
import signal
import subprocess
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import quote, urlsplit
APP_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = Path(os.environ.get("GHS_DATA_DIR", "/vol1/@appdata/GiteaHubSync"))
REPO_DIR = DATA_DIR / "repos"
STATE_DIR = DATA_DIR / ".last-pushed"
LOG_FILE = DATA_DIR / "sync.log"
PID_FILE = DATA_DIR / "daemon.pid"
PANEL_STATE = DATA_DIR / "state.json"
REPOS_FILE = DATA_DIR / "repos.json"
STATS_FILE = DATA_DIR / "stats.json"
ACCOUNTS_FILE = DATA_DIR / "accounts.json" # GitHub 多账号(含 Token,权限 600)
SETTINGS_FILE = DATA_DIR / "settings.json" # Gitea 用户名/密码、自动创建开关
GITCREDS_FILE = DATA_DIR / ".gitcreds" # git credential store(Gitea 密码,权限 600)
SSH_KEY = APP_DIR / "ssh" / "id_ed25519"
if (DATA_DIR / "id_ed25519").exists():
SSH_KEY = DATA_DIR / "id_ed25519"
KNOWN_HOSTS = DATA_DIR / "known_hosts"
GITEA_URL_ENV = os.environ.get("GITEA_URL") # “打开 Gitea”按钮地址覆盖,一般不设
DEFAULT_GITEA_HOST = "http://127.0.0.1:3000" # 同步引擎访问的 Gitea 地址(设置页可改)
DEFAULT_REPOS = ["SMB_Android", "DCU_APP", "GD32E50X_BOOT", "DCU_DesktopController",
"Motor_v2", "DCU_ECAT_Master", "GD32_BOOT"]
LEGACY_GH_USER = "cbb2625274797" # 旧版硬编码账号,仅用于首次运行的迁移
LEGACY_GITEA_USER = "cbb" # 旧版硬编码的 Gitea 用户名,仅作默认值
DEFAULT_INTERVAL = 10 # 同步间隔默认值(秒)
MIN_INTERVAL = 5 # 最小同步间隔(秒)
MAX_INTERVAL = 86400 # 最大同步间隔(秒,1 天)
DEFAULT_WORKERS = 3 # 同时同步的项目数默认值
MIN_WORKERS = 1
MAX_WORKERS = 8
RETRY_INTERVAL = 60 # 同步失败后的自动重试间隔(秒)
DEFAULT_MAX_RETRIES = 5 # 失败重试次数默认值
MIN_MAX_RETRIES = 0 # 0 = 不重试
MAX_MAX_RETRIES = 20
PANEL_PORT = int(os.environ.get("PANEL_PORT", "8899"))
START_TS = time.time()
NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,100}$")
ENV = dict(os.environ)
ENV["GIT_SSH_COMMAND"] = (f"ssh -i {SSH_KEY} -o IdentitiesOnly=yes "
f"-o StrictHostKeyChecking=accept-new "
f"-o UserKnownHostsFile={KNOWN_HOSTS}")
ENV["GIT_TERMINAL_PROMPT"] = "0"
# ---------------- 应用设置(settings.json) ----------------
cfg = {"gitea_user": LEGACY_GITEA_USER, "gitea_pass": "", "auto_create": True,
"interval": DEFAULT_INTERVAL, "gitea_host": DEFAULT_GITEA_HOST,
"max_workers": DEFAULT_WORKERS, "max_retries": DEFAULT_MAX_RETRIES}
def norm_gitea_host(h):
"""规范化 Gitea 地址:补 http://、去尾部斜杠;不合法返回 None"""
h = str(h or "").strip().rstrip("/")
if not h:
return None
if "://" not in h:
h = "http://" + h
try:
p = urlsplit(h)
host = p.hostname or ""
if p.scheme in ("http", "https") and host \
and not p.path.strip("/") and not p.query and not p.fragment \
and re.fullmatch(r"[A-Za-z0-9.\-:\[\]]+", host):
return h
except Exception:
pass
return None
def gitea_host():
return cfg.get("gitea_host") or DEFAULT_GITEA_HOST
def gitea_ui(host_header="", referer="", xfwd_host=""):
"""「打开 Gitea」按钮地址:把 127.0.0.1/localhost 替换成真实远程主机名。
推断顺序:X-Forwarded-Host(CGI 转发) > Host > Referer(浏览器自带)。
注意:仅当推断出的真实主机名不是 loopback 时才替换(本地直连保持原样)。"""
if GITEA_URL_ENV:
return GITEA_URL_ENV
base = gitea_host()
real = (xfwd_host or host_header or "").split(":")[0].strip()
if not real and referer:
m = re.match(r'^https?://([^/:/?#]+)', referer)
if m:
real = m.group(1)
if not real or real in ("127.0.0.1", "localhost", "0.0.0.0", "::1"):
return base # 本地/无法推断,保留 gitea_host 原值
try:
p = urlsplit(base)
if p.hostname in ("127.0.0.1", "localhost", "0.0.0.0", "::1", ""):
return f"{p.scheme}://{real}" + (f":{p.port}" if p.port else "")
except Exception:
pass
return base
def sync_interval():
"""当前同步间隔(秒),始终返回合法值"""
try:
iv = int(cfg.get("interval", DEFAULT_INTERVAL))
except Exception:
iv = DEFAULT_INTERVAL
return min(MAX_INTERVAL, max(MIN_INTERVAL, iv))
def valid_interval(v):
try:
return MIN_INTERVAL <= int(v) <= MAX_INTERVAL
except Exception:
return False
def sync_workers():
"""同时同步的项目数,始终返回合法值"""
try:
n = int(cfg.get("max_workers", DEFAULT_WORKERS))
except Exception:
n = DEFAULT_WORKERS
return min(MAX_WORKERS, max(MIN_WORKERS, n))
def valid_workers(v):
try:
return MIN_WORKERS <= int(v) <= MAX_WORKERS
except Exception:
return False
def max_retries():
"""失败自动重试次数,始终返回合法值"""
try:
n = int(cfg.get("max_retries", DEFAULT_MAX_RETRIES))
except Exception:
n = DEFAULT_MAX_RETRIES
return min(MAX_MAX_RETRIES, max(MIN_MAX_RETRIES, n))
def valid_retries(v):
try:
return MIN_MAX_RETRIES <= int(v) <= MAX_MAX_RETRIES
except Exception:
return False
def load_settings():
try:
d = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
if isinstance(d.get("gitea_user"), str) and valid_name(d["gitea_user"]):
cfg["gitea_user"] = d["gitea_user"]
cfg["gitea_pass"] = str(d.get("gitea_pass") or "")
cfg["auto_create"] = bool(d.get("auto_create", True))
if valid_interval(d.get("interval")):
cfg["interval"] = int(d["interval"])
if valid_workers(d.get("max_workers")):
cfg["max_workers"] = int(d["max_workers"])
if valid_retries(d.get("max_retries")):
cfg["max_retries"] = int(d["max_retries"])
h = norm_gitea_host(d.get("gitea_host"))
if h:
cfg["gitea_host"] = h
except Exception:
pass
write_gitcreds()
def save_settings():
try:
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
SETTINGS_FILE.write_text(json.dumps(cfg, ensure_ascii=False, indent=1),
encoding="utf-8")
os.chmod(SETTINGS_FILE, 0o600) # 含 Gitea 密码,仅属主可读
except Exception as e:
log(f"保存 settings.json 失败: {e}")
write_gitcreds()
def gitea_base():
"""Gitea 上当前用户的仓库前缀(地址可在设置页修改)"""
return f"{gitea_host()}/{cfg['gitea_user']}"
# ---------------- GitHub 多账号(accounts.json) ----------------
accounts = [] # [{id,label,gh_user,token,private,created}]
active_id = None
def load_accounts():
"""加载账号;首次运行从旧版迁移:生成「默认(SSH 私钥)」账号,行为与旧版一致"""
global accounts, active_id
try:
d = json.loads(ACCOUNTS_FILE.read_text(encoding="utf-8"))
accounts = [a for a in d.get("accounts", []) if a.get("id") and a.get("gh_user")]
active_id = d.get("active")
except Exception:
accounts = []
if not accounts:
accounts = [{"id": "default", "label": "默认(SSH 私钥)",
"gh_user": LEGACY_GH_USER, "token": "", "private": True,
"created": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}]
active_id = "default"
migrate_flat_state()
save_accounts()
if active_id not in [a["id"] for a in accounts]:
active_id = accounts[0]["id"]
save_accounts()
def save_accounts():
try:
ACCOUNTS_FILE.parent.mkdir(parents=True, exist_ok=True)
ACCOUNTS_FILE.write_text(
json.dumps({"active": active_id, "accounts": accounts},
ensure_ascii=False, indent=1), encoding="utf-8")
os.chmod(ACCOUNTS_FILE, 0o600) # 含 Token,仅属主可读
except Exception as e:
log(f"保存 accounts.json 失败: {e}")
def active_account():
for a in accounts:
if a["id"] == active_id:
return a
return accounts[0] if accounts else None
def find_account(aid):
for a in accounts:
if a["id"] == aid:
return a
return None
def migrate_flat_state():
"""旧版同步状态平铺在 STATE_DIR/<name>;新版按账号分目录 STATE_DIR/<account>/<name>"""
try:
legacy_dir = STATE_DIR / "default"
legacy_dir.mkdir(parents=True, exist_ok=True)
for f in STATE_DIR.iterdir():
if f.is_file() and valid_name(f.name):
f.rename(legacy_dir / f.name)
except Exception:
pass
def state_path(acc, name):
return STATE_DIR / ((acc or {}).get("id") or "default") / name
def mask_token(tok):
if not tok:
return ""
if len(tok) <= 8:
return "****"
return tok[:7] + "****" + tok[-4:]
def scrub(s, acc):
"""日志/报错脱敏:GitHub Token 和 Gitea 密码不进日志"""
if not s:
return s
if acc and acc.get("token"):
s = s.replace(acc["token"], "***")
if cfg.get("gitea_pass"):
s = s.replace(cfg["gitea_pass"], "***")
return s
state = {"enabled": True, "syncing": False, "last_check": None, "checks": 0,
"last_result": "尚未运行", "loop_last": None}
stats = {"total_checks": 0, "total_pushes": 0, "total_failures": 0, "total_retries": 0}
op_lock = threading.Lock() # 整轮同步互斥(同一轮只跑一次)
stats_lock = threading.Lock() # 保护 stats 计数
log_lock = threading.Lock() # 保护日志文件写入
repo_busy_lock = threading.Lock() # 保护 repo_in_progress
repo_in_progress = set() # 正在同步的仓库名(按仓库防重入)
# ---------------- 基础工具 ----------------
def log(msg):
with log_lock:
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n")
except Exception:
pass
def bump_stats(**kw):
"""线程安全地累加统计计数"""
with stats_lock:
for k, v in kw.items():
stats[k] = stats.get(k, 0) + v
save_stats()
def write_gitcreds():
"""把 Gitea 凭据写成 git credential store 文件(600),供 clone/fetch 读取;
这样镜像仓库的 remote 地址里不保存密码。清除密码时删除该文件。"""
try:
if cfg.get("gitea_pass"):
p = urlsplit(gitea_host())
netloc = p.hostname + (f":{p.port}" if p.port else "")
user = quote(str(cfg["gitea_user"]), safe="")
pw = quote(str(cfg["gitea_pass"]), safe="")
GITCREDS_FILE.write_text(f"{p.scheme}://{user}:{pw}@{netloc}\n",
encoding="utf-8")
os.chmod(GITCREDS_FILE, 0o600)
elif GITCREDS_FILE.exists():
GITCREDS_FILE.unlink()
except Exception:
pass
def repoint_mirror_remotes():
"""Gitea 地址变更后,把已有镜像仓库的 origin 指向新地址"""
n = 0
try:
for repo in REPO_DIR.glob("*.git"):
r = git(["-C", str(repo), "remote", "set-url", "origin",
f"{gitea_base()}/{repo.name[:-4]}.git"], timeout=20)
if r.returncode == 0:
n += 1
except Exception as e:
log(f"更新镜像 remote 失败: {e}")
return n
def git_creds_args():
if cfg.get("gitea_pass") and GITCREDS_FILE.exists():
return ["-c", f"credential.helper=store --file={GITCREDS_FILE}"]
return []
def git(args, timeout=300):
return subprocess.run(["git"] + git_creds_args() + args, capture_output=True,
text=True, env=ENV, timeout=timeout)
def load_stats():
global stats
try:
stats.update(json.loads(STATS_FILE.read_text(encoding="utf-8")))
except Exception:
pass
def save_stats():
try:
STATS_FILE.write_text(json.dumps(stats), encoding="utf-8")
except Exception:
pass
def load_repos():
"""同步项目列表(repos.json),首次运行播种默认 7 个"""
try:
data = json.loads(REPOS_FILE.read_text(encoding="utf-8"))
repos = [r for r in data.get("repos", []) if valid_name(r)]
if repos:
return repos
except Exception:
pass
save_repos(DEFAULT_REPOS)
return list(DEFAULT_REPOS)
def save_repos(repos):
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
REPOS_FILE.write_text(json.dumps({"repos": repos}, ensure_ascii=False, indent=1),
encoding="utf-8")
except Exception as e:
log(f"保存 repos.json 失败: {e}")
def valid_name(name):
return bool(name) and bool(NAME_RE.match(name)) and name not in (".", "..")
def gitea_api(path):
"""访问本地 Gitea API(配置了密码则带 Basic Auth,可读私有仓库),返回 (ok, json)"""
try:
cmd = ["curl", "-sS", "-m", "10"]
if cfg.get("gitea_pass"):
cmd += ["-u", f"{cfg['gitea_user']}:{cfg['gitea_pass']}"]
cmd.append(f"{gitea_host()}/api/v1{path}")
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
return r.returncode == 0, json.loads(r.stdout or "{}")
except Exception:
return False, {}
def gitea_probe(host=None):
"""探测 Gitea 地址连通性,返回 (ok, msg)"""
host = host or gitea_host()
try:
r = subprocess.run(["curl", "-sS", "-m", "8", "-w", "\n%{http_code}",
f"{host}/api/v1/version"],
capture_output=True, text=True, timeout=12)
out, _, code = r.stdout.rpartition("\n")
st = int(code) if code.strip().isdigit() else 0
if 200 <= st < 300:
try:
ver = json.loads(out or "{}").get("version", "?")
except Exception:
ver = "?"
return True, f"连接成功,Gitea 版本 {ver}"
return False, f"地址连不上(HTTP {st or '超时'}),请检查后重试"
except Exception as e:
return False, f"地址连不上:{e}"
def gitea_repo_exists(name):
ok, d = gitea_api(f"/repos/{cfg['gitea_user']}/{name}")
return ok and isinstance(d, dict) and d.get("full_name") == f"{cfg['gitea_user']}/{name}"
def gh_api(acc, path, method="GET", body=None, timeout=15):
"""调用 GitHub API(需 Token)。返回 (ok, http_status, json)"""
if not acc or not acc.get("token"):
return False, 0, {}
cmd = ["curl", "-sS", "-m", str(timeout), "-X", method,
"-H", f"Authorization: token {acc['token']}",
"-H", "Accept: application/vnd.github+json",
"-H", "Content-Type: application/json", "-w", "\n%{http_code}"]
if body is not None:
cmd += ["-d", json.dumps(body, ensure_ascii=False)]
cmd.append(f"https://api.github.com{path}")
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 5)
out, _, code = r.stdout.rpartition("\n")
status = int(code) if code.strip().isdigit() else 0
try:
d = json.loads(out or "{}")
except Exception:
d = {}
return (200 <= status < 300), status, d
except Exception:
return False, 0, {}
def gh_verify_token(token):
"""验证 Token 有效性,返回 (ok, login, msg)"""
token = (token or "").strip()
if not token:
return False, "", "Token 不能为空"
try:
r = subprocess.run(
["curl", "-sS", "-m", "15", "-w", "\n%{http_code}",
"-H", f"Authorization: token {token}",
"-H", "Accept: application/vnd.github+json",
"https://api.github.com/user"],
capture_output=True, text=True, timeout=20)
out, _, code = r.stdout.rpartition("\n")
status = int(code) if code.strip().isdigit() else 0
if 200 <= status < 300:
d = json.loads(out or "{}")
return True, (d.get("login") or ""), ""
if status == 401:
return False, "", "Token 无效或已过期(请重新生成)"
return False, "", f"GitHub API 返回 HTTP {status}"
except Exception as e:
return False, "", f"网络错误: {e}"
def gh_push_url(acc, name):
"""当前账号的 GitHub 推送地址:有 Token 走 HTTPS,否则回退 SSH 私钥"""
if acc.get("token"):
return f"https://x-access-token:{acc['token']}@github.com/{acc['gh_user']}/{name}.git"
return f"git@github.com:{acc['gh_user']}/{name}.git"
def github_repo_exists(acc, name):
if acc.get("token"):
ok, status, d = gh_api(acc, f"/repos/{acc['gh_user']}/{name}")
return ok and status == 200 and isinstance(d, dict) \
and d.get("full_name") == f"{acc['gh_user']}/{name}"
try:
r = git(["ls-remote", gh_push_url(acc, name), "HEAD"], timeout=30)
return r.returncode == 0
except Exception:
return False
def github_create_repo(acc, name):
"""在 GitHub 自动创建仓库(需 Token)。返回 (ok, msg)"""
if not acc.get("token"):
return False, "当前账号未配置 Token,无法自动创建(请到设置页添加带 Token 的 GitHub 账号)"
ok, status, d = gh_api(acc, "/user/repos", "POST", {
"name": name, "private": bool(acc.get("private", True)),
"description": f"镜像自飞牛 Gitea 仓库 {name}(GiteaHubSync 自动创建)"})
if ok and status == 201:
return True, "已创建"
if status == 422 and isinstance(d, dict) and "errors" in d:
return True, "已存在" # 重名等情况,按已存在继续推送
msg = d.get("message") if isinstance(d, dict) else str(d)
return False, f"HTTP {status} {msg}"
def list_gitea_repos():
ok, d = gitea_api(f"/users/{cfg['gitea_user']}/repos?limit=100")
if ok and isinstance(d, list):
return [x.get("name") for x in d if x.get("name")]
return []
# ---------------- 同步逻辑 ----------------
def fingerprint(repo):
r = git(["-C", str(repo), "for-each-ref", "--format=%(refname) %(objectname)"])
if r.returncode != 0:
return None
import hashlib
return hashlib.sha1(r.stdout.encode()).hexdigest()
def sync_repo(name):
"""返回 'changed' / 'same' / 'busy' / 'error:...';同一仓库并发调用时后到者返回 busy"""
if not valid_name(name):
return "error: 非法仓库名"
acc = active_account()
if not acc:
return "error: 没有可用的 GitHub 账号,请到设置页添加"
with repo_busy_lock:
if name in repo_in_progress:
return "busy"
repo_in_progress.add(name)
try:
return _sync_repo_locked(name, acc)
finally:
with repo_busy_lock:
repo_in_progress.discard(name)
def _sync_repo_locked(name, acc):
repo = REPO_DIR / f"{name}.git"
try:
if not repo.exists():
REPO_DIR.mkdir(parents=True, exist_ok=True)
r = git(["clone", "--quiet", "--mirror", f"{gitea_base()}/{name}.git", str(repo)])
if r.returncode != 0:
return "error: 克隆失败 " + scrub((r.stderr or "")[:120], acc)
log(f"{name}: 首次克隆完成")
r = git(["-C", str(repo), "fetch", "--quiet", "--prune", "origin"])
if r.returncode != 0:
return "error: fetch 失败 " + scrub((r.stderr or "")[:120], acc)
cur = fingerprint(repo)
if cur is None:
return "error: 读取 refs 失败"
st = state_path(acc, name)
prev = st.read_text().strip() if st.exists() else ""
if cur == prev:
return "same"
# 推送前确保 GitHub 仓库存在;不存在时按设置自动创建
if not github_repo_exists(acc, name):
if acc.get("token") and cfg.get("auto_create", True):
ok, msg = github_create_repo(acc, name)
if not ok:
return f"error: 自动创建 GitHub 仓库失败: {msg}"
log(f"{name}: GitHub 仓库不存在,已自动创建"
f"({'私有' if acc.get('private', True) else '公开'})")
else:
hint = ("当前账号无 Token 或未开启自动创建,请到设置页配置"
if not acc.get("token") else
"自动创建已关闭,请到设置页开启或在 GitHub 手动创建")
return f"error: GitHub 上不存在 {acc['gh_user']}/{name},{hint}"
p = git(["-C", str(repo), "push", "--mirror", gh_push_url(acc, name)])
if p.returncode != 0:
return "error: 推送失败 " + scrub((p.stderr or p.stdout or "")[:120], acc)
st.parent.mkdir(parents=True, exist_ok=True)
st.write_text(cur)
detail = " ".join(l.strip() for l in p.stdout.splitlines()
if any(k in l for k in ("new branch", "new tag", "deleted", "forced", "->")))
log(f"{name}: 已同步到 GitHub({acc['gh_user']})"
+ (f" | {scrub(detail, acc)}" if detail else ""))
return "changed"
except Exception as e:
return f"error: {scrub(str(e), acc)}"
def run_sync(repos=None):
"""跑一轮同步:多个项目按设置并行(默认 3 个同时)"""
with op_lock:
if state["syncing"]:
return False, "已有同步任务正在进行"
state["syncing"] = True
try:
repos = repos if repos is not None else load_repos()
results = {}
workers = sync_workers()
def worker(name):
try:
results[name] = sync_repo(name)
except Exception as e:
results[name] = f"error: {e}"
if repos:
with ThreadPoolExecutor(max_workers=workers,
thread_name_prefix="ghs-sync") as ex:
# map 会阻塞到全部完成;异常已在 worker 内捕获
list(ex.map(worker, repos))
errs, changed = [], 0
for r in repos:
res = results.get(r, "error: 未执行")
if res.startswith("error"):
errs.append(f"{r} {res}")
elif res == "changed":
changed += 1
# 'same' / 'busy' 不计失败
failed_names = [r for r in repos if results.get(r, "").startswith("error")]
with retry_lock:
for r in repos:
if r not in failed_names:
retry_queue.pop(r, None) # 成功的仓库退出重试队列
schedule_retries(failed_names)
if errs:
bump_stats(total_failures=len(errs))
if changed:
bump_stats(total_pushes=changed)
state["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
state["checks"] += 1
bump_stats(total_checks=1)
if errs:
state["last_result"] = "失败: " + "; ".join(errs)[:200]
return False, state["last_result"]
state["last_result"] = "成功"
return True, ("成功" if changed == 0 else f"成功(同步了 {changed} 个仓库)")
except Exception as e:
state["last_result"] = f"异常: {e}"
return False, state["last_result"]
finally:
state["syncing"] = False
# ---------------- 失败自动重试(独立于同步间隔,默认每 60s 一次) ----------------
retry_lock = threading.Lock()
retry_queue = {} # {仓库名: 已重试次数}
retry_next_at = 0.0 # 下次重试时间戳
def clear_retry(name):
with retry_lock:
retry_queue.pop(name, None)
def schedule_retries(failed):
"""整轮同步有失败时,把失败仓库排入自动重试(每轮重新计数)"""
global retry_next_at
mr = max_retries()
if mr <= 0 or not failed:
return
with retry_lock:
for name in failed:
retry_queue.setdefault(name, 0)
retry_next_at = time.time() + RETRY_INTERVAL
log(f"同步失败,已排程自动重试: {', '.join(failed)}"
f"(每 {RETRY_INTERVAL} 秒一次,最多 {mr} 次)")
def retry_snapshot():
"""重试状态(供前端展示)"""
with retry_lock:
if not retry_queue:
return {"pending": [], "attempts": {}, "max": max_retries(), "next_in": None}
return {"pending": sorted(retry_queue.keys()),
"attempts": dict(retry_queue),
"max": max_retries(),
"next_in": max(0, int(retry_next_at - time.time()))}
def retry_round():
"""跑一轮重试:并行重试队列中的仓库;成功移出,busy 不计数,失败计数+1,超上限放弃"""
global retry_next_at
with retry_lock:
batch = dict(retry_queue)
if not batch:
return
results = {}
workers = max(1, min(sync_workers(), len(batch)))
def worker(name):
try:
results[name] = sync_repo(name)
except Exception as e:
results[name] = f"error: {e}"
with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="ghs-retry") as ex:
list(ex.map(worker, batch))
mr = max_retries()
ok_names, failed, busy, gave_up = [], [], [], []
with retry_lock:
for name, res in results.items():
if res in ("changed", "same"):
retry_queue.pop(name, None)
ok_names.append(name)
elif res == "busy":
busy.append(name) # 被其他同步占用,不计数
else:
n = batch.get(name, 0) + 1
if n >= mr:
retry_queue.pop(name, None)
gave_up.append((name, n))
else:
retry_queue[name] = n
failed.append((name, n))
attempts = len(results) - len(busy)
if retry_queue:
retry_next_at = time.time() + RETRY_INTERVAL
if attempts:
bump_stats(total_retries=attempts)
if ok_names:
log(f"重试成功: {', '.join(ok_names)}")
for name, n in failed:
log(f"重试失败: {name}(第 {n}/{mr} 次,{RETRY_INTERVAL} 秒后再试)")
for name, n in gave_up:
log(f"重试 {n} 次仍失败,放弃自动重试: {name}(下一轮同步或手动同步会再次尝试)")
def retry_loop():
while True:
time.sleep(2)
try:
with retry_lock:
due = bool(retry_queue) and time.time() >= retry_next_at
if due:
retry_round()
except Exception as e:
log(f"重试线程异常: {e}")
def loop():
while True:
state["loop_last"] = time.time()
if state["enabled"]:
run_sync()
time.sleep(sync_interval())
def load_panel_state():
try:
state["enabled"] = bool(json.loads(PANEL_STATE.read_text(encoding="utf-8")).get("enabled", True))
except Exception:
pass
def save_panel_state():
try:
PANEL_STATE.write_text(json.dumps({"enabled": state["enabled"]}, ensure_ascii=False),
encoding="utf-8")
except Exception:
pass
# ---------------- 状态详情 ----------------
def repo_detail(name):
repo = REPO_DIR / f"{name}.git"
st = state_path(active_account(), name)
d = {"name": name, "cloned": repo.exists(), "branches": 0, "tags": 0,
"head": None, "last_commit": None, "size_mb": 0.0,
"status": "未同步", "last_push": None}
if st.exists():
d["status"] = "已同步"
d["last_push"] = datetime.fromtimestamp(st.stat().st_mtime).strftime("%m-%d %H:%M:%S")
if not repo.exists():
return d
try:
r = git(["-C", str(repo), "for-each-ref", "--format=%(refname)"], timeout=20)
if r.returncode == 0:
refs = r.stdout.split()
d["branches"] = sum(1 for x in refs if x.startswith("refs/heads/"))
d["tags"] = sum(1 for x in refs if x.startswith("refs/tags/"))
h = git(["-C", str(repo), "symbolic-ref", "--quiet", "HEAD"], timeout=10)
if h.returncode == 0 and h.stdout.strip():
d["head"] = h.stdout.strip().replace("refs/heads/", "")
if d["head"]:
c = git(["-C", str(repo), "log", "-1",
"--format=%h\t%s\t%an\t%ad", "--date=format:%m-%d %H:%M", d["head"]],
timeout=10)
if c.returncode == 0 and c.stdout.strip():
p = c.stdout.strip().split("\t", 3)
d["last_commit"] = {"hash": p[0],
"msg": p[1] if len(p) > 1 else "",
"author": p[2] if len(p) > 2 else "",
"time": p[3] if len(p) > 3 else ""}
d["size_mb"] = round(sum(f.stat().st_size for f in repo.rglob("*") if f.is_file()) / 1048576, 1)
except Exception:
pass
return d
def fmt_uptime(sec):
sec = int(sec)
d, rem = divmod(sec, 86400)
h, rem = divmod(rem, 3600)
m, s = divmod(rem, 60)
return (f"{d}天 " if d else "") + f"{h:02d}:{m:02d}:{s:02d}"
def account_public(acc):
"""账号信息(脱敏后可下发到前端)"""
if not acc:
return None
return {"id": acc["id"], "label": acc.get("label") or acc["gh_user"],
"gh_user": acc["gh_user"], "token": mask_token(acc.get("token")),
"has_token": bool(acc.get("token")),
"private": bool(acc.get("private", True)),
"method": "Token(HTTPS)" if acc.get("token") else "SSH 私钥"}
def global_status(ui_url=None):
repos = load_repos()
details = [repo_detail(r) for r in repos]
now = time.time()
next_in = None
if state["enabled"] and state["loop_last"]:
next_in = max(0, int(state["loop_last"] + sync_interval() - now))
gitea_all = list_gitea_repos()
acc = active_account()
return {
"enabled": state["enabled"],
"syncing": state["syncing"],
"uptime": fmt_uptime(now - START_TS),
"interval": sync_interval(),
"max_workers": sync_workers(),
"retry": retry_snapshot(),
"last_check": state["last_check"],
"next_in": next_in,
"checks": state["checks"],
"stats": stats,
"last_result": state["last_result"],
"repo_count": len(repos),
"synced_count": sum(1 for x in details if x["status"] == "已同步"),
"total_size_mb": round(sum(x["size_mb"] for x in details), 1),
"repos": details,
"gitea_url": ui_url or gitea_ui(),
"gitea_user": cfg["gitea_user"],
"gitea_online": bool(gitea_all),
"available": [r for r in gitea_all if r not in repos],
"account": account_public(acc),
"auto_create": bool(cfg.get("auto_create", True)),
"gh_url": f"https://github.com/{acc['gh_user']}?tab=repositories" if acc else "",
}
def tail_log(n=80):
try:
return LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()[-n:]
except Exception:
return []
# ---------------- 页面 ----------------
CSS = """
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:#f0f2f5;color:#1f2329;min-height:100vh}
.wrap{width:100%;padding:20px 20px 60px}
.top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:14px}
.top h1{font-size:20px;display:flex;align-items:center;gap:8px;min-width:0}
.pill{font-size:12px;padding:4px 12px;border-radius:999px;font-weight:600;white-space:nowrap;flex:none}
.pill.on{background:#e3f5e9;color:#0a7d34}.pill.off{background:#fee;color:#c0392b}
nav{display:flex;gap:6px;margin-bottom:16px;background:#fff;border-radius:12px;padding:6px;box-shadow:0 1px 4px rgba(0,0,0,.06);flex-wrap:wrap}
nav a{padding:8px 16px;border-radius:8px;text-decoration:none;color:#646a73;font-size:14px;font-weight:600;white-space:nowrap}
nav a.active{background:#e8f0fe;color:#1f6feb}
.card{background:#fff;border-radius:14px;padding:18px;margin-bottom:14px;box-shadow:0 1px 4px rgba(0,0,0,.06);min-width:0}
.row{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:14px}
.switchbox{display:flex;align-items:center;gap:14px;min-width:0}
.switch{position:relative;width:64px;height:34px;flex:none}
.switch input{opacity:0;width:0;height:0}
.slider{position:absolute;inset:0;background:#cbd2d9;border-radius:34px;cursor:pointer;transition:.25s}
.slider:before{content:"";position:absolute;width:26px;height:26px;left:4px;top:4px;background:#fff;border-radius:50%;transition:.25s;box-shadow:0 1px 3px rgba(0,0,0,.25)}
input:checked+.slider{background:#0a7d34}
input:checked+.slider:before{transform:translateX(30px)}
.swlabel{font-size:16px;font-weight:600}.swsub{font-size:12px;color:#646a73;margin-top:2px}
.btns{display:flex;gap:10px;flex-wrap:wrap}
button,a.btn{border:none;border-radius:10px;padding:11px 18px;font-size:14px;font-weight:600;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:6px;transition:.15s;white-space:nowrap}
button:active,a.btn:active{transform:scale(.97)}
.b-sync{background:#0a7d34;color:#fff}.b-sync:hover{background:#086b2c}
.b-gitea{background:#1f6feb;color:#fff}.b-gitea:hover{background:#1a5fd0}
.b-gh{background:#24292f;color:#fff}.b-gh:hover{background:#000}
.b-add{background:#7c3aed;color:#fff}.b-add:hover{background:#6d28d9}
.b-del{background:#fee2e2;color:#b91c1c;padding:6px 12px;font-size:12px}
.b-del:hover{background:#fecaca}
.b-mini{background:#eef2ff;color:#4338ca;padding:6px 12px;font-size:12px}
button:disabled{background:#cbd2d9;color:#fff;cursor:not-allowed}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px}
.stat{background:#f6f8fa;border-radius:10px;padding:12px 14px;min-width:0}
.stat .k{font-size:12px;color:#646a73}
.stat .v{font-size:15px;font-weight:700;margin-top:4px;word-break:break-word}
.tablewrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{padding:9px 8px;text-align:left;border-bottom:1px solid #eef0f3;vertical-align:top}
th{color:#646a73;font-weight:600;font-size:12px;white-space:nowrap}
td{word-break:break-word}
td.ok{color:#0a7d34;font-weight:600;white-space:nowrap}
td.warn{color:#b45309;font-weight:600;white-space:nowrap}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
.commit{color:#646a73;font-size:12px;margin-top:2px;line-height:1.5}
h2{font-size:15px;margin-bottom:12px;color:#1f2329}
pre{background:#0d1117;color:#c9d1d9;border-radius:10px;padding:14px;font-size:12px;line-height:1.6;max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all}
.tip{font-size:12px;color:#646a73;margin-top:10px;line-height:1.6}
.addrow{display:flex;gap:10px;flex-wrap:wrap}
.addrow input{flex:1;min-width:180px;border:1px solid #d0d5dd;border-radius:10px;padding:11px 14px;font-size:14px;outline:none}
.addrow input:focus{border-color:#7c3aed}
.avail{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px}
.chip{border:1px dashed #7c3aed;color:#7c3aed;border-radius:999px;padding:6px 14px;font-size:13px;cursor:pointer;background:#faf5ff;white-space:nowrap}
.chip:hover{background:#f3e8ff}
.msg{margin-top:10px;font-size:13px;padding:10px 14px;border-radius:8px;display:none;line-height:1.6;word-break:break-word}
.msg.err{display:block;background:#fee;color:#b91c1c}
.msg.ok{display:block;background:#e3f5e9;color:#0a7d34}
input[type=checkbox]{accent-color:#7c3aed;cursor:pointer}
.addrow input[type=password]{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.5px}
td .pill{font-size:12px}
/* ---------- 设置页 ---------- */
h2 .h2sub{font-size:12px;font-weight:400;color:#8a919e;margin-left:10px}
.banner{border-radius:10px;padding:12px 16px;font-size:13px;line-height:1.75;margin-bottom:14px;display:flex;gap:10px;align-items:flex-start}
.banner.warn{background:#fffaeb;border:1px solid #fec84b;color:#92400e}
.banner .ic{font-size:16px;line-height:1.5;flex:none}
.badge{display:inline-block;font-size:12px;font-weight:600;padding:3px 10px;border-radius:999px;white-space:nowrap;line-height:1.5}
.badge.b-ok{background:#e3f5e9;color:#0a7d34}
.badge.b-warn{background:#fef0c7;color:#b45309}
.badge.b-mute{background:#f0f2f5;color:#646a73}
.badge.b-info{background:#e8f0fe;color:#1f6feb}
.flabel{display:block;font-size:13px;font-weight:600;color:#374151;margin:16px 0 6px}
.flabel:first-child{margin-top:0}
.flabel .sub{font-weight:400;color:#8a919e;font-size:12px;margin-left:8px}
.chk{display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;padding:4px 0}
.chk input{width:18px;height:18px}
.optbox{background:#f6f8fa;border-radius:10px;padding:6px 16px;margin-top:16px}
.optrow{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:12px 0}
.optrow+.optrow{border-top:1px solid #eef0f3}
.opttext{flex:1;min-width:200px}
.opttext .swlabel{font-size:14px;font-weight:600}
.opttext .swsub{font-size:12px;color:#646a73;margin-top:2px;line-height:1.6}
.numin{flex:none;width:110px;border:1px solid #d0d5dd;border-radius:10px;padding:10px 12px;font-size:14px;outline:none;text-align:center}
.numin:focus{border-color:#7c3aed}
.numwrap{display:flex;align-items:center;gap:8px;flex:none}
.saved-tip{font-size:12px;font-weight:600;white-space:nowrap}
.saved-tip.ok{color:#0a7d34}
.saved-tip.err{color:#b91c1c}
input[readonly]{background:#f6f8fa;color:#646a73;cursor:not-allowed}
.b-icon{padding:5px 9px;min-width:auto;font-size:13px}
/* ---------- 模态弹窗 ---------- */
.modal{display:none;position:fixed;inset:0;background:rgba(15,23,42,.5);z-index:1000;align-items:center;justify-content:center;padding:24px;backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px)}
.modal.show{display:flex}
.modal-box{background:#fff;border-radius:16px;width:100%;max-width:520px;box-shadow:0 25px 70px rgba(15,23,42,.35),0 0 0 1px rgba(15,23,42,.04);max-height:90vh;overflow:auto;animation:modalIn .18s ease-out}
@keyframes modalIn{from{transform:translateY(-12px) scale(.98);opacity:.5}to{transform:translateY(0) scale(1);opacity:1}}
.modal-head{display:flex;align-items:center;gap:14px;padding:20px 24px;border-bottom:1px solid #f0f2f5}
.modal-avatar{width:42px;height:42px;border-radius:50%;background:linear-gradient(135deg,#7c3aed,#a855f7);color:#fff;display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;flex:none;box-shadow:0 4px 10px rgba(124,58,237,.25)}
.modal-head .titles{flex:1;min-width:0}
.modal-head h3{font-size:17px;font-weight:700;color:#1f2329;margin:0;line-height:1.3}
.modal-head .subtitle{font-size:12px;color:#8a919e;margin-top:2px;line-height:1.4}
.modal-close{background:none;border:none;font-size:22px;color:#8a919e;cursor:pointer;padding:6px 10px;border-radius:8px;line-height:1;flex:none;transition:all .15s}
.modal-close:hover{background:#f0f2f5;color:#1f2329}
.modal-body{padding:22px 24px;display:flex;flex-direction:column;gap:18px}
.modal-field{display:flex;flex-direction:column;gap:6px}
.modal-field label.mfl{font-size:13px;font-weight:600;color:#374151;display:flex;align-items:center;gap:6px}
.modal-field .mfhint{font-size:12px;color:#8a919e;line-height:1.5;margin-top:-2px}
.modal-body input[type=text],.modal-body input[type=password]{
border:1px solid #d0d5dd;border-radius:10px;padding:11px 14px;font-size:14px;outline:none;width:100%;box-sizing:border-box;font-family:inherit;background:#fff;color:#1f2329}
.modal-body input[type=text]:focus,.modal-body input[type=password]:focus{border-color:#7c3aed}
.modal-body input[readonly]{background:#f4f5f7;color:#374151;cursor:default;border-style:dashed}
.modal-body input[readonly]:focus{border-color:#d0d5dd}
/* 带前缀图标的输入框(保留图标,padding 让出左侧空间) */
.mf-input{position:relative}
.mf-input .mf-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);font-size:14px;color:#8a919e;pointer-events:none;line-height:1;transition:color .15s}
.mf-input input{padding-left:36px}
.mf-input input:focus+.mf-icon,.mf-input input:focus~.mf-icon{color:#7c3aed}
.mf-input.mf-token input{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.5px}
.mf-info{display:flex;align-items:center;gap:10px;padding:10px 14px;background:#f4f5f7;border:1.5px dashed #e0e3e8;border-radius:10px;color:#374151;font-size:14px}
.mf-info .mfi-label{font-size:12px;color:#8a919e;font-weight:500;flex:none}
.mf-info .mfi-value{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;word-break:break-all}
.mf-token-current{display:flex;align-items:center;gap:8px;padding:8px 12px;background:#f4f5f7;border-radius:8px;font-size:12px;color:#646a73;margin-top:-2px}
.mf-token-current code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#374151}
.mf-token-current .dot{width:6px;height:6px;border-radius:50%;background:#0a7d34;flex:none}
.mf-token-current.none .dot{background:#b45309}
.mf-token-current.none code{color:#b45309}
.mf-token-current .none{color:#b45309;font-weight:500}
.mf-row{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#f4f5f7;border-radius:10px;cursor:pointer;transition:background .15s;min-height:42px}
.mf-row:hover{background:#ebedf0}
.mf-row input[type=checkbox]{width:18px;height:18px;flex:none}
.modal-foot{padding:16px 24px;border-top:1px solid #f0f2f5;display:flex;align-items:center;gap:12px;background:#fafbfc;border-radius:0 0 16px 16px}
.modal-foot .modal-msg{margin:0}
.modal-foot .btn-cancel{background:#fff;border:1.5px solid #e5e8ec;color:#646a73;border-radius:10px;padding:10px 20px;font-size:14px;font-weight:600;cursor:pointer;transition:all .15s}
.modal-foot .btn-cancel:hover{background:#f4f5f7;border-color:#c7cdd4;color:#1f2329}
.modal-foot .btn-save{background:linear-gradient(135deg,#7c3aed,#a855f7);color:#fff;border:none;border-radius:10px;padding:10px 24px;font-size:14px;font-weight:600;cursor:pointer;transition:all .15s;box-shadow:0 4px 12px rgba(124,58,237,.25);min-width:120px}
.modal-foot .btn-save:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 6px 16px rgba(124,58,237,.35)}
.modal-foot .btn-save:disabled{opacity:.6;cursor:not-allowed}
.modal-msg{font-size:13px;flex:1;min-width:0;word-break:break-word;line-height:1.5}
.modal-msg.ok{color:#0a7d34}
.modal-msg.err{color:#b91c1c}
.modal-msg:empty{display:none}
@media (max-width:760px){
.modal-box{max-width:none;border-radius:14px}
.modal-head,.modal-body,.modal-foot{padding-left:18px;padding-right:18px}
}
table.hover tbody tr:hover{background:#f8fafc}
td.ops{white-space:nowrap}
td.ops button,td.ops .pill{margin:2px 4px 2px 0}
/* ---------- 窄屏(手机 / 飞牛桌面小窗口):表格转卡片 ---------- */
@media (max-width:760px){
.wrap{padding:14px 10px 40px}
.top h1{font-size:16px}
nav{gap:4px}
nav a{padding:8px 12px;font-size:13px;flex:1;text-align:center}
.stats{grid-template-columns:repeat(2,1fr);gap:8px}
.stat{padding:10px}
.stat .v{font-size:13px}
.card{padding:14px;border-radius:12px}
button,a.btn{padding:10px 14px;font-size:13px}
.btns{width:100%}
.btns button,.btns a.btn{flex:1;justify-content:center}
.addrow{flex-direction:column}
.addrow input{min-width:0;width:100%}
.addrow button{width:100%;justify-content:center}
table.resp{min-width:0}
table.resp thead{display:none}
table.resp tbody tr{display:block;border:1px solid #e5e8ec;border-radius:12px;margin-bottom:10px;padding:10px 12px;background:#fbfcfd}
table.resp tbody td{display:flex;justify-content:space-between;align-items:baseline;gap:6px 14px;border:none;padding:5px 0;flex-wrap:wrap}
table.resp tbody td::before{content:attr(data-label);color:#646a73;font-size:12px;font-weight:600;flex:none}
table.resp tbody td .val{flex:1;text-align:right;min-width:0;word-break:break-word}
table.resp tbody td:has(button){justify-content:flex-end}
table.resp tbody td:has(button)::before{margin-right:auto}
pre{font-size:11px;padding:10px}
}
"""
NAV = """<nav>
<a href="./" __A__>🎛 控制面板</a>
<a href="status" __B__>📊 项目状态</a>
<a href="manage" __C__>🧩 项目管理</a>
<a href="settings" __D__>⚙️ 设置</a>
</nav>"""
def page(title, body, active):
nav = (NAV.replace("__A__", 'class="active"' if active == "home" else "")
.replace("__B__", 'class="active"' if active == "status" else "")
.replace("__C__", 'class="active"' if active == "manage" else "")
.replace("__D__", 'class="active"' if active == "settings" else ""))
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} · GiteaHubSync</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔄</text></svg>">
<style>{CSS}</style>
</head>
<body>
<div class="wrap">
<div class="top"><h1>🔄 {title}</h1><span id="pill" class="pill on">运行中</span></div>
{nav}
{body}
</div>
</body>
</html>
"""
PAGE_HOME = """
<div class="card">
<div class="row">
<div class="switchbox">
<label class="switch"><input type="checkbox" id="sw" onchange="toggle()"><span class="slider"></span></label>
<div>
<div class="swlabel" id="swlabel">自动同步:开</div>
<div class="swsub" id="swsub">开启后定期检查 Gitea,有新提交自动推送到 GitHub</div>
</div>
</div>
<div class="btns">
<button class="b-sync" id="btnSync" onclick="syncNow()">⚡ 立即同步一次</button>
<a class="btn b-gitea" href="__GITEA__" target="_blank">🗂️ 打开 Gitea</a>
<a class="btn b-gh" id="btnGh" href="__GH__" target="_blank">🐙 GitHub 仓库列表</a>
</div>
</div>
</div>
<div class="card">
<div class="stats">
<div class="stat"><div class="k">自动同步</div><div class="v" id="sEnabled">—</div></div>
<div class="stat"><div class="k">GitHub 目标账号</div><div class="v" id="sAccount">—</div></div>
<div class="stat"><div class="k">同步项目</div><div class="v" id="sRepos">—</div></div>
<div class="stat"><div class="k">守护运行时长</div><div class="v" id="sUptime">—</div></div>
<div class="stat"><div class="k">最近实际同步</div><div class="v" id="sLast">—</div></div>
</div>
</div>
<div class="card">
<h2>同步日志(最近 80 行)</h2>
<pre id="log">加载中…</pre>
<div class="tip">日志文件:__LOGPATH__ · 面板每 3 秒自动刷新</div>
</div>
<script>
async function jget(u){const r=await fetch(u);return r.json()}
async function refresh(){
try{
const s=await jget('api/state');
document.getElementById('sw').checked=s.enabled;
document.getElementById('swlabel').textContent='自动同步:'+(s.enabled?'开':'关');
document.getElementById('swsub').textContent='开启后每 '+(s.interval||10)+' 秒检查一次 Gitea,'+(s.max_workers||1)+' 个项目并行同步,有新提交自动推送到 GitHub(间隔/并发可在设置页调整)';
const p=document.getElementById('pill');
p.textContent=s.enabled?'运行中':'已停止';p.className='pill '+(s.enabled?'on':'off');
document.getElementById('sEnabled').textContent=s.enabled?('开启('+(s.interval||10)+'秒/次)'):'已停止';
document.getElementById('sAccount').textContent=s.account?(s.account.gh_user+'('+s.account.method+')'):'未配置';
document.getElementById('sRepos').textContent=s.repo_count+' 个(已同步 '+s.synced_count+')';
document.getElementById('sUptime').textContent=s.uptime;
document.getElementById('sLast').textContent=s.last_real;
const l=await jget('api/log');
document.getElementById('log').textContent=l.lines.join('\\n')||'(暂无日志)';
}catch(e){document.getElementById('log').textContent='刷新失败: '+e}
}
async function toggle(){await fetch('api/toggle',{method:'POST'});refresh();}
async function syncNow(){
const b=document.getElementById('btnSync');b.disabled=true;b.textContent='⏳ 同步中…';
try{const r=await (await fetch('api/sync',{method:'POST'})).json();
if(!r.ok)alert('同步结果: '+(r.msg||JSON.stringify(r)));
}catch(e){alert('同步请求失败: '+e)}
finally{b.disabled=false;b.textContent='⚡ 立即同步一次';refresh();}
}
refresh();setInterval(refresh,3000);
</script>
"""
PAGE_STATUS = """
<div class="card">
<div class="stats">
<div class="stat"><div class="k">同步项目</div><div class="v" id="sCount">—</div></div>
<div class="stat"><div class="k">已同步</div><div class="v" id="sSynced">—</div></div>
<div class="stat"><div class="k">守护运行时长</div><div class="v" id="sUptime">—</div></div>
<div class="stat"><div class="k">距下次检查</div><div class="v" id="sNext">—</div></div>
<div class="stat"><div class="k">累计检查轮数</div><div class="v" id="sChecks">—</div></div>
<div class="stat"><div class="k">累计实际同步</div><div class="v" id="sPushes">—</div></div>
<div class="stat"><div class="k">累计失败次数</div><div class="v" id="sFails">—</div></div>
<div class="stat"><div class="k">失败重试</div><div class="v" id="sRetry">—</div></div>
<div class="stat"><div class="k">镜像总占用</div><div class="v" id="sSize">—</div></div>
</div>
</div>
<div class="card">
<div class="row">
<h2 style="margin:0">项目明细</h2>
<span class="tip" id="sTarget"></span>
</div>
<div style="overflow-x:auto">
<table class="resp">
<thead><tr><th>项目</th><th>状态</th><th>默认分支</th><th>分支/标签</th><th>最近推送同步</th><th>最新提交</th><th>大小</th><th></th></tr></thead>
<tbody id="tb"></tbody>
</table>
</div>
</div>
<script>
async function jget(u){const r=await fetch(u);return r.json()}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
async function refresh(){
try{
const s=await jget('api/status');
const p=document.getElementById('pill');
p.textContent=s.enabled?'运行中':'已停止';p.className='pill '+(s.enabled?'on':'off');
document.getElementById('sCount').textContent=s.repo_count+' 个';
document.getElementById('sSynced').textContent=s.synced_count+' / '+s.repo_count;
document.getElementById('sUptime').textContent=s.uptime;
document.getElementById('sNext').textContent=s.enabled?(s.next_in==null?'—':s.next_in+' 秒'):'已暂停';
document.getElementById('sChecks').textContent=s.stats.total_checks;
document.getElementById('sPushes').textContent=s.stats.total_pushes;
document.getElementById('sFails').textContent=s.stats.total_failures;
const rt=s.retry||{};
document.getElementById('sRetry').textContent=(rt.pending&&rt.pending.length)
?(rt.pending.length+' 个待重试 · '+rt.next_in+'s后(最多'+rt.max+'次)')
:'无待重试';
document.getElementById('sSize').textContent=s.total_size_mb+' MB';
document.getElementById('sTarget').textContent='同步目标: '+(s.account?s.account.gh_user+'('+s.account.method+')':'未配置')+' · '+(s.max_workers||1)+' 个并行 · Gitea '+(s.gitea_online?'在线 ✓':'离线 ✗');
document.getElementById('tb').innerHTML=s.repos.map(r=>{
const att=(rt.attempts||{})[r.name];
const stTxt=r.status==='已同步'?'✓ 已同步':(att!=null?('⏳ 重试中 '+att+'/'+(rt.max||5)):'⏳ 未同步');
const stCls=r.status==='已同步'?'ok':'warn';
const st=`<td class="${stCls}">${stTxt}</td>`;
const c=r.last_commit?`<div class="commit">${esc(r.last_commit.hash)} ${esc(r.last_commit.msg)} · ${esc(r.last_commit.author)} · ${esc(r.last_commit.time)}</div>`:'<div class="commit">—</div>';
return `<tr><td data-label="项目"><b>${esc(r.name)}</b></td>${st}<td data-label="默认分支">${esc(r.head||'—')}</td><td data-label="分支/标签">${r.branches} / ${r.tags}</td><td data-label="最近推送同步">${r.last_push||'—'}</td><td data-label="最新提交">${c}</td><td data-label="大小">${r.size_mb} MB</td><td data-label="操作"><button class="b-mini" onclick="syncOne('${esc(r.name)}')">同步</button></td></tr>`;
}).join('')||'<tr><td colspan="8" style="color:#646a73">还没有同步项目,去<a href="manage">项目管理</a>添加</td></tr>';
}catch(e){document.getElementById('tb').innerHTML='<tr><td colspan="8">刷新失败: '+e+'</td></tr>'}
}
async function syncOne(n){
const r=await (await fetch('api/repos/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n})})).json();
if(!r.ok)alert(n+': '+(r.msg||JSON.stringify(r)));
refresh();
}
refresh();setInterval(refresh,5000);
</script>
"""
PAGE_MANAGE = """
<div class="card">
<h2>快捷添加项目</h2>
<div class="addrow">
<input id="repoName" placeholder="输入 Gitea 上的仓库名,如 ESP32_COMM" onkeydown="if(event.key==='Enter')addRepo()">
<button class="b-add" id="btnAdd" onclick="addRepo()">➕ 添加并同步</button>
</div>
<div class="tip">下面可直接点击你 Gitea 上还未同步的仓库(GitHub 上不存在同名仓库时会按设置自动创建):</div>
<div class="avail" id="avail"></div>
<div class="msg" id="msg"></div>
</div>
<div class="card">
<h2>当前同步项目(<span id="cnt">0</span>)</h2>
<table class="resp">
<thead><tr><th>项目</th><th>状态</th><th>最近推送同步</th><th>操作</th></tr></thead>
<tbody id="tb"></tbody>
</table>
<div class="tip">移除只是停止同步,GitHub 上的仓库和本地镜像数据都会保留,以后可以随时加回来。</div>
</div>
<script>
async function jget(u){const r=await fetch(u);return r.json()}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function showMsg(t,ok){const m=document.getElementById('msg');m.textContent=t;m.className='msg '+(ok?'ok':'err');}
async function refresh(){
try{
const s=await jget('api/status');
document.getElementById('cnt').textContent=s.repo_count;
document.getElementById('avail').innerHTML=(s.available||[]).map(n=>
`<span class="chip" onclick="addRepo('${esc(n)}')">+ ${esc(n)}</span>`).join('')||'<span class="tip">Gitea 上的仓库都已加入同步</span>';
document.getElementById('tb').innerHTML=s.repos.map(r=>
`<tr><td data-label="项目"><b>${esc(r.name)}</b></td><td data-label="状态" class="${r.status==='已同步'?'ok':'warn'}">${r.status}</td><td data-label="最近推送同步">${r.last_push||'—'}</td><td data-label="操作"><button class="b-del" onclick="delRepo('${esc(r.name)}')">移除</button></td></tr>`).join('')
||'<tr><td colspan="4" style="color:#646a73">暂无项目</td></tr>';
}catch(e){showMsg('刷新失败: '+e,false)}
}
async function addRepo(n){
const name=(n||document.getElementById('repoName').value||'').trim();
if(!name){showMsg('请输入仓库名',false);return;}
const b=document.getElementById('btnAdd');b.disabled=true;b.textContent='⏳ 处理中…';
try{
const r=await (await fetch('api/repos/add',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})})).json();
showMsg(r.msg||JSON.stringify(r),!!r.ok);
if(r.ok)document.getElementById('repoName').value='';
}catch(e){showMsg('请求失败: '+e,false)}
finally{b.disabled=false;b.textContent='➕ 添加并同步';refresh();}
}
async function delRepo(n){
if(!confirm('确定把 '+n+' 从同步列表移除?\\n(GitHub 仓库和本地数据保留)'))return;
const r=await (await fetch('api/repos/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n})})).json();
showMsg(r.msg||JSON.stringify(r),!!r.ok);
refresh();
}
refresh();setInterval(refresh,5000);
</script>
"""
PAGE_SETTINGS = """
<div class="card">
<h2>GitHub 账号<span class="h2sub">推送目标 · 可添加多个,随时切换</span></h2>
<div id="accBanner"></div>
<div class="tablewrap">
<table class="resp hover">
<colgroup><col style="width:15%"><col style="width:21%"><col style="width:15%"><col style="width:14%"><col style="width:12%"><col style="width:23%"></colgroup>
<thead><tr><th>备注</th><th>GitHub 用户名</th><th>Token</th><th>推送方式</th><th>自动建库</th><th>操作</th></tr></thead>
<tbody id="tbAcc"></tbody>
</table>
</div>
<div class="tip">切换账号后,所有项目改推到该账号名下;每个账号独立记录同步进度,切回原账号不会重复推送。</div>
</div>
<div class="card">
<h2>添加 / 更新 GitHub 账号<span class="h2sub">Token 验证通过后自动识别用户名</span></h2>
<p style="color:#646a73;margin-bottom:14px">添加多个账号后可随时切换推送目标,每个账号独立记录同步进度。已存在的账号可点表格行右边的 ✏️ 编辑。</p>
<button class="b-add" onclick="openAddModal()" style="font-size:15px;padding:12px 24px">➕ 添加 GitHub 账号</button>
<div class="tip" style="margin-top:16px">
Token 获取:GitHub → Settings → Developer settings → Personal access tokens →
<b>Tokens (classic)</b> → Generate new token,勾选 <b>repo</b> 权限(即可自动创建仓库并推送)。<br>
链接:<a href="https://github.com/settings/tokens" target="_blank">github.com/settings/tokens</a> ·
Token 只保存在 NAS 本地(accounts.json,权限 600),不会上传到任何地方。
</div>
</div>
<div class="card">
<h2>Gitea 与同步设置</h2>
<label class="flabel">Gitea 地址<span class="sub">同步引擎访问的地址,默认 http://127.0.0.1:3000</span></label>
<div class="addrow">
<input id="giteaHost" placeholder="http://127.0.0.1:3000">
<button class="b-mini" id="btnGitea" onclick="saveGitea(false)">保存</button>
</div>
<label class="flabel">Gitea 用户名<span class="sub">面板按此用户名列出你可同步的仓库</span></label>
<div class="addrow">
<input id="giteaUser" placeholder="cbb">
<button class="b-mini" onclick="saveGitea(false)">保存</button>
</div>
<label class="flabel">Gitea 密码<span class="sub">仅仓库为私有时需要;留空保存可清除</span></label>
<div class="addrow">
<input id="giteaPass" type="password" placeholder="未设置">
<button class="b-mini" onclick="saveGitea(true)">保存</button>
</div>
<div class="tip" id="giteaUrlTip"></div>
<div class="optbox">
<div class="optrow">
<label class="switch"><input type="checkbox" id="swAuto" onchange="saveAuto()"><span class="slider"></span></label>
<div class="opttext">
<div class="swlabel">GitHub 上不存在同名仓库时自动创建</div>
<div class="swsub">需要当前账号已配置 Token;关闭后添加不存在的仓库会提示手动创建</div>
</div>
</div>
<div class="optrow">
<div class="numwrap">
<input id="syncInterval" type="number" min="5" max="86400" step="1" class="numin" onchange="saveInterval()">
<span class="saved-tip" id="intervalSaved"></span>
</div>
<div class="opttext">
<div class="swlabel">同步间隔(秒)</div>
<div class="swsub" id="intervalTip"></div>
</div>
</div>
<div class="optrow">
<div class="numwrap">
<input id="syncWorkers" type="number" min="1" max="8" step="1" class="numin" onchange="saveWorkers()">
<span class="saved-tip" id="workersSaved"></span>
</div>
<div class="opttext">
<div class="swlabel">同时同步项目数</div>
<div class="swsub" id="workersTip"></div>
</div>
</div>
<div class="optrow">
<div class="numwrap">
<input id="syncRetries" type="number" min="0" max="20" step="1" class="numin" onchange="saveRetries()">
<span class="saved-tip" id="retriesSaved"></span>
</div>
<div class="opttext">
<div class="swlabel">失败重试次数</div>
<div class="swsub" id="retriesTip"></div>
</div>
</div>
</div>
<div class="msg" id="msgCfg"></div>
</div>
<script>
async function jget(u){const r=await fetch(u);return r.json()}
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML}
function showMsg(id,t,ok){const m=document.getElementById(id);m.textContent=t;m.className='msg '+(ok?'ok':'err');}
// ---- 数值设置:改动即生效(失焦/回车自动保存,无需点按钮) ----
let lastVals={};
function valChanged(inputId,key){const v=document.getElementById(inputId).value;if(lastVals[key]===v)return false;lastVals[key]=v;return true;}
function flashSaved(id,ok,msg){
const el=document.getElementById(id);
el.textContent=ok?'✓ 已生效':('✗ '+(msg||'保存失败'));
el.className='saved-tip '+(ok?'ok':'err');
if(ok)setTimeout(()=>{if(el.textContent==='✓ 已生效')el.textContent='';},2000);
}
async function saveSetting(key,inputId,tipId,field){
if(!valChanged(inputId,key))return; // 没改动不重复保存
const v=parseInt(document.getElementById(inputId).value,10);
if(isNaN(v)){flashSaved(tipId,false,'请输入有效数字');return;}
let r;
try{
r=await (await fetch('api/settings/save',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({[field]:v})})).json();
}catch(e){flashSaved(tipId,false,'网络错误');return;}
await refresh(); // 刷新后非法值会自动回滚成服务器值
flashSaved(tipId,!!r.ok,r.msg);
}
async function saveInterval(){return saveSetting('interval','syncInterval','intervalSaved','interval');}
async function saveWorkers(){return saveSetting('workers','syncWorkers','workersSaved','max_workers');}
async function saveRetries(){return saveSetting('retries','syncRetries','retriesSaved','max_retries');}
async function refresh(){
try{
const s=await jget('api/settings');
document.getElementById('giteaHost').value=s.gitea_host||'http://127.0.0.1:3000';
document.getElementById('giteaUser').value=s.gitea_user||'';
document.getElementById('giteaPass').placeholder=s.gitea_pass_set
?'Gitea 密码(已设置;留空保存可清除)':'Gitea 密码(私有仓库必填)';
document.getElementById('giteaUrlTip').textContent='「打开 Gitea」按钮地址:'+(s.gitea_url||'—')+'(随访问地址自动适配);密码只保存在 NAS 本地,仅用于读取私有仓库。';
document.getElementById('swAuto').checked=!!s.auto_create;
const iv=document.getElementById('syncInterval');
iv.value=s.interval||10;iv.min=s.min_interval||5;iv.max=s.max_interval||86400;
document.getElementById('intervalTip').textContent='每隔 '+fmtInterval(s.interval||10)+' 检查一次 Gitea 是否有新提交('+(s.min_interval||5)+'~'+(s.max_interval||86400)+' 秒),改动即生效';
const wk=document.getElementById('syncWorkers');
wk.value=s.max_workers||3;wk.min=s.min_workers||1;wk.max=s.max_workers_limit||8;
document.getElementById('workersTip').textContent='每轮最多 '+(s.max_workers||3)+' 个项目同时克隆/推送(1~'+(s.max_workers_limit||8)+'),项目多时能显著加快;网络或 NAS 负载高就调小';
const rt=document.getElementById('syncRetries');
rt.value=(s.max_retries==null?5:s.max_retries);rt.min=0;rt.max=20;
document.getElementById('retriesTip').textContent='同步失败后每隔 '+(s.retry_interval||60)+' 秒自动重试失败的仓库,最多 '+(s.max_retries==null?5:s.max_retries)+' 次(0 = 不重试);与同步间隔无关,专治网络抖动';
lastVals={interval:iv.value,workers:wk.value,retries:rt.value};
const cur=(s.accounts||[]).find(a=>a.id===s.active);
// 当前账号无 Token 时,顶部提示为什么不能自动建库、怎么解锁
document.getElementById('accBanner').innerHTML=(cur&&!cur.has_token)
?`<div class="banner warn"><span class="ic">⚠️</span><div><b>当前账号「${esc(cur.label)}」通过 SSH 私钥推送,暂时无法自动创建仓库。</b>
<br>GitHub 的创建仓库接口只认 Token 认证,SSH 私钥只能推送<b>已存在</b>的仓库——这是 GitHub 的限制,不是软件没做。
<br>给这个账号配上 Token 即可解锁(推送也会改走 HTTPS,更稳更快):
<button class="b-mini" style="margin-left:6px" onclick="upgradeAcc('${esc(cur.id)}')">去添加 Token</button></div></div>`
:'';
allAccounts=s.accounts||[];
document.getElementById('tbAcc').innerHTML=(s.accounts||[]).map(a=>{
const isCur=a.id===s.active;
const ops=isCur
? '<span class="pill on">当前使用</span>'
: `<button class="b-mini" onclick="switchAcc('${esc(a.id)}')">切换到此账号</button>`;
const verify=a.has_token?`<button class="b-mini" onclick="verifyAcc('${esc(a.id)}')">验证</button>`:'';
const edit=`<button class="b-mini b-icon" title="编辑" onclick="openEditModal('${esc(a.id)}')">✏️</button>`;
const del=(a.has_token||(s.accounts||[]).length>1)
? `<button class="b-del" onclick="delAcc('${esc(a.id)}')">删除</button>`:'';
const method=a.has_token
?'<span class="badge b-ok">Token · HTTPS</span>'
:'<span class="badge b-mute">SSH 私钥</span>';
const create=a.has_token
?`<span class="badge ${a.private?'b-ok':'b-info'}">${a.private?'私有':'公开'}</span>`
:'<span class="badge b-warn" title="GitHub 创建仓库的接口只能用 Token 认证;添加 Token 后即可自动创建">需 Token</span>';
return `<tr><td data-label="备注"><b>${esc(a.label)}</b></td><td data-label="用户名" class="mono">${esc(a.gh_user)}</td>`
+`<td data-label="Token" class="mono">${esc(a.token||'(未配置)')}</td>`
+`<td data-label="推送方式">${method}</td>`
+`<td data-label="自动建库">${create}</td>`
+`<td data-label="操作" class="ops">${ops} ${edit} ${verify} ${del}</td></tr>`;
}).join('')||'<tr><td colspan="6" style="color:#646a73">暂无账号</td></tr>';
}catch(e){showMsg('msgAcc','刷新失败: '+e,false)}
}
// ---- GitHub 账号:弹窗式添加/编辑 ----
let allAccounts=[];
function showModalMsg(id,t,ok){
const m=document.getElementById(id);
m.textContent=t||''; m.className='modal-msg'+(ok===true?' ok':(ok===false?' err':''));
}
function closeModal(id){ document.getElementById(id).classList.remove('show'); }
function openAddModal(){
document.getElementById('addLabel').value='';
document.getElementById('addUser').value='';
document.getElementById('addToken').value='';
document.getElementById('addPrivate').checked=true;
showModalMsg('addMsg','',null);
document.getElementById('modalAdd').classList.add('show');
setTimeout(()=>document.getElementById('addLabel').focus(),50);
}
function openEditModal(id, focusToken){
const acc=allAccounts.find(a=>a.id===id);
if(!acc)return;
document.getElementById('modalEdit').dataset.id=id;
document.getElementById('editLabel').value=acc.label||'';
document.getElementById('editUser').textContent=acc.gh_user||'';
document.getElementById('editAvatar').textContent=(acc.gh_user||'?').charAt(0).toUpperCase();
// 当前 Token 显示块
const tcur=document.getElementById('editTokenCurrent');
const ttxt=document.getElementById('editTokenText');
if(acc.token){ ttxt.textContent=acc.token; tcur.classList.remove('none'); }
else { ttxt.textContent='(未配置 Token)'; tcur.classList.add('none'); }
document.getElementById('editToken').value='';
document.getElementById('editPrivate').checked=!!acc.private;
showModalMsg('editMsg','',null);
document.getElementById('modalEdit').classList.add('show');
setTimeout(()=>{
const el=focusToken?document.getElementById('editToken'):document.getElementById('editLabel');
el.focus();
},50);
}
function upgradeAcc(id){
// 顶部横幅「去添加 Token」:直接打开当前账号的编辑弹窗,聚焦 Token
openEditModal(id, true);
}
async function saveAdd(){
const body={
label: document.getElementById('addLabel').value.trim(),
gh_user: document.getElementById('addUser').value.trim(),
token: document.getElementById('addToken').value.trim(),
private: document.getElementById('addPrivate').checked,
};
if(!body.token){ showModalMsg('addMsg','请填写 Personal Access Token',false); return; }
const b=document.getElementById('btnAddSave'); b.disabled=true; b.textContent='⏳ 验证中…';
try{
const r=await (await fetch('api/accounts/add',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
showModalMsg('addMsg', r.msg||'', !!r.ok);
if(r.ok){ setTimeout(()=>{ closeModal('modalAdd'); refresh(); }, 700); }
}catch(e){ showModalMsg('addMsg','请求失败: '+e, false); }
finally{ b.disabled=false; b.textContent='✓ 验证并添加'; }
}
async function saveEdit(){
const id=document.getElementById('modalEdit').dataset.id;
if(!id){ showModalMsg('editMsg','内部错误:未选中账号',false); return; }
const body={
id,
label: document.getElementById('editLabel').value.trim(),
token: document.getElementById('editToken').value.trim(), // 空 = 不改
private: document.getElementById('editPrivate').checked,
};
const b=document.getElementById('btnEditSave'); b.disabled=true; b.textContent='⏳ 保存中…';
try{
const r=await (await fetch('api/accounts/edit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
showModalMsg('editMsg', r.msg||'', !!r.ok);
if(r.ok){ setTimeout(()=>{ closeModal('modalEdit'); refresh(); }, 700); }
}catch(e){ showModalMsg('editMsg','请求失败: '+e, false); }
finally{ b.disabled=false; b.textContent='保存修改'; }
}
async function switchAcc(id){
const r=await (await fetch('api/accounts/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})})).json();
showMsg('msgAcc',r.msg||JSON.stringify(r),!!r.ok);refresh();
}
async function verifyAcc(id){
const r=await (await fetch('api/accounts/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})})).json();
showMsg('msgAcc',r.msg||JSON.stringify(r),!!r.ok);refresh();
}
async function delAcc(id){
if(!confirm('确定删除该 GitHub 账号?\\n(只从本应用移除,GitHub 上的仓库不受影响)'))return;
const r=await (await fetch('api/accounts/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})})).json();
showMsg('msgAcc',r.msg||JSON.stringify(r),!!r.ok);refresh();
}
async function saveGitea(withPass){
// withPass=false 时不发送密码字段,避免改地址/用户名时误清密码
const body={gitea_host:document.getElementById('giteaHost').value.trim(),
gitea_user:document.getElementById('giteaUser').value.trim()};
if(withPass)body.gitea_pass=document.getElementById('giteaPass').value;
const r=await (await fetch('api/settings/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
showMsg('msgCfg',r.msg||JSON.stringify(r),!!r.ok);
if(withPass)document.getElementById('giteaPass').value='';
refresh();
}
async function saveAuto(){
const auto_create=document.getElementById('swAuto').checked;
const r=await (await fetch('api/settings/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({auto_create})})).json();
showMsg('msgCfg',r.msg||JSON.stringify(r),!!r.ok);refresh();
}
function fmtInterval(s){
s=parseInt(s,10)||0;
if(s>=3600)return (s/3600)+' 小时';
if(s>=60)return Math.round(s/6)/10+' 分钟';
return s+' 秒';
}
refresh();
</script>
<div class="modal" id="modalAdd">
<div class="modal-box">
<div class="modal-head">
<div class="modal-avatar">+</div>
<div class="titles">
<h3>添加 GitHub 账号</h3>
<div class="subtitle">用 Personal Access Token 验证后即可推送</div>
</div>
<button class="modal-close" onclick="closeModal('modalAdd')" aria-label="关闭">×</button>
</div>
<div class="modal-body">
<div class="modal-field">
<label class="mfl" for="addLabel">备注名</label>
<div class="mf-input"><input id="addLabel" placeholder="如:我的主账号" autocomplete="off"><span class="mf-icon">✏️</span></div>
</div>
<div class="modal-field">
<label class="mfl" for="addUser">GitHub 用户名</label>
<input id="addUser" placeholder="如 cbb2625274797" autocomplete="off">
<div class="mfhint">留空则按 Token 自动识别</div>
</div>
<div class="modal-field">
<label class="mfl" for="addToken">Personal Access Token</label>
<div class="mf-input mf-token"><input id="addToken" type="password" placeholder="ghp_ 开头" autocomplete="off"><span class="mf-icon">🔑</span></div>
<div class="mfhint">需 <code>repo</code> 权限(即可自动创建仓库并推送)</div>
</div>
<div class="mf-row">
<label class="chk" style="flex:1;cursor:pointer"><input type="checkbox" id="addPrivate" checked> 自动创建的仓库设为 <b>私有</b>(推荐)</label>
</div>
</div>
<div class="modal-foot">
<div class="modal-msg" id="addMsg"></div>
<button class="btn-cancel" onclick="closeModal('modalAdd')">取消</button>
<button class="btn-save" id="btnAddSave" onclick="saveAdd()">✓ 验证并添加</button>
</div>
</div>
</div>
<div class="modal" id="modalEdit">
<div class="modal-box">
<div class="modal-head">
<div class="modal-avatar" id="editAvatar">E</div>
<div class="titles">
<h3>编辑 GitHub 账号</h3>
<div class="subtitle" id="editSubtitle">修改备注、私有仓库设置,或更换 Token</div>
</div>
<button class="modal-close" onclick="closeModal('modalEdit')" aria-label="关闭">×</button>
</div>
<div class="modal-body">
<div class="modal-field">
<label class="mfl" for="editLabel">备注名</label>
<div class="mf-input"><input id="editLabel" autocomplete="off"><span class="mf-icon">✏️</span></div>
</div>
<div class="modal-field">
<label class="mfl">GitHub 用户名</label>
<div class="mf-info">
<span class="mfi-label">账号标识 · 不可修改</span>
<span class="mfi-value" id="editUser">—</span>
</div>
</div>
<div class="modal-field">
<label class="mfl" for="editToken">Personal Access Token</label>
<div class="mf-token-current" id="editTokenCurrent">
<span class="dot"></span>
<span>当前 Token:<code id="editTokenText">—</code></span>
</div>
<div class="mf-input mf-token"><input id="editToken" type="password" placeholder="粘贴新 Token 以替换(留空则不修改)" autocomplete="off"><span class="mf-icon">🔑</span></div>
<div class="mfhint">留空则保留当前 Token</div>
</div>
<div class="mf-row">
<label class="chk" style="flex:1;cursor:pointer"><input type="checkbox" id="editPrivate"> 自动创建的仓库设为 <b>私有</b>(推荐)</label>
</div>
</div>
<div class="modal-foot">
<div class="modal-msg" id="editMsg"></div>
<button class="btn-cancel" onclick="closeModal('modalEdit')">取消</button>
<button class="btn-save" id="btnEditSave" onclick="saveEdit()">保存修改</button>
</div>
</div>
</div>
<script>
// 弹窗:点遮罩关闭 + ESC 关闭。
// 关键:必须 mousedown 和 mouseup 都在遮罩上才算"点遮罩",避免在弹窗内
// 拖动选中文本时松手到遮罩上被误关。
document.querySelectorAll('.modal').forEach(m=>{
let downOnBackdrop=false;
m.addEventListener('mousedown',e=>{ downOnBackdrop=(e.target===m); });
m.addEventListener('click',e=>{ if(downOnBackdrop && e.target===m) m.classList.remove('show'); });
});
document.addEventListener('keydown',e=>{
if(e.key==='Escape') document.querySelectorAll('.modal.show').forEach(m=>m.classList.remove('show'));
});
</script>
"""
# ---------------- HTTP 服务 ----------------
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _json(self, obj, code=200):
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _html(self, html):
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)
def _body(self):
try:
n = int(self.headers.get("Content-Length", 0) or 0)
return json.loads(self.rfile.read(n) or b"{}")
except Exception:
return {}
def do_GET(self):
path = self.path.split("?")[0].rstrip("/") or "/"
if path == "/":
acc = active_account()
body = (PAGE_HOME.replace("__GITEA__", gitea_ui(
self.headers.get("Host"),
self.headers.get("Referer"),
self.headers.get("X-Forwarded-Host")))
.replace("__GH__", f"https://github.com/{acc['gh_user']}?tab=repositories" if acc else "")
.replace("__LOGPATH__", str(LOG_FILE)))
self._html(page("控制面板", body, "home"))
elif path == "/status":
self._html(page("项目状态", PAGE_STATUS, "status"))
elif path == "/manage":
self._html(page("项目管理", PAGE_MANAGE, "manage"))
elif path == "/settings":
self._html(page("设置", PAGE_SETTINGS, "settings"))
elif path == "/api/state":
repos = load_repos()
acc = active_account()
self._json({"enabled": state["enabled"], "syncing": state["syncing"],
"last_check": state["last_check"], "checks": state["checks"],
"last_result": state["last_result"],
"interval": sync_interval(),
"max_workers": sync_workers(),
"repo_count": len(repos),
"synced_count": sum(1 for r in repos if state_path(acc, r).exists()),
"uptime": fmt_uptime(time.time() - START_TS),
"account": account_public(acc),
"last_real": last_real_sync()})
elif path == "/api/status":
self._json(global_status(gitea_ui(
self.headers.get("Host"),
self.headers.get("Referer"),
self.headers.get("X-Forwarded-Host"))))
elif path == "/api/settings":
self._json({"gitea_user": cfg["gitea_user"],
"gitea_pass": mask_token(cfg.get("gitea_pass")),
"gitea_pass_set": bool(cfg.get("gitea_pass")),
"auto_create": bool(cfg.get("auto_create", True)),
"interval": sync_interval(),
"min_interval": MIN_INTERVAL,
"max_interval": MAX_INTERVAL,
"max_workers": sync_workers(),
"min_workers": MIN_WORKERS,
"max_workers_limit": MAX_WORKERS,
"max_retries": max_retries(),
"retry_interval": RETRY_INTERVAL,
"gitea_host": gitea_host(),
"gitea_url": gitea_ui(
self.headers.get("Host"),
self.headers.get("Referer"),
self.headers.get("X-Forwarded-Host")),
"active": active_id,
"accounts": [account_public(a) for a in accounts]})
elif path == "/api/log":
self._json({"lines": tail_log(80)})
else:
self._json({"error": "not found"}, 404)
def do_POST(self):
global active_id
path = self.path.split("?")[0].rstrip("/") or "/"
data = self._body()
if path == "/api/toggle":
state["enabled"] = not state["enabled"]
save_panel_state()
self._json({"ok": True, "enabled": state["enabled"]})
elif path == "/api/sync":
ok, msg = run_sync()
self._json({"ok": ok, "msg": msg})
elif path == "/api/settings/save":
new_user = new_pass = new_host = None
if "gitea_user" in data:
u = (data.get("gitea_user") or "").strip()
if not valid_name(u):
self._json({"ok": False, "msg": "Gitea 用户名不合法(仅限字母/数字/._-)"})
return
new_user = u
if "gitea_pass" in data:
new_pass = str(data.get("gitea_pass") or "") # 空串 = 清除密码
if "gitea_host" in data:
h = norm_gitea_host(data.get("gitea_host"))
if not h:
self._json({"ok": False, "msg": "Gitea 地址不合法,应类似 http://127.0.0.1:3000"})
return
new_host = h
if "interval" in data:
if not valid_interval(data.get("interval")):
self._json({"ok": False, "msg": f"同步间隔须为 {MIN_INTERVAL} ~ {MAX_INTERVAL} 之间的整数(秒)"})
return
if "max_workers" in data:
if not valid_workers(data.get("max_workers")):
self._json({"ok": False, "msg": f"同时同步项目数须为 {MIN_WORKERS} ~ {MAX_WORKERS} 之间的整数"})
return
if "max_retries" in data:
if not valid_retries(data.get("max_retries")):
self._json({"ok": False, "msg": f"失败重试次数须为 {MIN_MAX_RETRIES} ~ {MAX_MAX_RETRIES} 之间的整数"})
return
# 预校验:新地址先探连通性;涉及账号的改动再用新凭据调一次 /user 验证
trial = dict(cfg)
if new_user is not None:
trial["gitea_user"] = new_user
if new_pass is not None:
trial["gitea_pass"] = new_pass
if new_host is not None:
trial["gitea_host"] = new_host
ok, msg = gitea_probe(new_host)
if not ok:
self._json({"ok": False, "msg": msg})
return
if (new_user is not None or new_pass is not None) and trial["gitea_pass"]:
try:
r = subprocess.run(
["curl", "-sS", "-m", "10", "-w", "\n%{http_code}",
"-u", f"{trial['gitea_user']}:{trial['gitea_pass']}",
f"{trial['gitea_host']}/api/v1/user"],
capture_output=True, text=True, timeout=15)
out, _, code = r.stdout.rpartition("\n")
st = int(code) if code.strip().isdigit() else 0
if not (200 <= st < 300):
self._json({"ok": False, "msg": f"Gitea 账号验证失败(HTTP {st}):请检查用户名/密码"})
return
login = (json.loads(out or "{}") or {}).get("login")
if login and login != trial["gitea_user"]:
self._json({"ok": False, "msg": f"该密码对应的 Gitea 用户是 {login},与填写的 {trial['gitea_user']} 不一致"})
return
except Exception as e:
self._json({"ok": False, "msg": f"Gitea 账号验证出错: {e}"})
return
host_changed = new_host is not None and new_host != gitea_host()
if new_user is not None:
cfg["gitea_user"] = new_user
if new_pass is not None:
cfg["gitea_pass"] = new_pass
log("Gitea 密码已" + ("更新" if new_pass else "清除"))
if new_host is not None:
cfg["gitea_host"] = new_host
if "auto_create" in data:
cfg["auto_create"] = bool(data.get("auto_create"))
if "interval" in data:
old_iv = sync_interval()
cfg["interval"] = int(data.get("interval"))
if cfg["interval"] != old_iv:
log(f"同步间隔已调整: {old_iv}s -> {cfg['interval']}s")
if "max_workers" in data:
old_w = sync_workers()
cfg["max_workers"] = int(data.get("max_workers"))
if cfg["max_workers"] != old_w:
log(f"同时同步项目数已调整: {old_w} -> {cfg['max_workers']}")
if "max_retries" in data:
old_r = max_retries()
cfg["max_retries"] = int(data.get("max_retries"))
if cfg["max_retries"] != old_r:
log(f"失败重试次数已调整: {old_r} -> {cfg['max_retries']}")
save_settings()
notes = []
if new_host is not None:
ok, msg = gitea_probe()
notes.append(f"Gitea 地址已设为 {new_host}({msg})")
if host_changed:
n = repoint_mirror_remotes()
log(f"Gitea 地址变更: {n} 个镜像仓库的 origin 已更新")
notes.append(f"{n} 个已有镜像的拉取地址已更新")
if cfg.get("gitea_pass"):
notes.append("Gitea 密码验证通过 ✓,可同步私有仓库")
else:
ok, d = gitea_api(f"/users/{cfg['gitea_user']}")
if ok and isinstance(d, dict) and d.get("login"):
notes.append("未配置 Gitea 密码,只能同步公开仓库;仓库为私有时请填写密码")
else:
notes.append(f"注意:在新地址上找不到用户 {cfg['gitea_user']},请检查")
self._json({"ok": True, "msg": "已保存(" + ";".join(notes) + ")"})
elif path == "/api/accounts/add":
# 严格新增:Token 对应的 gh_user 若已存在则拒绝,引导用户用编辑功能
token = (data.get("token") or "").strip()
label = (data.get("label") or "").strip()
gh_user = (data.get("gh_user") or "").strip()
private = bool(data.get("private", True))
if not token:
self._json({"ok": False, "msg": "Token 不能为空"})
return
ok, login, err = gh_verify_token(token)
if not ok:
self._json({"ok": False, "msg": f"Token 验证失败: {err}"})
return
if gh_user and gh_user != login:
self._json({"ok": False, "msg": f"Token 对应的账号是 {login},与填写的 {gh_user} 不一致"})
return
if next((a for a in accounts if a["gh_user"] == login), None):
self._json({"ok": False,
"msg": f"GitHub 账号 {login} 已存在,请用编辑功能(每行右边的✏️)更新 Token/备注"})
return
acc = {"id": uuid.uuid4().hex[:8], "label": label or login,
"gh_user": login, "token": token, "private": private,
"created": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
accounts.append(acc)
# 旧版默认账号(无 Token)存在时,添加第一个 Token 账号后自动切过去
cur = active_account()
if (not cur) or (not cur.get("token")):
active_id = acc["id"]
save_accounts()
log(f"新增 GitHub 账号 {login}({'私有' if private else '公开'}仓库)")
self._json({"ok": True, "msg": f"账号 {login} 验证通过,已添加"
+ ("并切换为当前账号" if active_id == acc["id"] else "")})
elif path == "/api/accounts/edit":
# 按 id 编辑已有账号:token 留空则保留,可改 label / private
aid = (data.get("id") or "").strip()
acc = find_account(aid)
if not acc:
self._json({"ok": False, "msg": "账号不存在"})
return
new_token = (data.get("token") or "").strip()
if new_token:
ok, login, err = gh_verify_token(new_token)
if not ok:
self._json({"ok": False, "msg": f"Token 验证失败: {err}"})
return
if login != acc["gh_user"]:
self._json({"ok": False,
"msg": f"Token 对应的账号是 {login},与当前账号 {acc['gh_user']} 不一致"})
return
acc["token"] = new_token
log(f"GitHub 账号 {acc['gh_user']} 的 Token 已通过编辑更新")
if "private" in data:
acc["private"] = bool(data.get("private"))
if "label" in data:
label = (data.get("label") or "").strip()
if label:
acc["label"] = label
save_accounts()
self._json({"ok": True,
"msg": f"账号 {acc['gh_user']} 已保存" + ("(Token 已更新)" if new_token else "")})
elif path == "/api/accounts/switch":
aid = (data.get("id") or "").strip()
if not find_account(aid):
self._json({"ok": False, "msg": "账号不存在"})
return
active_id = aid
save_accounts()
acc = find_account(aid)
log(f"已切换 GitHub 账号: {acc['label']}({acc['gh_user']})")
self._json({"ok": True, "msg": f"已切换到 {acc['label']}({acc['gh_user']})"})
elif path == "/api/accounts/remove":
aid = (data.get("id") or "").strip()
acc = find_account(aid)
if not acc:
self._json({"ok": False, "msg": "账号不存在"})
return
if len(accounts) <= 1:
self._json({"ok": False, "msg": "至少保留一个账号"})
return
accounts.remove(acc)
if active_id == aid:
active_id = accounts[0]["id"]
save_accounts()
log(f"已移除 GitHub 账号 {acc['gh_user']}")
self._json({"ok": True, "msg": f"账号 {acc['gh_user']} 已移除(GitHub 仓库不受影响)"})
elif path == "/api/accounts/verify":
aid = (data.get("id") or "").strip()
acc = find_account(aid)
if not acc:
self._json({"ok": False, "msg": "账号不存在"})
return
if not acc.get("token"):
self._json({"ok": True, "msg": f"{acc['gh_user']}:SSH 私钥推送(无需 Token)"})
return
ok, login, err = gh_verify_token(acc["token"])
if ok:
self._json({"ok": True, "msg": f"Token 有效 ✓({login})"})
else:
self._json({"ok": False, "msg": f"Token 校验失败: {err}"})
elif path == "/api/repos/sync":
name = data.get("name", "")
if not valid_name(name):
self._json({"ok": False, "msg": "非法仓库名"})
return
# 不再被整轮同步全局拦截:只禁止同一仓库重复同步(sync_repo 内部按仓库防重入),
# 与正在进行的整轮同步互不阻塞,实现多项目同时同步
res = sync_repo(name)
state["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if res == "busy":
self._json({"ok": False, "msg": f"{name} 正在同步中,请稍候"})
elif res.startswith("error"):
bump_stats(total_failures=1)
self._json({"ok": False, "msg": f"{name} {res}"})
elif res == "changed":
bump_stats(total_pushes=1)
clear_retry(name) # 手动同步成功,退出自动重试队列
self._json({"ok": True, "msg": f"{name} 已同步到 GitHub"})
else:
clear_retry(name)
self._json({"ok": True, "msg": f"{name} 已是最新,无需同步"})
elif path == "/api/repos/add":
name = (data.get("name") or "").strip()
if not valid_name(name):
self._json({"ok": False, "msg": "仓库名不合法(仅限字母/数字/._-)"})
return
repos = load_repos()
if name in repos:
self._json({"ok": False, "msg": f"{name} 已在同步列表中"})
return
if not gitea_repo_exists(name):
self._json({"ok": False, "msg": f"Gitea 上不存在仓库 {cfg['gitea_user']}/{name},请检查名字"})
return
acc = active_account()
# GitHub 上不存在同名仓库时,按设置自动创建(需 Token)
if not github_repo_exists(acc, name):
if not (acc.get("token") and cfg.get("auto_create", True)):
self._json({"ok": False, "msg": f"GitHub 上不存在 {acc['gh_user']}/{name},且当前账号无法自动创建(需在设置页添加带 Token 的账号并开启自动创建)"})
return
ok, msg = github_create_repo(acc, name)
if not ok:
self._json({"ok": False, "msg": f"自动创建 GitHub 仓库失败: {msg}"})
return
log(f"{name}: GitHub 仓库不存在,添加时已自动创建({'私有' if acc.get('private', True) else '公开'})")
repos.append(name)
save_repos(repos)
log(f"{name}: 已加入同步列表")
res = sync_repo(name)
if res == "busy":
self._json({"ok": True, "msg": f"{name} 添加成功(该仓库正在同步中,稍后刷新看结果)"})
elif res.startswith("error"):
self._json({"ok": False, "msg": f"已加入列表,但首次同步失败:{res}"})
elif res == "changed":
bump_stats(total_pushes=1)
clear_retry(name)
self._json({"ok": True, "msg": f"{name} 添加成功,已完成首次同步"})
else:
clear_retry(name)
self._json({"ok": True, "msg": f"{name} 添加成功,内容已是最新"})
elif path == "/api/repos/remove":
name = (data.get("name") or "").strip()
repos = load_repos()
if name not in repos:
self._json({"ok": False, "msg": f"{name} 不在同步列表中"})
return
repos = [r for r in repos if r != name]
save_repos(repos)
clear_retry(name)
log(f"{name}: 已从同步列表移除")
self._json({"ok": True, "msg": f"{name} 已移除(GitHub 仓库与本地数据保留)"})
else:
self._json({"error": "not found"}, 404)
def last_real_sync():
try:
for line in reversed(LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()):
if "已同步到 GitHub" in line:
return line.strip()
except Exception:
pass
return "尚无记录"
def write_pidfile():
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
PID_FILE.write_text(str(os.getpid()))
except Exception:
pass
def main():
signal.signal(signal.SIGHUP, signal.SIG_IGN)
DATA_DIR.mkdir(parents=True, exist_ok=True)
(DATA_DIR / "logs").mkdir(exist_ok=True)
# fnOS 解包可能把密钥权限放宽,ssh 会拒绝;守护进程即属主,启动时自愈
try:
if SSH_KEY.exists():
os.chmod(SSH_KEY, 0o600)
except Exception:
pass
write_pidfile()
atexit.register(lambda: PID_FILE.exists() and PID_FILE.unlink())
load_settings()
load_accounts()
load_panel_state()
load_stats()
load_repos()
acc = active_account()
log(f"GiteaHubSync 守护进程启动 (pid {os.getpid()}, 自动同步={'开' if state['enabled'] else '关'}, "
f"项目 {len(load_repos())} 个, GitHub 账号={acc['label'] if acc else '无'}"
f"({acc['gh_user'] if acc else '-'}/{'Token' if acc and acc.get('token') else 'SSH'}), "
f"Gitea 用户={cfg['gitea_user']}, 地址={gitea_host()}, 自动创建={'开' if cfg.get('auto_create') else '关'})")
threading.Thread(target=loop, daemon=True).start()
threading.Thread(target=retry_loop, daemon=True).start()
server = ThreadingHTTPServer(("0.0.0.0", PANEL_PORT), Handler)
log(f"控制面板监听 :{PANEL_PORT}")
server.serve_forever()
if __name__ == "__main__":
main()