fix 修复bug
This commit is contained in:
@@ -78,3 +78,5 @@ CREDITS_RECHARGE_PACKAGE_CREDITS=1500
|
|||||||
# 下单入口:POST https://你的域名/api/pay/wechat/
|
# 下单入口:POST https://你的域名/api/pay/wechat/
|
||||||
# 回调入口:POST https://你的域名/api/pay/wechat/backcall
|
# 回调入口:POST https://你的域名/api/pay/wechat/backcall
|
||||||
SHOP_BACKEND_CALLBACK_TOKEN=
|
SHOP_BACKEND_CALLBACK_TOKEN=
|
||||||
|
# 超级管理后台口令(访问 /admin?token=你的口令)
|
||||||
|
SUPER_ADMIN_TOKEN=
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ class Settings(BaseSettings):
|
|||||||
platform_openai_max_retries: int = Field(default=0, alias="PLATFORM_OPENAI_MAX_RETRIES")
|
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_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")
|
shop_backend_callback_token: str = Field(default="", alias="SHOP_BACKEND_CALLBACK_TOKEN")
|
||||||
|
super_admin_token: str = Field(default="", alias="SUPER_ADMIN_TOKEN")
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
240
app/main.py
240
app/main.py
@@ -4,6 +4,7 @@ import logging
|
|||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import sqlite3
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -33,6 +34,7 @@ from app.schemas import (
|
|||||||
ForgotPasswordResetRequest,
|
ForgotPasswordResetRequest,
|
||||||
IMPublishRequest,
|
IMPublishRequest,
|
||||||
PosterGenerateRequest,
|
PosterGenerateRequest,
|
||||||
|
ResetCodeRegenerateRequest,
|
||||||
RewriteRequest,
|
RewriteRequest,
|
||||||
WechatCoverUploadByUrlRequest,
|
WechatCoverUploadByUrlRequest,
|
||||||
WechatCoverGenerateRequest,
|
WechatCoverGenerateRequest,
|
||||||
@@ -80,6 +82,7 @@ _login_rate: dict[str, list[float]] = {}
|
|||||||
_challenge_pool: dict[str, dict] = {}
|
_challenge_pool: dict[str, dict] = {}
|
||||||
USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{4,24}$")
|
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}$")
|
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:
|
def _session_ttl(remember_me: bool) -> int:
|
||||||
@@ -100,6 +103,17 @@ def _require_user(request: Request) -> dict | None:
|
|||||||
return u
|
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:
|
def _client_ip(request: Request) -> str:
|
||||||
xff = (request.headers.get("x-forwarded-for") or "").strip()
|
xff = (request.headers.get("x-forwarded-for") or "").strip()
|
||||||
if xff:
|
if xff:
|
||||||
@@ -162,18 +176,66 @@ def _validate_username_password(username: str, password: str) -> tuple[bool, str
|
|||||||
|
|
||||||
|
|
||||||
def _platform_model_cfg() -> dict:
|
def _platform_model_cfg() -> dict:
|
||||||
|
override = _get_platform_model_override()
|
||||||
return {
|
return {
|
||||||
"api_key": settings.platform_openai_api_key or "",
|
"api_key": str(override.get("api_key") or settings.platform_openai_api_key or ""),
|
||||||
"base_url": settings.platform_openai_base_url or "",
|
"base_url": str(override.get("base_url") or settings.platform_openai_base_url or ""),
|
||||||
"model": settings.platform_openai_model,
|
"model": str(override.get("model") or settings.platform_openai_model),
|
||||||
"image_model": settings.platform_openai_image_model,
|
"image_model": str(override.get("image_model") or settings.platform_openai_image_model),
|
||||||
"timeout_sec": float(settings.platform_openai_timeout),
|
"timeout_sec": float(override.get("timeout_sec") or settings.platform_openai_timeout),
|
||||||
"max_output_tokens": int(settings.platform_openai_max_output_tokens),
|
"max_output_tokens": int(override.get("max_output_tokens") or settings.platform_openai_max_output_tokens),
|
||||||
"max_retries": int(settings.platform_openai_max_retries),
|
"max_retries": int(override.get("max_retries") or settings.platform_openai_max_retries),
|
||||||
"model_name": "平台模型",
|
"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]:
|
def _select_model_cfg(user_id: int, prefer_vip: bool = True) -> tuple[dict | None, str]:
|
||||||
vip = users.get_vip_status(user_id)
|
vip = users.get_vip_status(user_id)
|
||||||
now = int(time.time())
|
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})
|
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)
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
async def favicon():
|
async def favicon():
|
||||||
# 浏览器通常请求 /favicon.ico,统一跳转到静态图标
|
# 浏览器通常请求 /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")
|
@app.get("/api/auth/me")
|
||||||
async def auth_me(request: Request):
|
async def auth_me(request: Request):
|
||||||
user = _current_user(request)
|
user = _current_user(request)
|
||||||
@@ -470,9 +671,9 @@ async def auth_challenge():
|
|||||||
@app.post("/api/auth/register")
|
@app.post("/api/auth/register")
|
||||||
async def auth_register(req: AuthCredentialRequest, request: Request, response: Response):
|
async def auth_register(req: AuthCredentialRequest, request: Request, response: Response):
|
||||||
ip = _client_ip(request)
|
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": "请求过于频繁,请稍后再试"}
|
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": "该用户名操作过于频繁,请稍后再试"}
|
return {"ok": False, "detail": "该用户名操作过于频繁,请稍后再试"}
|
||||||
username = (req.username or "").strip()
|
username = (req.username or "").strip()
|
||||||
password = req.password or ""
|
password = req.password or ""
|
||||||
@@ -505,15 +706,17 @@ async def auth_register(req: AuthCredentialRequest, request: Request, response:
|
|||||||
"detail": "注册并登录成功,已赠送试用 Credits,请保存重置码",
|
"detail": "注册并登录成功,已赠送试用 Credits,请保存重置码",
|
||||||
"user": {"id": user["id"], "username": user["username"]},
|
"user": {"id": user["id"], "username": user["username"]},
|
||||||
"reset_code": user.get("reset_code", ""),
|
"reset_code": user.get("reset_code", ""),
|
||||||
|
"is_new_user": True,
|
||||||
|
"redirect_to": "/guide",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/login")
|
@app.post("/api/auth/login")
|
||||||
async def auth_login(req: AuthCredentialRequest, request: Request, response: Response):
|
async def auth_login(req: AuthCredentialRequest, request: Request, response: Response):
|
||||||
ip = _client_ip(request)
|
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": "登录过于频繁,请稍后再试"}
|
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": "该账户登录尝试过多,请稍后再试"}
|
return {"ok": False, "detail": "该账户登录尝试过多,请稍后再试"}
|
||||||
try:
|
try:
|
||||||
user = users.verify_user((req.username or "").strip(), req.password or "")
|
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": "密码重置成功,请返回登录页重新登录"}
|
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")
|
@app.post("/api/auth/password/change")
|
||||||
async def auth_change_password(req: ChangePasswordRequest, request: Request, response: Response):
|
async def auth_change_password(req: ChangePasswordRequest, request: Request, response: Response):
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ class UserProfileUpdateRequest(BaseModel):
|
|||||||
shipping_address: str = ""
|
shipping_address: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ResetCodeRegenerateRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class PosterGenerateRequest(BaseModel):
|
class PosterGenerateRequest(BaseModel):
|
||||||
title: str = ""
|
title: str = ""
|
||||||
summary: str = ""
|
summary: str = ""
|
||||||
|
|||||||
@@ -454,6 +454,34 @@ class UserStore:
|
|||||||
)
|
)
|
||||||
return True
|
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:
|
def create_session(self, user_id: int, ttl_seconds: int = 7 * 24 * 3600) -> str:
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
token_hash = self._hash_token(token)
|
token_hash = self._hash_token(token)
|
||||||
|
|||||||
248
app/static/admin.js
Normal file
248
app/static/admin.js
Normal file
@@ -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 = '<tr><td colspan="4" class="muted">暂无记录</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = rows
|
||||||
|
.map((r) => {
|
||||||
|
const status = Number(r.deleted_at || 0) > 0 ? "已注销" : "正常";
|
||||||
|
return `<tr>
|
||||||
|
<td>${Number(r.id || 0)}</td>
|
||||||
|
<td>${String(r.username || "")}</td>
|
||||||
|
<td>${fmtTime(r.created_at)}</td>
|
||||||
|
<td>${status}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.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 = "<th>无列信息</th>";
|
||||||
|
body.innerHTML = '<tr><td class="muted">当前页没有数据</td></tr>';
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -108,7 +108,12 @@ if (registerBtn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setStatus("注册成功,正在跳转...");
|
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) {
|
} catch (e) {
|
||||||
setStatus(e.message || "请求异常", true);
|
setStatus(e.message || "请求异常", true);
|
||||||
await refreshChallenge();
|
await refreshChallenge();
|
||||||
|
|||||||
@@ -17,7 +17,18 @@
|
|||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const enabled = Boolean(vip.vip_enabled);
|
const enabled = Boolean(vip.vip_enabled);
|
||||||
const hasActiveSubscription = Number(vip.cycle_started_at || 0) > 0 && Number(vip.cycle_expires_at || 0) > now;
|
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 {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ const bindBtn = $("bindBtn");
|
|||||||
const deleteWechatBtn = $("deleteWechatBtn");
|
const deleteWechatBtn = $("deleteWechatBtn");
|
||||||
const logoutBtn = $("logoutBtn");
|
const logoutBtn = $("logoutBtn");
|
||||||
const changePwdBtn = $("changePwdBtn");
|
const changePwdBtn = $("changePwdBtn");
|
||||||
|
const regenResetCodeBtn = $("regenResetCodeBtn");
|
||||||
const deleteAccountBtn = $("deleteAccountBtn");
|
const deleteAccountBtn = $("deleteAccountBtn");
|
||||||
const modelSelect = $("modelSelect");
|
const modelSelect = $("modelSelect");
|
||||||
const saveModelBtn = $("saveModelBtn");
|
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) {
|
if (deleteAccountBtn) {
|
||||||
deleteAccountBtn.addEventListener("click", async () => {
|
deleteAccountBtn.addEventListener("click", async () => {
|
||||||
const pwd = ($("deletePassword") && $("deletePassword").value) || "";
|
const pwd = ($("deletePassword") && $("deletePassword").value) || "";
|
||||||
|
|||||||
151
app/templates/admin.html
Normal file
151
app/templates/admin.html
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{{ app_name }} · 超级管理后台</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css?v=20260428z" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="app-shell">
|
||||||
|
<aside class="side-nav">
|
||||||
|
<div class="side-brand">{{ app_name }}</div>
|
||||||
|
<nav>
|
||||||
|
<a href="/">内容生产</a>
|
||||||
|
<a href="/settings">账号与模型</a>
|
||||||
|
<a href="/billing">账单中心</a>
|
||||||
|
<a href="/upgrade">升级订阅</a>
|
||||||
|
<a href="/profile">个人中心</a>
|
||||||
|
<a href="/guide">新手指引</a>
|
||||||
|
<a class="active" href="/admin?token={{ request.query_params.get('token','') }}">超级管理</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="main-wrap">
|
||||||
|
<header class="topbar topbar-compact">
|
||||||
|
<h1>超级管理后台</h1>
|
||||||
|
<span class="global-mode-hint mode-badge">平台模型与数据库管理</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel-scroll">
|
||||||
|
<article class="settings-card">
|
||||||
|
<h3>用户注册总览</h3>
|
||||||
|
<div class="grid-2" style="margin-top: 12px;">
|
||||||
|
<label>
|
||||||
|
总用户数
|
||||||
|
<input id="adminTotalUsers" type="text" value="0" readonly />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
当前有效用户
|
||||||
|
<input id="adminActiveUsers" type="text" value="0" readonly />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
今日新增
|
||||||
|
<input id="adminTodayUsers" type="text" value="0" readonly />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
已注销用户
|
||||||
|
<input id="adminDeletedUsers" type="text" value="0" readonly />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<button id="adminUserRefreshBtn" class="button secondary">刷新用户统计</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="settings-card">
|
||||||
|
<h3>最近注册记录</h3>
|
||||||
|
<div class="billing-table-wrap" style="margin-top: 12px;">
|
||||||
|
<table class="billing-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>用户名</th>
|
||||||
|
<th>注册时间</th>
|
||||||
|
<th>状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="adminRecentUsersRows">
|
||||||
|
<tr><td colspan="4" class="muted">暂无记录</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="settings-card">
|
||||||
|
<h3>平台模型配置</h3>
|
||||||
|
<div class="grid-2" style="margin-top: 12px;">
|
||||||
|
<label>
|
||||||
|
API Key
|
||||||
|
<input id="platformApiKey" type="password" placeholder="sk-..." />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Base URL
|
||||||
|
<input id="platformBaseUrl" type="text" placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
文本模型
|
||||||
|
<input id="platformTextModel" type="text" placeholder="qwen-plus" list="platformTextModelOptions" />
|
||||||
|
<datalist id="platformTextModelOptions"></datalist>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
生图模型
|
||||||
|
<input id="platformImageModel" type="text" placeholder="wanx2.0-t2i-turbo" list="platformImageModelOptions" />
|
||||||
|
<datalist id="platformImageModelOptions"></datalist>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
超时(秒)
|
||||||
|
<input id="platformTimeoutSec" type="number" min="5" max="600" value="120" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
最大输出 tokens
|
||||||
|
<input id="platformMaxOutputTokens" type="number" min="256" max="65535" value="8192" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<button id="platformSaveBtn" class="button">保存平台模型配置</button>
|
||||||
|
<button id="platformReloadBtn" class="button secondary">重新加载</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="settings-card">
|
||||||
|
<h3>数据库总览</h3>
|
||||||
|
<div class="grid-2" style="margin-top: 12px;">
|
||||||
|
<label>
|
||||||
|
选择数据表
|
||||||
|
<select id="adminTableSelect" class="ui-select"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
每页条数
|
||||||
|
<input id="adminLimit" type="number" min="1" max="500" value="100" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<button id="adminPrevBtn" class="button secondary">上一页</button>
|
||||||
|
<button id="adminNextBtn" class="button secondary">下一页</button>
|
||||||
|
<button id="adminRefreshBtn" class="button">刷新</button>
|
||||||
|
</div>
|
||||||
|
<p id="adminStatus" class="muted" style="margin-top: 10px;">准备就绪</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="settings-card">
|
||||||
|
<h3>表数据</h3>
|
||||||
|
<div class="billing-table-wrap" style="margin-top: 12px;">
|
||||||
|
<table class="billing-table" id="adminTableData">
|
||||||
|
<thead>
|
||||||
|
<tr id="adminHeadRow"><th>暂无数据</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="adminBodyRows">
|
||||||
|
<tr><td class="muted">请选择一个表后加载数据</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/static/ui-dialog.js?v=20260428p"></script>
|
||||||
|
<script src="/static/admin.js?v=20260428b"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -137,6 +137,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="actions-inline">
|
<div class="actions-inline">
|
||||||
<a class="subtle-link" href="/auth/forgot">忘记密码提示</a>
|
<a class="subtle-link" href="/auth/forgot">忘记密码提示</a>
|
||||||
|
<button id="regenResetCodeBtn" class="secondary topbar-btn" type="button">重新生成重置码</button>
|
||||||
<button id="changePwdBtn" class="secondary topbar-btn" type="button">修改密码</button>
|
<button id="changePwdBtn" class="secondary topbar-btn" type="button">修改密码</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
@@ -160,6 +161,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<script src="/static/ui-dialog.js?v=20260428a"></script>
|
<script src="/static/ui-dialog.js?v=20260428a"></script>
|
||||||
<script src="/static/mode-hint.js?v=20260428a"></script>
|
<script src="/static/mode-hint.js?v=20260428a"></script>
|
||||||
<script src="/static/settings.js?v=20260428r"></script>
|
<script src="/static/settings.js?v=20260428s"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user