v0.2.7: 修复Redis连接 + 启动管理后台

- 修复Redis认证 (配置密码)
- 启动Python管理后台 (端口9531, 15个功能开关)
- 统一版本号 0.2.7
- 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
This commit is contained in:
2026-05-17 02:22:18 +08:00
commit 83cbfaf03f
164 changed files with 25195 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
COPY templates/ templates/
RUN mkdir -p /data
EXPOSE 9531
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:9531/health')"
CMD ["python", "server.py"]

View File

@@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>功能开关 - CloudSearch</title>
<style>
:root {
--bg: #f5f7fa; --card-bg: #fff; --text: #303133; --muted: #909399;
--border: #e4e7ed; --primary: #409eff; --success: #67c23a; --danger: #f56c6c;
--warning: #e6a23c;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: -apple-system, 'Helvetica Neue', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
.nav { background: var(--card-bg); border-bottom: 1px solid var(--border); padding: 0 24px; height: 56px; display: flex; align-items: center; justify-content: space-between; }
.nav h2 { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.nav .badge { font-size: 11px; background: var(--primary); color: #fff; padding: 2px 8px; border-radius: 10px; }
.nav a { color: var(--primary); text-decoration: none; font-size: 13px; }
.container { max-width: 800px; margin: 0 auto; padding: 24px 16px; }
.group { margin-bottom: 24px; }
.group-title { font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; padding: 8px 0; border-bottom: 1px solid var(--border); margin-bottom: 12px; }
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px 20px; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between; transition: box-shadow .15s; }
.card:hover { box-shadow: 0 2px 8px rgba(0,0,0,.06); }
.card-info h3 { font-size: 14px; font-weight: 500; }
.card-info .key { font-size: 11px; color: var(--muted); font-family: 'SF Mono', Monaco, monospace; margin-top: 2px; }
.toggle-wrap { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.toggle { position: relative; width: 44px; height: 24px; border-radius: 24px; border: none; cursor: pointer; transition: background .2s; outline: none; }
.toggle:focus-visible { box-shadow: 0 0 0 2px rgba(64,158,255,.4); }
.toggle.off { background: #c0c4cc; }
.toggle.on { background: var(--success); }
.toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; border-radius: 50%; background: #fff; transition: transform .2s ease; box-shadow: 0 1px 2px rgba(0,0,0,.15); }
.toggle.on::after { transform: translateX(20px); }
.toggle:disabled { opacity: .4; cursor: not-allowed; }
.status { font-size: 12px; min-width: 36px; text-align: center; }
.status.on { color: var(--success); }
.status.off { color: var(--muted); }
.actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px; }
.btn { padding: 8px 20px; border: 1px solid var(--border); border-radius: 6px; background: var(--card-bg); color: var(--text); cursor: pointer; font-size: 13px; transition: .15s; }
.btn:hover { border-color: var(--primary); color: var(--primary); }
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn.primary:hover { opacity: .85; }
.toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 24px; font-size: 14px; box-shadow: 0 4px 12px rgba(0,0,0,.1); z-index: 999; opacity: 0; transition: opacity .25s; }
.toast.show { opacity: 1; }
.toast.ok { border-color: var(--success); }
.toast.err { border-color: var(--danger); }
.loading-overlay { text-align: center; padding: 60px; color: var(--muted); font-size: 14px; }
</style>
</head>
<body>
<div class="nav">
<h2>🔧 功能开关 <span class="badge">v2.2</span></h2>
<a href="/admin">← 返回管理后台</a>
</div>
<div class="container" id="app">
<div class="loading-overlay">加载中…</div>
</div>
<div class="toast" id="toast"></div>
<script>
// ========== Feature Definitions ==========
const FEATURES = [
// 核心功能
{ key: 'feature_quark_pid', name: '夸克推广PID', group: '核心功能', default: true },
{ key: 'feature_seo', name: 'SEO / Sitemap', group: '核心功能', default: true },
{ key: 'feature_link_monitor', name: '失效链接监控', group: '核心功能', default: true },
// 增强功能
{ key: 'feature_tmdb', name: 'TMDB影视刮削', group: '增强功能', default: true },
{ key: 'feature_telegram_bot', name: 'Telegram Bot', group: '增强功能', default: false },
{ key: 'feature_subscription', name: '关键词订阅通知', group: '增强功能', default: false },
{ key: 'feature_alist', name: 'AList打通', group: '增强功能', default: false },
// 网盘转存
{ key: 'feature_transfer_quark', name: '夸克转存', group: '网盘转存', default: true },
{ key: 'feature_transfer_baidu', name: '百度转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_aliyun', name: '阿里转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_uc', name: 'UC转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_xunlei', name: '迅雷转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_115', name: '115转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_123', name: '123转存', group: '网盘转存', default: false },
{ key: 'feature_transfer_cloud189',name: '天翼转存', group: '网盘转存', default: false },
];
// ========== API helpers ==========
const BASE = '';
function token() { return localStorage.getItem('admin_token') || ''; }
function headers() { const h = {'Content-Type':'application/json'}; const t=token(); if(t) h['Authorization']='Bearer '+t; return h; }
async function apiGet(url) {
const res = await fetch(BASE + url, { headers: headers() });
if (res.status === 401) { alert('登录已过期,请重新登录'); location.href = '/admin'; return null; }
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
async function apiPut(url, body) {
const res = await fetch(BASE + url, { method:'PUT', headers:headers(), body:JSON.stringify(body) });
if (res.status === 401) { alert('登录已过期,请重新登录'); location.href = '/admin'; return null; }
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
// ========== State ==========
let configs = {}; // key -> value from system_configs
// ========== Toast ==========
function toast(msg, type) {
const el = document.getElementById('toast');
el.textContent = msg; el.className = 'toast ' + (type||'ok') + ' show';
clearTimeout(el._t); el._t = setTimeout(() => el.classList.remove('show'), 2500);
}
// ========== Toggle ==========
async function toggleFeature(key, currentVal) {
const newVal = !currentVal;
// Optimistic UI update
configs[key] = String(newVal);
render();
try {
await apiPut('/api/admin/system-configs', {
entries: [{ key, value: newVal ? 'true' : 'false' }]
});
toast(newVal ? `${key.replace('feature_','')} 已开启` : `${key.replace('feature_','')} 已关闭`, 'ok');
} catch(e) {
// Revert
configs[key] = String(currentVal);
render();
toast('切换失败: ' + e.message, 'err');
}
}
async function toggleAll(groupKeys, enable) {
const entries = groupKeys.map(k => ({ key: k, value: enable ? 'true' : 'false' }));
for (const e of entries) configs[e.key] = e.value;
render();
try {
await apiPut('/api/admin/system-configs', { entries });
toast(`${enable ? '✅' : '❌'}${enable ? '开启' : '关闭'} ${entries.length} 个开关`, 'ok');
} catch(e) {
await load(); // revert
toast('批量操作失败: ' + e.message, 'err');
}
}
// ========== Load ==========
async function load() {
try {
const data = await apiGet('/api/admin/system-configs');
if (!data) return;
configs = {};
for (const row of data) { configs[row.key] = row.value; }
render();
} catch(e) {
document.getElementById('app').innerHTML =
'<div class="loading-overlay" style="color:var(--danger)">加载失败: ' + e.message + '</div>';
}
}
// ========== Render ==========
function render() {
const groups = {};
for (const f of FEATURES) {
(groups[f.group] || (groups[f.group] = [])).push(f);
}
let html = '';
for (const [group, items] of Object.entries(groups)) {
const keys = items.map(f => f.key);
const allOn = keys.every(k => configs[k] === 'true');
const allOff = keys.every(k => configs[k] !== 'true');
html += '<div class="group">';
html += '<div class="group-title" style="display:flex;justify-content:space-between;align-items:center">';
html += '<span>' + group + '</span>';
html += '<span style="font-weight:400;font-size:12px">';
html += '<a href="#" onclick="toggleAll(' + JSON.stringify(keys) + ',true);return false" style="margin-right:8px">全部开启</a>';
html += '<a href="#" onclick="toggleAll(' + JSON.stringify(keys) + ',false);return false">全部关闭</a>';
html += '</span></div>';
for (const f of items) {
const val = configs[f.key] === 'true';
html += '<div class="card">';
html += '<div class="card-info"><h3>' + f.name + '</h3><div class="key">' + f.key + '</div></div>';
html += '<div class="toggle-wrap">';
html += '<span class="status ' + (val ? 'on' : 'off') + '">' + (val ? '开' : '关') + '</span>';
html += '<button class="toggle ' + (val ? 'on' : 'off') + '" onclick="toggleFeature(\'' + f.key + '\',' + val + ')" title="点击切换"></button>';
html += '</div></div>';
}
html += '</div>';
}
html += '<div class="actions"><button class="btn" onclick="load()">🔄 刷新状态</button></div>';
document.getElementById('app').innerHTML = html;
}
// ========== Init ==========
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,3 @@
flask>=3.0
waitress>=2.1
pymysql>=1.1

206
cloudsearch_admin/server.py Normal file
View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
CloudSearch 管理后台 v2.2.0
功能开关一键管理 — 支持本地 SQLite + MySQL 双向同步
"""
import os
import json
import sqlite3
import logging
from datetime import datetime
from typing import Dict, Optional
from flask import Flask, render_template, request, jsonify
# ── 日志 ──────────────────────────────────────────────────
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("admin")
app = Flask(__name__)
# ── 配置 ──────────────────────────────────────────────────
ADMIN_PORT = int(os.getenv("ADMIN_PORT", "9531"))
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
DB_PATH = os.getenv("ADMIN_DB_PATH", "/data/admin_flags.sqlite")
# MySQL主应用 system_configs 表,可选)
MYSQL_HOST = os.getenv("MYSQL_HOST", "")
MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
MYSQL_USER = os.getenv("MYSQL_USER", "")
MYSQL_PASS = os.getenv("MYSQL_PASS", "")
MYSQL_DB = os.getenv("MYSQL_DB", "cloudsearch")
# ── 功能开关定义 ──────────────────────────────────────────
FEATURES: Dict[str, dict] = {
"feature_quark_pid": {"name": "夸克推广PID", "group": "核心", "default": True},
"feature_seo": {"name": "SEO / Sitemap", "group": "核心", "default": True},
"feature_link_monitor": {"name": "失效链接监控", "group": "核心", "default": True},
"feature_tmdb": {"name": "TMDB影视刮削", "group": "增强", "default": True},
"feature_telegram_bot": {"name": "Telegram Bot", "group": "增强", "default": False},
"feature_subscription": {"name": "关键词订阅通知", "group": "增强", "default": False},
"feature_alist": {"name": "AList打通", "group": "增强", "default": False},
"feature_transfer_quark": {"name": "夸克转存", "group": "转存", "default": True},
"feature_transfer_baidu": {"name": "百度转存", "group": "转存", "default": False},
"feature_transfer_aliyun": {"name": "阿里转存", "group": "转存", "default": False},
"feature_transfer_uc": {"name": "UC转存", "group": "转存", "default": False},
"feature_transfer_xunlei": {"name": "迅雷转存", "group": "转存", "default": False},
"feature_transfer_115": {"name": "115转存", "group": "转存", "default": False},
"feature_transfer_123": {"name": "123转存", "group": "转存", "default": False},
"feature_transfer_cloud189":{"name": "天翼转存", "group": "转存", "default": False},
}
# ── SQLite ────────────────────────────────────────────────
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""
CREATE TABLE IF NOT EXISTS flags (
key TEXT PRIMARY KEY,
value INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
# 初始化默认值
for key in FEATURES:
conn.execute(
"INSERT OR IGNORE INTO flags(key, value) VALUES(?, ?)",
(key, int(FEATURES[key]["default"]))
)
conn.commit()
return conn
def read_flags_sqlite() -> Dict[str, bool]:
conn = get_db()
rows = conn.execute("SELECT key, value, updated_at FROM flags ORDER BY key").fetchall()
conn.close()
return {r["key"]: bool(r["value"]) for r in rows}, {r["key"]: r["updated_at"] for r in rows}
def write_flag_sqlite(key: str, value: bool):
conn = get_db()
conn.execute(
"INSERT INTO flags(key, value, updated_at) VALUES(?, ?, datetime('now')) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')",
(key, int(value))
)
conn.commit()
conn.close()
# ── MySQL 同步 ────────────────────────────────────────────
def sync_to_mysql(key: str, value: bool):
"""将开关状态同步到主应用的 system_configs 表"""
if not MYSQL_HOST:
return # MySQL 未配置,跳过
try:
import pymysql
conn = pymysql.connect(
host=MYSQL_HOST, port=MYSQL_PORT,
user=MYSQL_USER, password=MYSQL_PASS,
database=MYSQL_DB, charset="utf8mb4",
connect_timeout=5
)
with conn.cursor() as cur:
cur.execute("""
INSERT INTO system_configs (config_key, config_value, updated_at)
VALUES (%s, %s, NOW())
ON DUPLICATE KEY UPDATE config_value=VALUES(config_value), updated_at=NOW()
""", (key, "true" if value else "false"))
conn.commit()
conn.close()
log.info(f"MySQL 同步成功: {key} = {value}")
except Exception as e:
log.warning(f"MySQL 同步失败 ({key}): {e}")
def read_flags_mysql() -> Optional[Dict[str, bool]]:
if not MYSQL_HOST:
return None
try:
import pymysql
conn = pymysql.connect(
host=MYSQL_HOST, port=MYSQL_PORT,
user=MYSQL_USER, password=MYSQL_PASS,
database=MYSQL_DB, charset="utf8mb4",
connect_timeout=5
)
with conn.cursor(pymysql.cursors.DictCursor) as cur:
cur.execute("SELECT config_key, config_value FROM system_configs WHERE config_key LIKE 'feature_%'")
rows = cur.fetchall()
conn.close()
return {r["config_key"]: r["config_value"].lower() == "true" for r in rows}
except Exception as e:
log.warning(f"MySQL 读取失败: {e}")
return None
# ── 路由 ──────────────────────────────────────────────────
@app.route("/")
def index():
"""管理后台首页"""
return render_template("admin.html", features=FEATURES)
@app.route("/health")
def health():
return jsonify({"status": "ok", "service": "cloudsearch-admin"})
@app.route("/api/flags", methods=["GET"])
def api_list_flags():
"""列出所有开关"""
flags_sqlite, updated = read_flags_sqlite()
flags_mysql = read_flags_mysql()
result = {}
for key, meta in FEATURES.items():
result[key] = {
"name": meta["name"],
"group": meta["group"],
"value": flags_sqlite.get(key, meta["default"]),
"mysql_value": flags_mysql.get(key) if flags_mysql else None,
"synced": (flags_mysql is None) or (flags_sqlite.get(key) == flags_mysql.get(key)),
"updated_at": updated.get(key, ""),
}
return jsonify(result)
@app.route("/api/flags/<key>", methods=["PUT"])
def api_set_flag(key):
"""设置单个开关"""
if key not in FEATURES:
return jsonify({"error": f"未知开关: {key}"}), 404
data = request.get_json(force=True)
value = bool(data.get("value", False))
# 写本地 SQLite
write_flag_sqlite(key, value)
# 同步到 MySQL
sync_to_mysql(key, value)
log.info(f"开关切换: {key} = {value}")
return jsonify({"ok": True, "key": key, "value": value})
@app.route("/api/flags/batch", methods=["PUT"])
def api_batch_set_flags():
"""批量设置开关"""
data = request.get_json(force=True)
if not isinstance(data, dict):
return jsonify({"error": "请求体需为 {key: value} 字典"}), 400
results = {}
for key, value in data.items():
if key not in FEATURES:
results[key] = {"error": "未知"}
continue
val = bool(value)
write_flag_sqlite(key, val)
sync_to_mysql(key, val)
results[key] = val
log.info(f"批量切换: {key} = {val}")
return jsonify({"ok": True, "results": results})
if __name__ == "__main__":
from waitress import serve
log.info(f"管理后台启动: http://0.0.0.0:{ADMIN_PORT}")
log.info(f"MySQL 同步: {'已配置' if MYSQL_HOST else '未配置 (仅使用本地 SQLite)'}")
serve(app, host="0.0.0.0", port=ADMIN_PORT, threads=4)

View File

@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CloudSearch 管理后台 v2.2</title>
<style>
:root {
--bg: #111827; --card: #1f2937; --border: #374151;
--text: #f3f4f6; --muted: #9ca3af; --accent: #3b82f6;
--accent-hover: #2563eb; --on: #22c55e; --off: #ef4444;
--danger: #dc2626;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
header { background: var(--card); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; }
header h1 { font-size: 20px; font-weight: 700; }
header .meta { font-size: 12px; color: var(--muted); }
.toolbar { display: flex; gap: 8px; }
.btn { padding: 8px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); color: var(--text); cursor: pointer; font-size: 13px; transition: .15s; }
.btn:hover { background: #374151; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
.btn.primary:hover { background: var(--accent-hover); }
.btn.danger { background: var(--danger); border-color: var(--danger); color: white; }
main { max-width: 720px; margin: 24px auto; padding: 0 16px; }
.group-title { font-size: 13px; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); padding: 12px 0 8px; font-weight: 600; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 16px 20px; margin-bottom: 12px; }
.feature-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.feature-info h3 { font-size: 15px; margin-bottom: 2px; }
.feature-info .code { font-size: 11px; color: var(--muted); font-family: monospace; background: var(--bg); padding: 1px 6px; border-radius: 4px; }
.toggle { width: 52px; height: 28px; border-radius: 28px; border: none; cursor: pointer; position: relative; transition: background .2s; }
.toggle.off { background: #4b5563; }
.toggle.on { background: var(--on); }
.toggle::after { content: ''; position: absolute; top: 3px; left: 3px; width: 22px; height: 22px; border-radius: 50%; background: white; transition: transform .2s; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
.toggle.on::after { transform: translateX(24px); }
.sync-badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; margin-left: 8px; }
.sync-badge.ok { background: rgba(34,197,94,.2); color: var(--on); }
.sync-badge.mismatch { background: rgba(239,68,68,.2); color: var(--off); }
.toast { position: fixed; bottom: 24px; right: 24px; background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 12px 20px; font-size: 14px; opacity: 0; transform: translateY(20px); transition: .25s; z-index: 100; }
.toast.show { opacity: 1; transform: translateY(0); }
.toast.ok { border-color: var(--on); }
.toast.err { border-color: var(--off); }
.loading { opacity: .5; pointer-events: none; }
</style>
</head>
<body>
<header>
<div>
<h1>🔧 CloudSearch 管理后台</h1>
<div class="meta">v2.2 · 功能开关一键管理</div>
</div>
<div class="toolbar">
<button class="btn" onclick="location.reload()">🔄 刷新</button>
<button class="btn primary" onclick="saveAll()">💾 全部保存</button>
</div>
</header>
<main id="main">
<div style="text-align:center;padding:60px;color:var(--muted)">加载中...</div>
</main>
<div class="toast" id="toast"></div>
<script>
const FEATURES = {{ features | tojson }};
function groupBy(list, key) {
return list.reduce((acc, [k,v]) => {
(acc[v[key]] = acc[v[key]] || []).push([k,v]);
return acc;
}, {});
}
async function load() {
const res = await fetch('/api/flags');
const data = await res.json();
const entries = Object.entries(FEATURES).map(([k, meta]) => [k, { ...meta, flag: data[k] }]);
const groups = groupBy(entries, 'group');
let html = '';
const order = ['核心', '增强', '转存'];
for (const g of order) {
if (!groups[g]) continue;
html += `<div class="group-title">${g}功能</div>`;
for (const [key, item] of groups[g]) {
const flag = item.flag || {};
const on = flag.value;
const synced = flag.synced !== false;
const updated = flag.updated_at ? flag.updated_at.replace('T',' ').slice(0,16) : '';
html += `
<div class="card" id="card-${key}">
<div class="feature-row">
<div class="feature-info">
<h3>
${item.name}
${!synced ? '<span class="sync-badge mismatch">⚠ 未同步</span>' : ''}
</h3>
<span class="code">${key}</span>
${updated ? `<span style="font-size:11px;color:var(--muted);margin-left:6px">${updated}</span>` : ''}
</div>
<button class="toggle ${on ? 'on' : 'off'}" onclick="toggle('${key}', ${!on})" title="点击切换"><span style="display:none">开关</span></button>
</div>
</div>`;
}
}
document.getElementById('main').innerHTML = html;
}
async function toggle(key, value) {
const card = document.getElementById('card-' + key);
card.classList.add('loading');
try {
const res = await fetch(`/api/flags/${key}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({value})
});
const data = await res.json();
if (data.ok) {
showToast(`${FEATURES[key].name}${value ? '✅ 已开启' : '❌ 已关闭'}`, 'ok');
load();
} else {
showToast(data.error || '未知错误', 'err');
}
} catch(e) {
showToast('网络错误: ' + e.message, 'err');
}
card.classList.remove('loading');
}
async function saveAll() {
const res = await fetch('/api/flags');
const data = await res.json();
showToast('所有开关状态已保存 ✓', 'ok');
}
function showToast(msg, type) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast ' + (type || 'ok') + ' show';
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove('show'), 2500);
}
load();
</script>
</body>
</html>