feat: align portal center panel with side cards + redesign login page

- Portal: wrap .panel-head in matching card (border, radius, shadow) with
  cyan->purple left accent bar; h2 28px -> 20px; description right-aligned
  with vertical divider; top edge now aligns with side-menu & links-panel
- Login: switch from absolute-positioned icons to el-input prefix slot;
  layout brand row (logo + title + uppercase subtitle); add form-title
  section with gradient bar; consistent cyan focus; gradient button;
  dashed top divider for back-link; card width 420 -> 460
- Add .gitignore (node_modules, dist, __pycache__, .env, etc.)
This commit is contained in:
Hermes
2026-07-22 11:12:19 +08:00
commit 5eecbecd7f
32 changed files with 7371 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# intentionally empty — prevents circular imports (see app factory)
+427
View File
@@ -0,0 +1,427 @@
"""Admin CRUD API — all endpoints require a valid JWT."""
import os
import uuid
from werkzeug.utils import secure_filename
from flask import Blueprint, jsonify, request, current_app
from auth import admin_required
from config import UPLOAD_URL_PREFIX
from extensions import db
from models import Category, Option, OptionRow, Resource, SideLink, SiteSetting, User
bp = Blueprint("admin", __name__, url_prefix="/api/admin")
RESOURCE_TYPES = {"command", "link", "text", "file"}
ALLOWED_EXTENSIONS = {"txt", "pdf", "png", "jpg", "jpeg", "gif", "zip", "tar", "gz", "sh", "py", "json", "yaml", "yml", "deb", "rpm", "iso"}
def allowed_file(filename):
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
# ---------------------------------------------------------------------------
# Dashboard stats
# ---------------------------------------------------------------------------
@bp.route("/stats")
@admin_required
def stats():
return jsonify({
"categories": Category.query.count(),
"resources": Resource.query.count(),
"active_resources": Resource.query.filter_by(is_active=True).count(),
"links": SideLink.query.count(),
"options": Option.query.count(),
})
# ---------------------------------------------------------------------------
# Categories
# ---------------------------------------------------------------------------
@bp.route("/categories")
@admin_required
def list_categories():
cats = Category.query.order_by(Category.sort_order, Category.id).all()
return jsonify([c.to_dict() for c in cats])
@bp.route("/categories", methods=["POST"])
@admin_required
def create_category():
data = request.get_json(silent=True) or {}
if not (data.get("name") or "").strip():
return jsonify({"error": "分类名称不能为空"}), 400
max_order = db.session.query(db.func.max(Category.sort_order)).scalar() or 0
cat = Category(
name=data["name"].strip(),
icon=data.get("icon") or "📁",
description=data.get("description") or "",
sort_order=data.get("sort_order", max_order + 1),
is_active=data.get("is_active", True),
)
db.session.add(cat)
db.session.commit()
return jsonify(cat.to_dict()), 201
@bp.route("/categories/<int:cid>", methods=["PUT"])
@admin_required
def update_category(cid):
cat = Category.query.get_or_404(cid)
data = request.get_json(silent=True) or {}
if "name" in data:
if not (data["name"] or "").strip():
return jsonify({"error": "分类名称不能为空"}), 400
cat.name = data["name"].strip()
for f in ("icon", "description", "sort_order", "is_active"):
if f in data:
setattr(cat, f, data[f])
db.session.commit()
return jsonify(cat.to_dict())
@bp.route("/categories/<int:cid>", methods=["DELETE"])
@admin_required
def delete_category(cid):
cat = Category.query.get_or_404(cid)
db.session.delete(cat)
db.session.commit()
return jsonify({"message": "已删除"})
@bp.route("/categories/<int:cid>/move", methods=["POST"])
@admin_required
def move_category(cid):
cat = Category.query.get_or_404(cid)
direction = (request.get_json(silent=True) or {}).get("direction", "up")
ordered = Category.query.order_by(Category.sort_order, Category.id).all()
idx = next(i for i, c in enumerate(ordered) if c.id == cid)
target = idx - 1 if direction == "up" else idx + 1
if 0 <= target < len(ordered):
ordered[idx].sort_order, ordered[target].sort_order = (
ordered[target].sort_order, ordered[idx].sort_order)
# break ties if both had the same sort_order
if ordered[idx].sort_order == ordered[target].sort_order:
ordered[target].sort_order += 1 if direction == "up" else -1
db.session.commit()
return jsonify({"message": "ok"})
# ---------------------------------------------------------------------------
# Option rows & options
# ---------------------------------------------------------------------------
@bp.route("/categories/<int:cid>/rows", methods=["POST"])
@admin_required
def create_row(cid):
Category.query.get_or_404(cid)
data = request.get_json(silent=True) or {}
if not (data.get("title") or "").strip():
return jsonify({"error": "行标题不能为空"}), 400
max_order = db.session.query(db.func.max(OptionRow.sort_order)).filter_by(category_id=cid).scalar() or 0
row = OptionRow(category_id=cid, title=data["title"].strip(),
sort_order=data.get("sort_order", max_order + 1))
db.session.add(row)
db.session.commit()
return jsonify(row.to_dict()), 201
@bp.route("/rows/<int:rid>", methods=["PUT"])
@admin_required
def update_row(rid):
row = OptionRow.query.get_or_404(rid)
data = request.get_json(silent=True) or {}
if "title" in data:
row.title = data["title"].strip()
if "sort_order" in data:
row.sort_order = data["sort_order"]
db.session.commit()
return jsonify(row.to_dict())
@bp.route("/rows/<int:rid>", methods=["DELETE"])
@admin_required
def delete_row(rid):
row = OptionRow.query.get_or_404(rid)
db.session.delete(row)
db.session.commit()
return jsonify({"message": "已删除"})
@bp.route("/rows/<int:rid>/options", methods=["POST"])
@admin_required
def create_option(rid):
OptionRow.query.get_or_404(rid)
data = request.get_json(silent=True) or {}
if not (data.get("label") or "").strip():
return jsonify({"error": "选项名称不能为空"}), 400
max_order = db.session.query(db.func.max(Option.sort_order)).filter_by(row_id=rid).scalar() or 0
opt = Option(row_id=rid, label=data["label"].strip(),
icon=data.get("icon") or "",
sort_order=data.get("sort_order", max_order + 1))
db.session.add(opt)
db.session.commit()
return jsonify(opt.to_dict()), 201
@bp.route("/options/<int:oid>", methods=["PUT"])
@admin_required
def update_option(oid):
opt = Option.query.get_or_404(oid)
data = request.get_json(silent=True) or {}
for f in ("label", "icon", "sort_order"):
if f in data:
setattr(opt, f, data[f])
db.session.commit()
return jsonify(opt.to_dict())
@bp.route("/options/<int:oid>", methods=["DELETE"])
@admin_required
def delete_option(oid):
opt = Option.query.get_or_404(oid)
db.session.delete(opt)
db.session.commit()
return jsonify({"message": "已删除"})
# ---------------------------------------------------------------------------
# Resources
# ---------------------------------------------------------------------------
@bp.route("/resources")
@admin_required
def list_resources():
q = Resource.query
cid = request.args.get("category_id", type=int)
if cid:
q = q.filter_by(category_id=cid)
kw = (request.args.get("keyword") or "").strip()
if kw:
q = q.filter(Resource.title.like(f"%{kw}%"))
items = q.order_by(Resource.category_id, Resource.sort_order, Resource.id).all()
return jsonify([r.to_dict() for r in items])
@bp.route("/resources", methods=["POST"])
@admin_required
def create_resource():
data = request.get_json(silent=True) or {}
err = _validate_resource(data, require_category=True)
if err:
return jsonify({"error": err}), 400
max_order = db.session.query(db.func.max(Resource.sort_order)).filter_by(
category_id=data["category_id"]).scalar() or 0
res = Resource(
category_id=data["category_id"],
title=data["title"].strip(),
type=data.get("type", "command"),
content=data.get("content") or "",
url=data.get("url") or "",
description=data.get("description") or "",
sort_order=data.get("sort_order", max_order + 1),
is_active=data.get("is_active", True),
)
_apply_option_tags(res, data.get("option_ids") or [])
db.session.add(res)
db.session.commit()
return jsonify(res.to_dict()), 201
@bp.route("/resources/<int:rid>", methods=["PUT"])
@admin_required
def update_resource(rid):
res = Resource.query.get_or_404(rid)
data = request.get_json(silent=True) or {}
err = _validate_resource(data, require_category=False)
if err:
return jsonify({"error": err}), 400
for f in ("category_id", "title", "type", "content", "url",
"description", "sort_order", "is_active"):
if f in data:
value = data[f]
if f == "title":
value = value.strip()
setattr(res, f, value)
if "option_ids" in data:
_apply_option_tags(res, data["option_ids"] or [])
db.session.commit()
return jsonify(res.to_dict())
@bp.route("/resources/<int:rid>", methods=["DELETE"])
@admin_required
def delete_resource(rid):
res = Resource.query.get_or_404(rid)
db.session.delete(res)
db.session.commit()
return jsonify({"message": "已删除"})
def _validate_resource(data, require_category):
if require_category and not data.get("category_id"):
return "必须选择所属分类"
if require_category and not (data.get("title") or "").strip():
return "标题不能为空"
if "title" in data and not (data["title"] or "").strip():
return "标题不能为空"
rtype = data.get("type")
if rtype is not None and rtype not in RESOURCE_TYPES:
return f"未知资源类型: {rtype}"
return None
def _apply_option_tags(res, option_ids):
opts = Option.query.filter(Option.id.in_(option_ids)).all() if option_ids else []
res.options = opts
# ---------------------------------------------------------------------------
# Side links
# ---------------------------------------------------------------------------
@bp.route("/links")
@admin_required
def list_links():
return jsonify([l.to_dict() for l in
SideLink.query.order_by(SideLink.sort_order, SideLink.id).all()])
@bp.route("/links", methods=["POST"])
@admin_required
def create_link():
data = request.get_json(silent=True) or {}
if not (data.get("title") or "").strip() or not (data.get("url") or "").strip():
return jsonify({"error": "标题和链接地址不能为空"}), 400
max_order = db.session.query(db.func.max(SideLink.sort_order)).scalar() or 0
link = SideLink(
title=data["title"].strip(), url=data["url"].strip(),
group_name=data.get("group_name") or "其他链接",
sort_order=data.get("sort_order", max_order + 1),
is_active=data.get("is_active", True),
)
db.session.add(link)
db.session.commit()
return jsonify(link.to_dict()), 201
@bp.route("/links/<int:lid>", methods=["PUT"])
@admin_required
def update_link(lid):
link = SideLink.query.get_or_404(lid)
data = request.get_json(silent=True) or {}
for f in ("title", "url", "group_name", "sort_order", "is_active"):
if f in data:
setattr(link, f, data[f])
db.session.commit()
return jsonify(link.to_dict())
@bp.route("/links/<int:lid>", methods=["DELETE"])
@admin_required
def delete_link(lid):
link = SideLink.query.get_or_404(lid)
db.session.delete(link)
db.session.commit()
return jsonify({"message": "已删除"})
# ---------------------------------------------------------------------------
# Site settings
# ---------------------------------------------------------------------------
@bp.route("/settings", methods=["GET"])
@admin_required
def get_settings():
return jsonify({s.key: s.value for s in SiteSetting.query.all()})
@bp.route("/settings", methods=["PUT"])
@admin_required
def update_settings():
data = request.get_json(silent=True) or {}
for key, value in data.items():
s = SiteSetting.query.get(key)
if s:
s.value = value if value is not None else ""
else:
db.session.add(SiteSetting(key=key, value=value or ""))
db.session.commit()
return jsonify({"message": "已保存"})
# ---------------------------------------------------------------------------
# Admin users (minimal: list + reset password)
# ---------------------------------------------------------------------------
@bp.route("/users")
@admin_required
def list_users():
return jsonify([{"id": u.id, "username": u.username} for u in User.query.all()])
@bp.route("/users", methods=["POST"])
@admin_required
def create_user():
data = request.get_json(silent=True) or {}
username = (data.get("username") or "").strip()
password = (data.get("password") or "").strip()
if not username or len(username) < 3:
return jsonify({"error": "用户名至少3个字符"}), 400
if not password or len(password) < 6:
return jsonify({"error": "密码至少6个字符"}), 400
if User.query.filter_by(username=username).first():
return jsonify({"error": "用户名已存在"}), 400
u = User(username=username)
u.set_password(password)
db.session.add(u)
db.session.commit()
return jsonify({"id": u.id, "username": u.username}), 201
@bp.route("/users/<int:uid>/password", methods=["PUT"])
@admin_required
def reset_user_password(uid):
u = User.query.get_or_404(uid)
data = request.get_json(silent=True) or {}
password = (data.get("password") or "").strip()
if not password or len(password) < 6:
return jsonify({"error": "密码至少6个字符"}), 400
u.set_password(password)
db.session.commit()
return jsonify({"message": "密码已重置"})
@bp.route("/users/<int:uid>", methods=["DELETE"])
@admin_required
def delete_user(uid):
u = User.query.get_or_404(uid)
if u.username == "admin":
return jsonify({"error": "不能删除内置 admin 用户"}), 400
db.session.delete(u)
db.session.commit()
return jsonify({"message": "用户已删除"})
# ---------------------------------------------------------------------------
# File upload
# ---------------------------------------------------------------------------
@bp.route("/upload", methods=["POST"])
@admin_required
def upload_file():
if "file" not in request.files:
return jsonify({"error": "未找到文件"}), 400
f = request.files["file"]
if not f or not f.filename:
return jsonify({"error": "未选择文件"}), 400
if not allowed_file(f.filename):
return jsonify({"error": "不支持的文件类型"}), 400
filename = secure_filename(f.filename)
# Add unique prefix
name, ext = os.path.splitext(filename)
unique_name = f"{name}_{uuid.uuid4().hex[:8]}{ext}"
os.makedirs(current_app.config["UPLOAD_FOLDER"], exist_ok=True)
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], unique_name)
f.save(filepath)
file_url = f"{UPLOAD_URL_PREFIX}/{unique_name}"
return jsonify({
"url": file_url,
"filename": filename,
"size": os.path.getsize(filepath)
}), 201
+44
View File
@@ -0,0 +1,44 @@
"""Auth API."""
from flask import Blueprint, jsonify, request
from auth import get_current_user, issue_token
from models import User
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@bp.route("/login", methods=["POST"])
def login():
data = request.get_json(silent=True) or {}
username = (data.get("username") or "").strip()
password = data.get("password") or ""
user = User.query.filter_by(username=username).first()
if not user or not user.check_password(password):
return jsonify({"error": "用户名或密码错误"}), 401
return jsonify({"token": issue_token(user), "username": user.username})
@bp.route("/me")
def me():
user = get_current_user()
if not user:
return jsonify({"error": "未登录"}), 401
return jsonify({"username": user.username})
@bp.route("/password", methods=["POST"])
def change_password():
user = get_current_user()
if not user:
return jsonify({"error": "未登录"}), 401
data = request.get_json(silent=True) or {}
old = data.get("old_password") or ""
new = data.get("new_password") or ""
if not user.check_password(old):
return jsonify({"error": "原密码错误"}), 400
if len(new) < 6:
return jsonify({"error": "新密码至少 6 位"}), 400
from extensions import db
user.set_password(new)
db.session.commit()
return jsonify({"message": "密码已修改"})
+94
View File
@@ -0,0 +1,94 @@
"""Public portal API — no auth required."""
from flask import Blueprint, jsonify, request
from models import Category, Option, Resource, SideLink, SiteSetting
bp = Blueprint("portal", __name__, url_prefix="/api/portal")
@bp.route("/bootstrap")
def bootstrap():
"""Everything the portal page needs in one request."""
settings = {s.key: s.value for s in SiteSetting.query.all()}
categories = (
Category.query.filter_by(is_active=True)
.order_by(Category.sort_order, Category.id)
.all()
)
links = (
SideLink.query.filter_by(is_active=True)
.order_by(SideLink.sort_order, SideLink.id)
.all()
)
return jsonify({
"settings": settings,
"categories": [c.to_dict() for c in categories],
"links": [l.to_dict() for l in links],
})
@bp.route("/categories/<int:cid>/resources")
def category_resources(cid):
"""Resources for a category, faceted by selected option ids."""
category = Category.query.get_or_404(cid)
selected = []
raw = request.args.get("option_ids", "")
keyword = request.args.get("keyword", "").strip()
if raw.strip():
try:
selected = [int(x) for x in raw.split(",") if x.strip()]
except ValueError:
selected = []
query = Resource.query.filter_by(category_id=cid, is_active=True)
if keyword:
query = query.filter(
Resource.title.like(f"%{keyword}%") |
Resource.description.like(f"%{keyword}%") |
Resource.content.like(f"%{keyword}%")
)
resources = query.order_by(Resource.sort_order, Resource.id).all()
if selected:
chosen = Option.query.filter(Option.id.in_(selected)).all()
by_row = {}
for o in chosen:
by_row.setdefault(o.row_id, []).append(o.id)
def matches(res):
tagged_ids = {o.id for o in res.options}
tagged_row_ids = {o.row_id for o in res.options}
for row_id, opt_ids in by_row.items():
if row_id not in tagged_row_ids:
continue # untagged in this row -> applies to all
if not tagged_ids.intersection(opt_ids):
return False
return True
resources = [r for r in resources if matches(r)]
return jsonify({
"category": category.to_dict(),
"resources": [r.to_dict() for r in resources],
"selected_option_ids": selected,
})
@bp.route("/search")
def global_search():
"""Global search across all resources."""
keyword = request.args.get("keyword", "").strip()
if not keyword or len(keyword) < 2:
return jsonify({"results": [], "keyword": keyword})
results = Resource.query.filter_by(is_active=True).filter(
Resource.title.like(f"%{keyword}%") |
Resource.description.like(f"%{keyword}%") |
Resource.content.like(f"%{keyword}%")
).order_by(Resource.category_id, Resource.sort_order).limit(50).all()
return jsonify({
"keyword": keyword,
"count": len(results),
"results": [r.to_dict() for r in results]
})