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
+57
View File
@@ -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)
+45
View File
@@ -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
+17
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
+153
View File
@@ -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,
}
+6
View File
@@ -0,0 +1,6 @@
Flask>=3.0
Flask-SQLAlchemy>=3.1
Flask-Cors>=4.0
PyJWT>=2.8
bcrypt>=4.0
gunicorn>=21.2
+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]
})
+207
View File
@@ -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()