From 0134a5ef6423b57a6c79a900ae0bf1917661160d Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 28 Apr 2026 19:40:02 +0800 Subject: [PATCH] =?UTF-8?q?fix=20=E4=BF=AE=E5=A4=8Dbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + app/config.py | 1 + app/main.py | 240 ++++++++++++++++++++++++++++++++-- app/schemas.py | 4 + app/services/user_store.py | 28 ++++ app/static/admin.js | 248 ++++++++++++++++++++++++++++++++++++ app/static/auth.js | 7 +- app/static/mode-hint.js | 13 +- app/static/settings.js | 37 ++++++ app/templates/admin.html | 151 ++++++++++++++++++++++ app/templates/settings.html | 3 +- 11 files changed, 720 insertions(+), 14 deletions(-) create mode 100644 app/static/admin.js create mode 100644 app/templates/admin.html diff --git a/.env.example b/.env.example index 3df8d85..535958f 100644 --- a/.env.example +++ b/.env.example @@ -78,3 +78,5 @@ CREDITS_RECHARGE_PACKAGE_CREDITS=1500 # 下单入口:POST https://你的域名/api/pay/wechat/ # 回调入口:POST https://你的域名/api/pay/wechat/backcall SHOP_BACKEND_CALLBACK_TOKEN= +# 超级管理后台口令(访问 /admin?token=你的口令) +SUPER_ADMIN_TOKEN= diff --git a/app/config.py b/app/config.py index 61ae5fe..aa7532f 100644 --- a/app/config.py +++ b/app/config.py @@ -156,6 +156,7 @@ class Settings(BaseSettings): platform_openai_max_retries: int = Field(default=0, alias="PLATFORM_OPENAI_MAX_RETRIES") shop_backend_create_order_url: str | None = Field(default=None, alias="SHOP_BACKEND_CREATE_ORDER_URL") shop_backend_callback_token: str = Field(default="", alias="SHOP_BACKEND_CALLBACK_TOKEN") + super_admin_token: str = Field(default="", alias="SUPER_ADMIN_TOKEN") settings = Settings() diff --git a/app/main.py b/app/main.py index cecf53e..7500cc2 100644 --- a/app/main.py +++ b/app/main.py @@ -4,6 +4,7 @@ import logging import math import re import secrets +import sqlite3 import socket import time import uuid @@ -33,6 +34,7 @@ from app.schemas import ( ForgotPasswordResetRequest, IMPublishRequest, PosterGenerateRequest, + ResetCodeRegenerateRequest, RewriteRequest, WechatCoverUploadByUrlRequest, WechatCoverGenerateRequest, @@ -80,6 +82,7 @@ _login_rate: dict[str, list[float]] = {} _challenge_pool: dict[str, dict] = {} USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{4,24}$") PASSWORD_STRONG_RE = re.compile(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9])\S{10,64}$") +TABLE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") def _session_ttl(remember_me: bool) -> int: @@ -100,6 +103,17 @@ def _require_user(request: Request) -> dict | None: return u +def _require_super_admin(request: Request) -> None: + configured = (settings.super_admin_token or "").strip() + provided = ( + request.headers.get("X-Admin-Token") + or request.query_params.get("token") + or "" + ).strip() + if not configured or provided != configured: + raise HTTPException(status_code=403, detail="forbidden") + + def _client_ip(request: Request) -> str: xff = (request.headers.get("x-forwarded-for") or "").strip() if xff: @@ -162,18 +176,66 @@ def _validate_username_password(username: str, password: str) -> tuple[bool, str def _platform_model_cfg() -> dict: + override = _get_platform_model_override() return { - "api_key": settings.platform_openai_api_key or "", - "base_url": settings.platform_openai_base_url or "", - "model": settings.platform_openai_model, - "image_model": settings.platform_openai_image_model, - "timeout_sec": float(settings.platform_openai_timeout), - "max_output_tokens": int(settings.platform_openai_max_output_tokens), - "max_retries": int(settings.platform_openai_max_retries), + "api_key": str(override.get("api_key") or settings.platform_openai_api_key or ""), + "base_url": str(override.get("base_url") or settings.platform_openai_base_url or ""), + "model": str(override.get("model") or settings.platform_openai_model), + "image_model": str(override.get("image_model") or settings.platform_openai_image_model), + "timeout_sec": float(override.get("timeout_sec") or settings.platform_openai_timeout), + "max_output_tokens": int(override.get("max_output_tokens") or settings.platform_openai_max_output_tokens), + "max_retries": int(override.get("max_retries") or settings.platform_openai_max_retries), "model_name": "平台模型", } +def _ensure_system_settings_table() -> None: + with sqlite3.connect(settings.auth_db_path) as c: + c.execute( + """ + CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + ) + """ + ) + c.commit() + + +def _get_system_setting(key: str, default: str = "") -> str: + _ensure_system_settings_table() + with sqlite3.connect(settings.auth_db_path) as c: + row = c.execute("SELECT value FROM system_settings WHERE key=? LIMIT 1", (key,)).fetchone() + return str(row[0]) if row and row[0] is not None else default + + +def _set_system_setting(key: str, value: str) -> None: + _ensure_system_settings_table() + with sqlite3.connect(settings.auth_db_path) as c: + c.execute( + """ + INSERT INTO system_settings(key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at + """, + (key, value, int(time.time())), + ) + c.commit() + + +def _get_platform_model_override() -> dict: + return { + "api_key": _get_system_setting("platform_model_api_key", ""), + "base_url": _get_system_setting("platform_model_base_url", ""), + "model": _get_system_setting("platform_model_text_model", ""), + "image_model": _get_system_setting("platform_model_image_model", ""), + "timeout_sec": _get_system_setting("platform_model_timeout_sec", ""), + "max_output_tokens": _get_system_setting("platform_model_max_output_tokens", ""), + "max_retries": _get_system_setting("platform_model_max_retries", ""), + } + + def _select_model_cfg(user_id: int, prefer_vip: bool = True) -> tuple[dict | None, str]: vip = users.get_vip_status(user_id) now = int(time.time()) @@ -390,6 +452,12 @@ async def guide_page(request: Request): return templates.TemplateResponse("guide.html", {"request": request, "app_name": settings.app_name}) +@app.get("/admin", response_class=HTMLResponse) +async def admin_page(request: Request): + _require_super_admin(request) + return templates.TemplateResponse("admin.html", {"request": request, "app_name": settings.app_name}) + + @app.get("/favicon.ico", include_in_schema=False) async def favicon(): # 浏览器通常请求 /favicon.ico,统一跳转到静态图标 @@ -420,6 +488,139 @@ async def api_config(request: Request): } +@app.get("/api/admin/tables") +async def admin_tables(request: Request): + _require_super_admin(request) + with sqlite3.connect(settings.auth_db_path) as c: + rows = c.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name ASC" + ).fetchall() + return {"ok": True, "tables": [str(r[0]) for r in rows]} + + +@app.get("/api/admin/table/{table_name}") +async def admin_table_rows(table_name: str, request: Request, limit: int = 100, offset: int = 0): + _require_super_admin(request) + tname = (table_name or "").strip() + if not TABLE_NAME_RE.match(tname): + return {"ok": False, "detail": "invalid table name"} + page_limit = max(1, min(int(limit), 500)) + page_offset = max(0, min(int(offset), 100000)) + with sqlite3.connect(settings.auth_db_path) as c: + c.row_factory = sqlite3.Row + exists = c.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1", + (tname,), + ).fetchone() + if not exists: + return {"ok": False, "detail": "table not found"} + count_row = c.execute(f'SELECT COUNT(*) AS cnt FROM "{tname}"').fetchone() + total = int((count_row["cnt"] if count_row else 0) or 0) + rows = c.execute( + f'SELECT * FROM "{tname}" LIMIT ? OFFSET ?', + (page_limit, page_offset), + ).fetchall() + items = [dict(r) for r in rows] + columns = list(items[0].keys()) if items else [] + return { + "ok": True, + "table": tname, + "limit": page_limit, + "offset": page_offset, + "total": total, + "columns": columns, + "rows": items, + } + + +@app.get("/api/admin/platform-model") +async def admin_get_platform_model(request: Request): + _require_super_admin(request) + cfg = _platform_model_cfg() + return { + "ok": True, + "config": { + "api_key": cfg.get("api_key", ""), + "base_url": cfg.get("base_url", ""), + "model": cfg.get("model", ""), + "image_model": cfg.get("image_model", ""), + "timeout_sec": cfg.get("timeout_sec", 120), + "max_output_tokens": cfg.get("max_output_tokens", 8192), + "max_retries": cfg.get("max_retries", 0), + }, + "text_model_options": [s.strip() for s in (settings.platform_openai_text_model_options or "").split(",") if s.strip()], + "image_model_options": [s.strip() for s in (settings.platform_openai_image_model_options or "").split(",") if s.strip()], + } + + +@app.post("/api/admin/platform-model") +async def admin_update_platform_model(request: Request): + _require_super_admin(request) + body = await request.json() + api_key = str((body or {}).get("api_key") or "").strip() + base_url = str((body or {}).get("base_url") or "").strip() + model = str((body or {}).get("model") or "").strip() + image_model = str((body or {}).get("image_model") or "").strip() + timeout_sec = max(5.0, min(600.0, float((body or {}).get("timeout_sec") or settings.platform_openai_timeout))) + max_output_tokens = max(256, min(65535, int((body or {}).get("max_output_tokens") or settings.platform_openai_max_output_tokens))) + max_retries = max(0, min(5, int((body or {}).get("max_retries") or settings.platform_openai_max_retries))) + if not api_key or not model: + return {"ok": False, "detail": "平台模型配置至少需要 API Key 和文本模型"} + + _set_system_setting("platform_model_api_key", api_key) + _set_system_setting("platform_model_base_url", base_url) + _set_system_setting("platform_model_text_model", model) + _set_system_setting("platform_model_image_model", image_model) + _set_system_setting("platform_model_timeout_sec", str(timeout_sec)) + _set_system_setting("platform_model_max_output_tokens", str(max_output_tokens)) + _set_system_setting("platform_model_max_retries", str(max_retries)) + return {"ok": True, "detail": "平台模型配置已保存"} + + +@app.get("/api/admin/users/overview") +async def admin_users_overview(request: Request, limit: int = 30): + _require_super_admin(request) + now = int(time.time()) + today_start = now - (now % 86400) + page_limit = max(1, min(int(limit), 200)) + with sqlite3.connect(settings.auth_db_path) as c: + c.row_factory = sqlite3.Row + total_row = c.execute("SELECT COUNT(*) AS cnt FROM users").fetchone() + active_row = c.execute("SELECT COUNT(*) AS cnt FROM users WHERE deleted_at IS NULL").fetchone() + deleted_row = c.execute("SELECT COUNT(*) AS cnt FROM users WHERE deleted_at IS NOT NULL").fetchone() + today_row = c.execute( + "SELECT COUNT(*) AS cnt FROM users WHERE created_at>=? AND created_at str | None: + uid = int(user_id or 0) + if uid <= 0: + return None + with self._conn() as c: + row = c.execute( + """ + SELECT id, password_hash, password_salt + FROM users + WHERE id=? AND deleted_at IS NULL + LIMIT 1 + """, + (uid,), + ).fetchone() + if not row: + return None + calc_pwd = self._hash_password(password or "", row["password_salt"] or "") + if not hmac.compare_digest(calc_pwd, row["password_hash"] or ""): + return None + reset_code = self._generate_reset_code() + reset_salt = secrets.token_hex(8) + reset_hash = self._hash_reset_code(reset_code, reset_salt) + c.execute( + "UPDATE users SET reset_code_hash=?, reset_code_salt=? WHERE id=?", + (reset_hash, reset_salt, uid), + ) + return reset_code + def create_session(self, user_id: int, ttl_seconds: int = 7 * 24 * 3600) -> str: token = secrets.token_urlsafe(32) token_hash = self._hash_token(token) diff --git a/app/static/admin.js b/app/static/admin.js new file mode 100644 index 0000000..38ebb92 --- /dev/null +++ b/app/static/admin.js @@ -0,0 +1,248 @@ +(() => { + const $ = (id) => document.getElementById(id); + const token = new URLSearchParams(window.location.search).get("token") || ""; + let currentOffset = 0; + let currentTotal = 0; + function fmtTime(ts) { + const n = Number(ts || 0); + if (!n) return "-"; + const d = new Date(n * 1000); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + const ss = String(d.getSeconds()).padStart(2, "0"); + return `${y}-${m}-${day} ${hh}:${mm}:${ss}`; + } + + function setStatus(msg, isError = false) { + const el = $("adminStatus"); + if (!el) return; + el.textContent = msg || ""; + el.style.color = isError ? "#b91c1c" : ""; + } + + async function getJSON(url) { + const res = await fetch(url, { + headers: { + "X-Admin-Token": token, + }, + credentials: "same-origin", + }); + let data = {}; + try { + data = await res.json(); + } catch (_) { + data = {}; + } + if (!res.ok) { + throw new Error(data.detail || `HTTP ${res.status}`); + } + return data; + } + + async function postJSON(url, payload) { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Admin-Token": token, + }, + credentials: "same-origin", + body: JSON.stringify(payload || {}), + }); + let data = {}; + try { + data = await res.json(); + } catch (_) { + data = {}; + } + if (!res.ok || data.ok === false) { + throw new Error(data.detail || `HTTP ${res.status}`); + } + return data; + } + + function fillOptions(id, items) { + const el = $(id); + if (!el) return; + el.innerHTML = ""; + for (const it of items || []) { + const op = document.createElement("option"); + op.value = String(it || ""); + el.appendChild(op); + } + } + + async function loadPlatformModel() { + const data = await getJSON("/api/admin/platform-model"); + const cfg = data.config || {}; + if ($("platformApiKey")) $("platformApiKey").value = cfg.api_key || ""; + if ($("platformBaseUrl")) $("platformBaseUrl").value = cfg.base_url || ""; + if ($("platformTextModel")) $("platformTextModel").value = cfg.model || ""; + if ($("platformImageModel")) $("platformImageModel").value = cfg.image_model || ""; + if ($("platformTimeoutSec")) $("platformTimeoutSec").value = Number(cfg.timeout_sec || 120); + if ($("platformMaxOutputTokens")) $("platformMaxOutputTokens").value = Number(cfg.max_output_tokens || 8192); + fillOptions("platformTextModelOptions", data.text_model_options || []); + fillOptions("platformImageModelOptions", data.image_model_options || []); + } + + async function loadUserOverview() { + const data = await getJSON("/api/admin/users/overview?limit=30"); + const stats = data.stats || {}; + if ($("adminTotalUsers")) $("adminTotalUsers").value = String(stats.total_users || 0); + if ($("adminActiveUsers")) $("adminActiveUsers").value = String(stats.active_users || 0); + if ($("adminTodayUsers")) $("adminTodayUsers").value = String(stats.today_new_users || 0); + if ($("adminDeletedUsers")) $("adminDeletedUsers").value = String(stats.deleted_users || 0); + + const tbody = $("adminRecentUsersRows"); + if (!tbody) return; + const rows = Array.isArray(data.recent_users) ? data.recent_users : []; + if (!rows.length) { + tbody.innerHTML = '暂无记录'; + return; + } + tbody.innerHTML = rows + .map((r) => { + const status = Number(r.deleted_at || 0) > 0 ? "已注销" : "正常"; + return ` + ${Number(r.id || 0)} + ${String(r.username || "")} + ${fmtTime(r.created_at)} + ${status} + `; + }) + .join(""); + } + + async function savePlatformModel() { + await postJSON("/api/admin/platform-model", { + api_key: ($("platformApiKey")?.value || "").trim(), + base_url: ($("platformBaseUrl")?.value || "").trim(), + model: ($("platformTextModel")?.value || "").trim(), + image_model: ($("platformImageModel")?.value || "").trim(), + timeout_sec: Number($("platformTimeoutSec")?.value || 120), + max_output_tokens: Number($("platformMaxOutputTokens")?.value || 8192), + max_retries: 0, + }); + setStatus("平台模型配置已保存"); + if (window.uiAlert) window.uiAlert("平台模型配置已保存并立即生效"); + } + + function renderTable(rows, columns) { + const head = $("adminHeadRow"); + const body = $("adminBodyRows"); + if (!head || !body) return; + head.innerHTML = ""; + body.innerHTML = ""; + if (!columns || !columns.length) { + head.innerHTML = "无列信息"; + body.innerHTML = '当前页没有数据'; + return; + } + for (const col of columns) { + const th = document.createElement("th"); + th.textContent = col; + head.appendChild(th); + } + if (!rows || !rows.length) { + const tr = document.createElement("tr"); + const td = document.createElement("td"); + td.colSpan = columns.length; + td.className = "muted"; + td.textContent = "当前页没有数据"; + tr.appendChild(td); + body.appendChild(tr); + return; + } + for (const row of rows) { + const tr = document.createElement("tr"); + for (const col of columns) { + const td = document.createElement("td"); + const val = row[col]; + td.textContent = val == null ? "" : typeof val === "object" ? JSON.stringify(val) : String(val); + tr.appendChild(td); + } + body.appendChild(tr); + } + } + + async function loadTables() { + const data = await getJSON("/api/admin/tables"); + const sel = $("adminTableSelect"); + if (!sel) return; + sel.innerHTML = ""; + const tables = Array.isArray(data.tables) ? data.tables : []; + for (const t of tables) { + const opt = document.createElement("option"); + opt.value = t; + opt.textContent = t; + sel.appendChild(opt); + } + if (!tables.length) { + setStatus("数据库没有可展示的数据表"); + return; + } + await loadRows(true); + } + + async function loadRows(resetOffset = false) { + const sel = $("adminTableSelect"); + const limitInput = $("adminLimit"); + if (!sel || !limitInput) return; + const table = (sel.value || "").trim(); + if (!table) return; + if (resetOffset) currentOffset = 0; + const limit = Math.max(1, Math.min(500, Number(limitInput.value) || 100)); + const data = await getJSON( + `/api/admin/table/${encodeURIComponent(table)}?limit=${limit}&offset=${currentOffset}` + ); + if (!data.ok) { + throw new Error(data.detail || "加载失败"); + } + currentTotal = Number(data.total || 0); + renderTable(data.rows || [], data.columns || []); + const start = currentOffset + 1; + const end = Math.min(currentOffset + limit, currentTotal); + setStatus(`表 ${table}:第 ${start}-${end > 0 ? end : 0} 条 / 共 ${currentTotal} 条`); + } + + async function safeRun(fn) { + try { + await fn(); + } catch (err) { + const msg = err && err.message ? err.message : "请求失败"; + setStatus(msg, true); + if (window.uiAlert) window.uiAlert(msg); + } + } + + $("adminRefreshBtn")?.addEventListener("click", () => safeRun(() => loadRows(false))); + $("adminTableSelect")?.addEventListener("change", () => safeRun(() => loadRows(true))); + $("platformReloadBtn")?.addEventListener("click", () => safeRun(loadPlatformModel)); + $("platformSaveBtn")?.addEventListener("click", () => safeRun(savePlatformModel)); + $("adminUserRefreshBtn")?.addEventListener("click", () => safeRun(loadUserOverview)); + $("adminPrevBtn")?.addEventListener("click", () => + safeRun(async () => { + const limit = Math.max(1, Math.min(500, Number($("adminLimit")?.value) || 100)); + currentOffset = Math.max(0, currentOffset - limit); + await loadRows(false); + }) + ); + $("adminNextBtn")?.addEventListener("click", () => + safeRun(async () => { + const limit = Math.max(1, Math.min(500, Number($("adminLimit")?.value) || 100)); + if (currentOffset + limit < currentTotal) { + currentOffset += limit; + } + await loadRows(false); + }) + ); + + safeRun(async () => { + await loadUserOverview(); + await loadPlatformModel(); + await loadTables(); + }); +})(); diff --git a/app/static/auth.js b/app/static/auth.js index c5573ec..51e5437 100644 --- a/app/static/auth.js +++ b/app/static/auth.js @@ -108,7 +108,12 @@ if (registerBtn) { } } setStatus("注册成功,正在跳转..."); - window.location.href = nextPath(); + const redirectTo = (data.redirect_to || "").trim(); + if (redirectTo && redirectTo.startsWith("/")) { + window.location.href = redirectTo; + } else { + window.location.href = nextPath(); + } } catch (e) { setStatus(e.message || "请求异常", true); await refreshChallenge(); diff --git a/app/static/mode-hint.js b/app/static/mode-hint.js index ffd2e90..57eba0b 100644 --- a/app/static/mode-hint.js +++ b/app/static/mode-hint.js @@ -17,7 +17,18 @@ const now = Math.floor(Date.now() / 1000); const enabled = Boolean(vip.vip_enabled); const hasActiveSubscription = Number(vip.cycle_started_at || 0) > 0 && Number(vip.cycle_expires_at || 0) > now; - setText(enabled && hasActiveSubscription ? "当前模型模式:平台模型" : "当前模型模式:自由模型"); + const activeModel = data.active_ai_model || {}; + const hasSelfModel = + Boolean(String(activeModel.api_key || "").trim()) && + Boolean(String(activeModel.model || "").trim()); + const hasTrialCredits = Number(vip.total_available_credits || 0) > 0; + if (enabled && hasActiveSubscription) { + setText("当前模型模式:平台模型"); + } else if (enabled && !hasSelfModel && hasTrialCredits) { + setText("当前模型模式:平台模型(体验版)"); + } else { + setText("当前模型模式:自由模型"); + } } catch { // ignore } diff --git a/app/static/settings.js b/app/static/settings.js index ca5bacd..034309e 100644 --- a/app/static/settings.js +++ b/app/static/settings.js @@ -101,6 +101,7 @@ const bindBtn = $("bindBtn"); const deleteWechatBtn = $("deleteWechatBtn"); const logoutBtn = $("logoutBtn"); const changePwdBtn = $("changePwdBtn"); +const regenResetCodeBtn = $("regenResetCodeBtn"); const deleteAccountBtn = $("deleteAccountBtn"); const modelSelect = $("modelSelect"); const saveModelBtn = $("saveModelBtn"); @@ -347,6 +348,42 @@ if (changePwdBtn) { }); } +if (regenResetCodeBtn) { + regenResetCodeBtn.addEventListener("click", async () => { + const pwd = await window.uiPrompt("请输入当前登录密码,用于生成新的重置码:", "重新生成重置码", "", "请输入当前密码"); + if (!pwd) return; + setLoading(regenResetCodeBtn, true, "重新生成重置码", "生成中..."); + try { + const out = await postJSON("/api/auth/reset-code/regenerate", { password: pwd }); + if (!out.ok) { + setStatus(out.detail || "生成失败", true); + return; + } + const code = String(out.reset_code || "").trim(); + if (!code) { + setStatus("生成失败,请稍后再试", true); + return; + } + await window.uiAlert( + `新的重置码如下(仅展示这一次):\n\n${code}\n\n请立即复制并妥善保存,旧重置码已失效。`, + "重置码已更新" + ); + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(code); + } + } catch { + // ignore clipboard failure + } + setStatus("新重置码已生成并展示一次,请妥善保存。"); + } catch (e) { + setStatus(e.message || "生成失败", true); + } finally { + setLoading(regenResetCodeBtn, false, "重新生成重置码", "生成中..."); + } + }); +} + if (deleteAccountBtn) { deleteAccountBtn.addEventListener("click", async () => { const pwd = ($("deletePassword") && $("deletePassword").value) || ""; diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..c932113 --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,151 @@ + + + + + + {{ app_name }} · 超级管理后台 + + + +
+ + +
+
+

超级管理后台

+ 平台模型与数据库管理 +
+ +
+
+

用户注册总览

+
+ + + + +
+
+ +
+
+ +
+

最近注册记录

+
+ + + + + + + + + + + + +
ID用户名注册时间状态
暂无记录
+
+
+ +
+

平台模型配置

+
+ + + + + + +
+
+ + +
+
+ +
+

数据库总览

+
+ + +
+
+ + + +
+

准备就绪

+
+ +
+

表数据

+
+ + + + + + + +
暂无数据
请选择一个表后加载数据
+
+
+
+
+
+ + + + + diff --git a/app/templates/settings.html b/app/templates/settings.html index fd43ea6..0618a74 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -137,6 +137,7 @@
忘记密码提示 +
@@ -160,6 +161,6 @@
- +