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,57 @@
|
||||
"""App factory. Serves API + (in production) the built Vue frontend."""
|
||||
import os
|
||||
|
||||
from flask import Flask, send_from_directory
|
||||
|
||||
from config import BASE_DIR, Config, UPLOAD_DIR, UPLOAD_URL_PREFIX
|
||||
from extensions import db
|
||||
|
||||
DIST_DIR = os.path.join(BASE_DIR, "..", "frontend", "dist")
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__, static_folder=None)
|
||||
app.config.from_object(Config)
|
||||
|
||||
os.makedirs(os.path.join(BASE_DIR, "instance"), exist_ok=True)
|
||||
db.init_app(app)
|
||||
db.app = app
|
||||
|
||||
from routes import auth as auth_routes
|
||||
from routes import portal as portal_routes
|
||||
from routes import admin as admin_routes
|
||||
app.register_blueprint(auth_routes.bp)
|
||||
app.register_blueprint(portal_routes.bp)
|
||||
app.register_blueprint(admin_routes.bp)
|
||||
|
||||
@app.route("/api/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
# Serve uploaded files
|
||||
os.makedirs(Config.UPLOAD_FOLDER, exist_ok=True)
|
||||
@app.route(UPLOAD_URL_PREFIX + "/<path:filename>")
|
||||
def uploaded_file(filename):
|
||||
return send_from_directory(Config.UPLOAD_FOLDER, filename)
|
||||
|
||||
# ---- Serve built Vue SPA in production (after `npm run build`) ----
|
||||
if os.path.isdir(DIST_DIR):
|
||||
@app.route("/", defaults={"path": ""})
|
||||
@app.route("/<path:path>")
|
||||
def spa(path):
|
||||
full = os.path.join(DIST_DIR, path)
|
||||
if path and os.path.isfile(full):
|
||||
return send_from_directory(DIST_DIR, path)
|
||||
return send_from_directory(DIST_DIR, "index.html")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
from seed import seed_all
|
||||
seed_all()
|
||||
app.run(host="0.0.0.0", port=8090, debug=False)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Auth helpers: JWT issue/verify + admin guard."""
|
||||
import functools
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
from flask import current_app, jsonify, request
|
||||
|
||||
from models import User
|
||||
|
||||
|
||||
def issue_token(user):
|
||||
payload = {
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=current_app.config["JWT_EXPIRE_HOURS"]),
|
||||
"iat": datetime.now(timezone.utc),
|
||||
}
|
||||
return jwt.encode(payload, current_app.config["SECRET_KEY"], algorithm="HS256")
|
||||
|
||||
|
||||
def decode_token(token):
|
||||
try:
|
||||
return jwt.decode(token, current_app.config["SECRET_KEY"], algorithms=["HS256"])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user():
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
return None
|
||||
payload = decode_token(auth[7:])
|
||||
if not payload:
|
||||
return None
|
||||
return User.query.get(payload["user_id"])
|
||||
|
||||
|
||||
def admin_required(fn):
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({"error": "未登录或登录已过期"}), 401
|
||||
return fn(*args, **kwargs)
|
||||
return wrapper
|
||||
@@ -0,0 +1,17 @@
|
||||
import os
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
UPLOAD_DIR = os.environ.get("OPS_UPLOAD_DIR", os.path.join(BASE_DIR, "uploads"))
|
||||
UPLOAD_URL_PREFIX = os.environ.get("OPS_UPLOAD_URL_PREFIX", "/uploads")
|
||||
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get("OPS_SECRET_KEY", "ops-portal-dev-secret-change-me-32b!")
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||
"OPS_DATABASE_URL", "sqlite:///" + os.path.join(BASE_DIR, "instance", "ops_portal.db")
|
||||
)
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
JWT_EXPIRE_HOURS = int(os.environ.get("OPS_JWT_EXPIRE_HOURS", "12"))
|
||||
MAX_CONTENT_LENGTH = 32 * 1024 * 1024
|
||||
JSON_AS_ASCII = False
|
||||
UPLOAD_FOLDER = UPLOAD_DIR
|
||||
@@ -0,0 +1,3 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
@@ -0,0 +1,153 @@
|
||||
"""SQLAlchemy models for OPS Resource Portal."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from extensions import db
|
||||
|
||||
|
||||
def utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Association table: resource <-> option (faceted tagging)
|
||||
# ---------------------------------------------------------------------------
|
||||
resource_options = db.Table(
|
||||
"resource_options",
|
||||
db.Column("resource_id", db.Integer, db.ForeignKey("resources.id", ondelete="CASCADE"), primary_key=True),
|
||||
db.Column("option_id", db.Integer, db.ForeignKey("options.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = "users"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||
password = db.Column(db.String(128), nullable=False) # bcrypt hash
|
||||
created_at = db.Column(db.DateTime, default=utcnow)
|
||||
|
||||
def set_password(self, raw):
|
||||
import bcrypt
|
||||
self.password = bcrypt.hashpw(raw.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
def check_password(self, raw):
|
||||
import bcrypt
|
||||
try:
|
||||
return bcrypt.checkpw(raw.encode("utf-8"), self.password.encode("utf-8"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class SiteSetting(db.Model):
|
||||
"""Key-value site configuration editable from the admin panel."""
|
||||
__tablename__ = "site_settings"
|
||||
key = db.Column(db.String(64), primary_key=True)
|
||||
value = db.Column(db.Text, default="")
|
||||
updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class Category(db.Model):
|
||||
"""Left-nav menu entry, e.g. 基础设备 / pypi源."""
|
||||
__tablename__ = "categories"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(80), nullable=False)
|
||||
icon = db.Column(db.String(16), default="📁") # emoji icon
|
||||
description = db.Column(db.String(255), default="")
|
||||
sort_order = db.Column(db.Integer, default=0)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=utcnow)
|
||||
|
||||
rows = db.relationship("OptionRow", backref="category", lazy=True,
|
||||
cascade="all, delete-orphan", order_by="OptionRow.sort_order")
|
||||
resources = db.relationship("Resource", backref="category", lazy=True,
|
||||
cascade="all, delete-orphan")
|
||||
|
||||
def to_dict(self, with_rows=True):
|
||||
d = {
|
||||
"id": self.id, "name": self.name, "icon": self.icon,
|
||||
"description": self.description, "sort_order": self.sort_order,
|
||||
"is_active": self.is_active,
|
||||
"resource_count": len(self.resources),
|
||||
}
|
||||
if with_rows:
|
||||
d["rows"] = [r.to_dict() for r in self.rows]
|
||||
return d
|
||||
|
||||
|
||||
class OptionRow(db.Model):
|
||||
"""A selection row inside a category, e.g. 运行环境 / 操作系统."""
|
||||
__tablename__ = "option_rows"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
category_id = db.Column(db.Integer, db.ForeignKey("categories.id", ondelete="CASCADE"), nullable=False)
|
||||
title = db.Column(db.String(80), nullable=False) # e.g. 运行环境
|
||||
sort_order = db.Column(db.Integer, default=0)
|
||||
|
||||
options = db.relationship("Option", backref="row", lazy=True,
|
||||
cascade="all, delete-orphan", order_by="Option.sort_order")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id, "title": self.title, "sort_order": self.sort_order,
|
||||
"options": [o.to_dict() for o in self.options],
|
||||
}
|
||||
|
||||
|
||||
class Option(db.Model):
|
||||
"""A selectable tile inside a row, e.g. 物理机 / ubuntu."""
|
||||
__tablename__ = "options"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
row_id = db.Column(db.Integer, db.ForeignKey("option_rows.id", ondelete="CASCADE"), nullable=False)
|
||||
label = db.Column(db.String(80), nullable=False)
|
||||
icon = db.Column(db.String(16), default="")
|
||||
sort_order = db.Column(db.Integer, default=0)
|
||||
|
||||
def to_dict(self):
|
||||
return {"id": self.id, "label": self.label, "icon": self.icon, "sort_order": self.sort_order}
|
||||
|
||||
|
||||
class Resource(db.Model):
|
||||
"""A published internal resource: command / link / text / file."""
|
||||
__tablename__ = "resources"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
category_id = db.Column(db.Integer, db.ForeignKey("categories.id", ondelete="CASCADE"), nullable=False)
|
||||
title = db.Column(db.String(160), nullable=False)
|
||||
type = db.Column(db.String(16), nullable=False, default="command") # command|link|text|file
|
||||
content = db.Column(db.Text, default="") # command text / rich text / note
|
||||
url = db.Column(db.String(500), default="") # link target / file URL
|
||||
description = db.Column(db.String(255), default="")
|
||||
sort_order = db.Column(db.Integer, default=0)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
options = db.relationship("Option", secondary=resource_options, lazy="subquery",
|
||||
backref=db.backref("resources", lazy=True))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id, "category_id": self.category_id, "title": self.title,
|
||||
"type": self.type, "content": self.content, "url": self.url,
|
||||
"description": self.description, "sort_order": self.sort_order,
|
||||
"is_active": self.is_active,
|
||||
"option_ids": sorted(o.id for o in self.options),
|
||||
"created_at": self.created_at.strftime("%Y-%m-%d %H:%M") if self.created_at else "",
|
||||
"updated_at": self.updated_at.strftime("%Y-%m-%d %H:%M") if self.updated_at else "",
|
||||
}
|
||||
|
||||
|
||||
class SideLink(db.Model):
|
||||
"""Right-panel 其他链接 entry."""
|
||||
__tablename__ = "side_links"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(160), nullable=False)
|
||||
url = db.Column(db.String(500), nullable=False)
|
||||
group_name = db.Column(db.String(80), default="其他链接")
|
||||
sort_order = db.Column(db.Integer, default=0)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id, "title": self.title, "url": self.url,
|
||||
"group_name": self.group_name, "sort_order": self.sort_order,
|
||||
"is_active": self.is_active,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
Flask>=3.0
|
||||
Flask-SQLAlchemy>=3.1
|
||||
Flask-Cors>=4.0
|
||||
PyJWT>=2.8
|
||||
bcrypt>=4.0
|
||||
gunicorn>=21.2
|
||||
@@ -0,0 +1 @@
|
||||
# intentionally empty — prevents circular imports (see app factory)
|
||||
@@ -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
|
||||
@@ -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": "密码已修改"})
|
||||
@@ -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]
|
||||
})
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
"""Seed default admin + demo content mirroring the reference design."""
|
||||
from extensions import db
|
||||
from models import Category, Option, OptionRow, Resource, SideLink, SiteSetting, User
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"site_name": "OPS 资源中心",
|
||||
"hero_tag": "INTERNAL RESOURCES",
|
||||
"hero_title": "内部资源 快速上手",
|
||||
"hero_subtitle": "执行一条命令完成初始化,发现更多面向开发者的内部镜像与工具",
|
||||
"hero_command": "curl -sSL http://ops.internal/init.sh | bash",
|
||||
"hero_command_label": "RUN THE COMMAND TO INSTALL",
|
||||
"announcement": "欢迎使用内部资源中心 · 新增资源请联系运维组",
|
||||
"footer_text": "OPS Resource Portal · 内部使用",
|
||||
}
|
||||
|
||||
|
||||
def seed_admin():
|
||||
if not User.query.filter_by(username="admin").first():
|
||||
u = User(username="admin")
|
||||
u.set_password("admin123")
|
||||
db.session.add(u)
|
||||
db.session.commit()
|
||||
print("[seed] admin user created (admin / admin123)")
|
||||
|
||||
|
||||
def seed_settings():
|
||||
changed = False
|
||||
for k, v in DEFAULT_SETTINGS.items():
|
||||
if not SiteSetting.query.get(k):
|
||||
db.session.add(SiteSetting(key=k, value=v))
|
||||
changed = True
|
||||
if changed:
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def seed_demo():
|
||||
"""Populate demo content only on a completely empty database."""
|
||||
if Category.query.first():
|
||||
return
|
||||
|
||||
# ---------------- Categories ----------------
|
||||
cats = {}
|
||||
for i, (name, icon, desc) in enumerate([
|
||||
("基础设备", "🖥️", "物理机 / KVM / Docker 等基础环境入口"),
|
||||
("基础系统", "💿", "各操作系统的初始化与配置"),
|
||||
("初始化执行命令", "⚡", "新机器上架后执行的标准命令"),
|
||||
("软件列表", "📦", "内部常用软件与工具清单"),
|
||||
("pypi源", "🐍", "内部 PyPI 镜像源配置"),
|
||||
("镜像源", "🪞", "apt / yum / npm 等镜像源汇总"),
|
||||
]):
|
||||
c = Category(name=name, icon=icon, description=desc, sort_order=i)
|
||||
db.session.add(c)
|
||||
cats[name] = c
|
||||
db.session.flush()
|
||||
|
||||
# ---------------- Option rows ----------------
|
||||
def add_rows(cat, rows):
|
||||
out = {}
|
||||
for i, (title, labels) in enumerate(rows):
|
||||
row = OptionRow(category_id=cat.id, title=title, sort_order=i)
|
||||
db.session.add(row)
|
||||
db.session.flush()
|
||||
opts = []
|
||||
for j, (label, icon) in enumerate(labels):
|
||||
o = Option(row_id=row.id, label=label, icon=icon, sort_order=j)
|
||||
db.session.add(o)
|
||||
opts.append(o)
|
||||
db.session.flush()
|
||||
out[title] = opts
|
||||
return out
|
||||
|
||||
env_labels = [("物理机", "🖥️"), ("KVM虚拟机", "🧩"), ("Docker容器", "🐳")]
|
||||
os_labels = [("ubuntu", ""), ("debian", ""), ("centos", ""), ("kylin", "")]
|
||||
|
||||
opts_base = add_rows(cats["基础设备"], [("运行环境", env_labels)])
|
||||
opts_sys = add_rows(cats["基础系统"], [("运行环境", env_labels), ("操作系统", os_labels)])
|
||||
opts_pypi = add_rows(cats["pypi源"], [("运行环境", env_labels), ("操作系统", os_labels)])
|
||||
|
||||
# ---------------- Resources ----------------
|
||||
r = Resource(
|
||||
category_id=cats["pypi源"].id,
|
||||
title="配置内部 pip 源",
|
||||
type="command",
|
||||
content="mkdir -p ~/.pip && wget http://ops.streamcomputing.com/repo_list/pip.conf -O ~/.pip/pip.conf",
|
||||
description="一键写入 pip.conf,立即生效",
|
||||
sort_order=0,
|
||||
)
|
||||
db.session.add(r)
|
||||
|
||||
db.session.add(Resource(
|
||||
category_id=cats["pypi源"].id,
|
||||
title="Docker 构建时注入 pip 源",
|
||||
type="command",
|
||||
content="RUN mkdir -p /root/.pip && wget -q http://ops.streamcomputing.com/repo_list/pip.conf -O /root/.pip/pip.conf",
|
||||
description="适用于 Dockerfile 构建场景",
|
||||
sort_order=1,
|
||||
options=[opts_pypi["运行环境"][2]], # Docker容器
|
||||
))
|
||||
|
||||
db.session.add(Resource(
|
||||
category_id=cats["pypi源"].id,
|
||||
title="PyPI 包索引浏览",
|
||||
type="link",
|
||||
url="http://ops.streamcomputing.com/pypi/",
|
||||
description="Index of / — 内部镜像的全量包列表",
|
||||
sort_order=2,
|
||||
))
|
||||
|
||||
db.session.add(Resource(
|
||||
category_id=cats["基础系统"].id,
|
||||
title="Ubuntu / Debian 初始化",
|
||||
type="command",
|
||||
content="wget http://ops.streamcomputing.com/repo_list/ubuntu.sources -O /etc/apt/sources.list.d/internal.sources && apt-get update",
|
||||
description="替换为内部 apt 源并刷新索引",
|
||||
sort_order=0,
|
||||
options=[opts_sys["操作系统"][0], opts_sys["操作系统"][1]], # ubuntu, debian
|
||||
))
|
||||
db.session.add(Resource(
|
||||
category_id=cats["基础系统"].id,
|
||||
title="CentOS / Kylin 初始化",
|
||||
type="command",
|
||||
content="wget http://ops.streamcomputing.com/repo_list/CentOS-Base.repo -O /etc/yum.repos.d/CentOS-Base.repo && yum makecache",
|
||||
description="替换为内部 yum 源并建立缓存",
|
||||
sort_order=1,
|
||||
options=[opts_sys["操作系统"][2], opts_sys["操作系统"][3]], # centos, kylin
|
||||
))
|
||||
db.session.add(Resource(
|
||||
category_id=cats["基础系统"].id,
|
||||
title="KVM 虚拟机 cloud-init 说明",
|
||||
type="text",
|
||||
content="KVM 模板镜像已内置 cloud-init,首次启动会自动扩容磁盘并注入 SSH 公钥。\n如需自定义主机名,请在创建时传入 metadata。",
|
||||
description="适用于 KVM 模板镜像",
|
||||
sort_order=2,
|
||||
options=[opts_sys["运行环境"][1]], # KVM虚拟机
|
||||
))
|
||||
|
||||
db.session.add(Resource(
|
||||
category_id=cats["初始化执行命令"].id,
|
||||
title="标准初始化脚本",
|
||||
type="command",
|
||||
content="curl -sSL http://ops.streamcomputing.com/init/bootstrap.sh | bash",
|
||||
description="包含时区、内核参数、监控 agent 安装",
|
||||
sort_order=0,
|
||||
))
|
||||
db.session.add(Resource(
|
||||
category_id=cats["初始化执行命令"].id,
|
||||
title="初始化自检",
|
||||
type="command",
|
||||
content="ops-check --all --report",
|
||||
description="初始化完成后执行,输出自检报告",
|
||||
sort_order=1,
|
||||
))
|
||||
|
||||
db.session.add(Resource(
|
||||
category_id=cats["软件列表"].id,
|
||||
title="内部软件清单",
|
||||
type="link",
|
||||
url="http://ops.streamcomputing.com/software/",
|
||||
description="常用软件版本与下载入口",
|
||||
sort_order=0,
|
||||
))
|
||||
db.session.add(Resource(
|
||||
category_id=cats["软件列表"].id,
|
||||
title="CLI 工具合集",
|
||||
type="text",
|
||||
content="ops-cli / dbtool / netprobe / logtail\n安装方式见各软件详情页。",
|
||||
sort_order=1,
|
||||
))
|
||||
|
||||
db.session.add(Resource(
|
||||
category_id=cats["基础设备"].id,
|
||||
title="设备申领入口",
|
||||
type="link",
|
||||
url="http://ops.streamcomputing.com/devices/",
|
||||
description="物理机 / 虚拟机 / 容器资源申领",
|
||||
sort_order=0,
|
||||
))
|
||||
|
||||
db.session.add(Resource(
|
||||
category_id=cats["镜像源"].id,
|
||||
title="镜像源总览",
|
||||
type="link",
|
||||
url="http://ops.streamcomputing.com/mirrors/",
|
||||
description="apt / yum / pypi / npm / docker registry",
|
||||
sort_order=0,
|
||||
))
|
||||
|
||||
# ---------------- Side links ----------------
|
||||
for i, (title, url) in enumerate([
|
||||
("Docker镜像仓库", "http://ops.streamcomputing.com/docker/"),
|
||||
("Docker基础镜像使用方法以及镜像列表", "http://ops.streamcomputing.com/docker/guide"),
|
||||
("KVM模板镜像下载", "http://ops.streamcomputing.com/kvm/images"),
|
||||
("KVM模板镜像信息一览表", "http://ops.streamcomputing.com/kvm/list"),
|
||||
("iso镜像下载", "http://ops.streamcomputing.com/iso/"),
|
||||
("grafana网址", "http://grafana.streamcomputing.com/"),
|
||||
("ops文档中心", "http://docs.streamcomputing.com/"),
|
||||
]):
|
||||
db.session.add(SideLink(title=title, url=url, sort_order=i))
|
||||
|
||||
db.session.commit()
|
||||
print("[seed] demo content created")
|
||||
|
||||
|
||||
def seed_all():
|
||||
seed_admin()
|
||||
seed_settings()
|
||||
seed_demo()
|
||||
Reference in New Issue
Block a user