"""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/", 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/", 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//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//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/", 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/", 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//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/", 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/", 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/", 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/", 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/", 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/", 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//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/", 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