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",
+ (today_start, today_start + 86400),
+ ).fetchone()
+ recent = c.execute(
+ """
+ SELECT id, username, created_at, deleted_at
+ FROM users
+ ORDER BY created_at DESC, id DESC
+ LIMIT ?
+ """,
+ (page_limit,),
+ ).fetchall()
+ return {
+ "ok": True,
+ "stats": {
+ "total_users": int((total_row["cnt"] if total_row else 0) or 0),
+ "active_users": int((active_row["cnt"] if active_row else 0) or 0),
+ "deleted_users": int((deleted_row["cnt"] if deleted_row else 0) or 0),
+ "today_new_users": int((today_row["cnt"] if today_row else 0) or 0),
+ },
+ "recent_users": [
+ {
+ "id": int(r["id"] or 0),
+ "username": str(r["username"] or ""),
+ "created_at": int(r["created_at"] or 0),
+ "deleted_at": int(r["deleted_at"] or 0) if r["deleted_at"] else 0,
+ }
+ for r in recent
+ ],
+ }
+
+
@app.get("/api/auth/me")
async def auth_me(request: Request):
user = _current_user(request)
@@ -470,9 +671,9 @@ async def auth_challenge():
@app.post("/api/auth/register")
async def auth_register(req: AuthCredentialRequest, request: Request, response: Response):
ip = _client_ip(request)
- if _hit_limit(_register_rate, f"ip:{ip}", limit=8, window_sec=600):
+ if _hit_limit(_register_rate, f"ip:{ip}", limit=20, window_sec=300):
return {"ok": False, "detail": "请求过于频繁,请稍后再试"}
- if _hit_limit(_register_rate, f"user:{(req.username or '').strip().lower()}", limit=6, window_sec=600):
+ if _hit_limit(_register_rate, f"user:{(req.username or '').strip().lower()}", limit=12, window_sec=300):
return {"ok": False, "detail": "该用户名操作过于频繁,请稍后再试"}
username = (req.username or "").strip()
password = req.password or ""
@@ -505,15 +706,17 @@ async def auth_register(req: AuthCredentialRequest, request: Request, response:
"detail": "注册并登录成功,已赠送试用 Credits,请保存重置码",
"user": {"id": user["id"], "username": user["username"]},
"reset_code": user.get("reset_code", ""),
+ "is_new_user": True,
+ "redirect_to": "/guide",
}
@app.post("/api/auth/login")
async def auth_login(req: AuthCredentialRequest, request: Request, response: Response):
ip = _client_ip(request)
- if _hit_limit(_login_rate, f"ip:{ip}", limit=20, window_sec=600):
+ if _hit_limit(_login_rate, f"ip:{ip}", limit=60, window_sec=300):
return {"ok": False, "detail": "登录过于频繁,请稍后再试"}
- if _hit_limit(_login_rate, f"user:{(req.username or '').strip().lower()}", limit=12, window_sec=600):
+ if _hit_limit(_login_rate, f"user:{(req.username or '').strip().lower()}", limit=30, window_sec=300):
return {"ok": False, "detail": "该账户登录尝试过多,请稍后再试"}
try:
user = users.verify_user((req.username or "").strip(), req.password or "")
@@ -565,6 +768,21 @@ async def auth_forgot_password_reset(req: ForgotPasswordResetRequest):
return {"ok": True, "detail": "密码重置成功,请返回登录页重新登录"}
+@app.post("/api/auth/reset-code/regenerate")
+async def auth_regenerate_reset_code(req: ResetCodeRegenerateRequest, request: Request):
+ user = _require_user(request)
+ if not user:
+ return {"ok": False, "detail": "请先登录"}
+ new_code = users.regenerate_reset_code(user["id"], req.password or "")
+ if not new_code:
+ return {"ok": False, "detail": "当前密码错误,无法生成新重置码"}
+ return {
+ "ok": True,
+ "detail": "新重置码已生成,仅展示一次,请立即保存",
+ "reset_code": new_code,
+ }
+
+
@app.post("/api/auth/password/change")
async def auth_change_password(req: ChangePasswordRequest, request: Request, response: Response):
user = _require_user(request)
diff --git a/app/schemas.py b/app/schemas.py
index 68382c9..92a866d 100644
--- a/app/schemas.py
+++ b/app/schemas.py
@@ -161,6 +161,10 @@ class UserProfileUpdateRequest(BaseModel):
shipping_address: str = ""
+class ResetCodeRegenerateRequest(BaseModel):
+ password: str
+
+
class PosterGenerateRequest(BaseModel):
title: str = ""
summary: str = ""
diff --git a/app/services/user_store.py b/app/services/user_store.py
index e906a5c..f59a6cc 100644
--- a/app/services/user_store.py
+++ b/app/services/user_store.py
@@ -454,6 +454,34 @@ class UserStore:
)
return True
+ def regenerate_reset_code(self, user_id: int, password: str) -> 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 }} · 超级管理后台
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
-
+