v0.2.7: 修复Redis连接 + 启动管理后台
- 修复Redis认证 (配置密码) - 启动Python管理后台 (端口9531, 15个功能开关) - 统一版本号 0.2.7 - 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
This commit is contained in:
18
cloudsearch_admin/Dockerfile
Normal file
18
cloudsearch_admin/Dockerfile
Normal 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"]
|
||||
196
cloudsearch_admin/features.html
Normal file
196
cloudsearch_admin/features.html
Normal 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>
|
||||
3
cloudsearch_admin/requirements.txt
Normal file
3
cloudsearch_admin/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
flask>=3.0
|
||||
waitress>=2.1
|
||||
pymysql>=1.1
|
||||
206
cloudsearch_admin/server.py
Normal file
206
cloudsearch_admin/server.py
Normal 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)
|
||||
149
cloudsearch_admin/templates/admin.html
Normal file
149
cloudsearch_admin/templates/admin.html
Normal 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>
|
||||
Reference in New Issue
Block a user