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:
@@ -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
|
||||
Reference in New Issue
Block a user