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
+46
View File
@@ -0,0 +1,46 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.venv/
venv/
env/
.eggs/
*.egg
.pytest_cache/
.mypy_cache/
# Flask instance
instance/
*.db
*.sqlite
*.sqlite3
# Logs
*.log
logs/
# Node / Vite
node_modules/
dist/
.vite/
.cache/
# Env / secrets
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
.DS_Store
Thumbs.db
# Misc
.coverage
htmlcov/
.tox/
+155
View File
@@ -0,0 +1,155 @@
# OPS 资源中心
一个现代化的内部资源管理门户,用于集中管理运维命令、文档、链接和文件。
## 功能特性
### 门户展示
- 🎨 现代化响应式设计
- 🔍 全局搜索功能,支持按 `/` 快捷键搜索
- 🏷️ 多维度分类筛选
- 📋 资源卡片展示,支持一键复制命令
- 🔗 右侧快捷链接栏
### 管理后台
- 📊 数据仪表盘
- 📁 分类管理(支持排序、分面选项)
- 📄 资源管理(支持 4 种类型:命令/链接/文档/文件)
- 📎 文件上传功能
- 🔗 侧栏链接管理
- ⚙️ 站点设置(标题、副标题、公告等)
- 👥 用户管理(创建、重置密码、删除)
## 技术栈
### 后端
- **Flask** - Web 框架
- **Flask-SQLAlchemy** - ORM
- **SQLite** - 数据库
- **JWT** - 身份认证
- **bcrypt** - 密码加密
### 前端
- **Vue 3** - 前端框架
- **Element Plus** - UI 组件库
- **Vite** - 构建工具
- **Axios** - HTTP 客户端
## 快速开始
### 环境要求
- Python 3.8+
- Node.js 16+
### 后端启动
```bash
cd backend
# 安装依赖
pip install -r requirements.txt
# 启动服务(开发模式)
python app.py
```
服务将在 `http://localhost:8090` 启动
### 前端启动
```bash
cd frontend
# 安装依赖
npm install
# 开发模式
npm run dev
# 构建生产版本
npm run build
```
## 默认账号
- 用户名: `admin`
- 密码: `admin123`
## 项目结构
```
ops-manager/
├── backend/ # 后端服务
│ ├── app.py # 应用入口
│ ├── config.py # 配置文件
│ ├── models.py # 数据模型
│ ├── auth.py # 认证相关
│ ├── routes/ # API 路由
│ │ ├── portal.py # 门户公开 API
│ │ ├── admin.py # 管理后台 API
│ │ └── auth.py # 认证 API
│ ├── instance/ # 数据库文件(自动创建)
│ ├── uploads/ # 上传文件目录(自动创建)
│ └── requirements.txt # Python 依赖
└── frontend/ # 前端应用
├── src/
│ ├── views/ # 页面组件
│ │ ├── Portal.vue # 门户首页
│ │ └── admin/ # 管理后台页面
│ ├── api/index.js # API 封装
│ ├── router/ # 路由配置
│ └── styles/ # 样式文件
├── dist/ # 构建产物(自动创建)
└── package.json
```
## 资源类型
1. **命令 (command)** - 可一键复制的 Shell 命令
2. **链接 (link)** - 内部/外部链接跳转
3. **文档 (text)** - 富文本说明文档
4. **文件 (file)** - 可下载的文件资源
## 环境变量
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `OPS_SECRET_KEY` | JWT 加密密钥 | `ops-portal-dev-secret-change-me-32b!` |
| `OPS_DATABASE_URL` | 数据库连接字符串 | `sqlite:///instance/ops_portal.db` |
| `OPS_JWT_EXPIRE_HOURS` | JWT 过期时间(小时) | `12` |
| `OPS_UPLOAD_DIR` | 文件上传目录 | `backend/uploads` |
| `OPS_UPLOAD_URL_PREFIX` | 文件访问 URL 前缀 | `/uploads` |
## 部署建议
1. **生产环境**使用 Gunicorn 或 uWSGI 部署后端
2. 使用 Nginx 反向代理前端静态文件和 API
3. 配置环境变量 `OPS_SECRET_KEY` 为强随机字符串
4. 定期备份 SQLite 数据库文件
## 开发
### 添加新的资源类型
1.`backend/models.py` 中更新 `Resource` 模型(如果需要)
2.`backend/routes/admin.py` 中更新 `RESOURCE_TYPES` 和验证逻辑
3. 在前端 `Resources.vue` 中添加对应展示样式
4. 在门户 `Portal.vue` 中添加 badge 样式
### 自定义主题
主要样式变量定义在 `frontend/src/styles/portal.css``:root` 块中:
```css
:root {
--ink: #0a1424; /* 主深色 */
--cyan: #2dd9f0; /* 主青色 */
--red: #e5484d; /* 主红色 */
--paper: #f2f5f9; /* 背景色 */
/* ... */
}
```
## License
MIT
+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()
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OPS 资源中心</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1870
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "ops-portal-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"element-plus": "^2.9.3",
"@element-plus/icons-vue": "^2.3.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.7"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+105
View File
@@ -0,0 +1,105 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { getToken, removeToken } from '../utils/auth'
import router from '../router'
const api = axios.create({
baseURL: '/api',
timeout: 30000
})
api.interceptors.request.use((config) => {
const token = getToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
(res) => res,
(err) => {
const status = err.response?.status
const msg = err.response?.data?.error || err.message || '请求失败'
if (status === 401 && router.currentRoute.value.path.startsWith('/admin')) {
removeToken()
router.push({ name: 'admin-login' })
}
ElMessage.error(msg)
return Promise.reject(err)
}
)
// ---------- Portal (public) ----------
export const portalApi = {
bootstrap: () => api.get('/portal/bootstrap'),
resources: (categoryId, optionIds = [], keyword = '') =>
api.get(`/portal/categories/${categoryId}/resources`, {
params: {
...(optionIds.length ? { option_ids: optionIds.join(',') } : {}),
...(keyword ? { keyword } : {})
}
}),
search: (keyword) => api.get('/portal/search', { params: { keyword } })
}
// ---------- Auth ----------
export const authApi = {
login: (username, password) => api.post('/auth/login', { username, password }),
me: () => api.get('/auth/me'),
changePassword: (old_password, new_password) => api.post('/auth/password', { old_password, new_password })
}
// ---------- Admin ----------
export const adminApi = {
stats: () => api.get('/admin/stats'),
// categories
listCategories: () => api.get('/admin/categories'),
createCategory: (data) => api.post('/admin/categories', data),
updateCategory: (id, data) => api.put(`/admin/categories/${id}`, data),
deleteCategory: (id) => api.delete(`/admin/categories/${id}`),
moveCategory: (id, direction) => api.post(`/admin/categories/${id}/move`, { direction }),
// option rows
createRow: (categoryId, data) => api.post(`/admin/categories/${categoryId}/rows`, data),
updateRow: (id, data) => api.put(`/admin/rows/${id}`, data),
deleteRow: (id) => api.delete(`/admin/rows/${id}`),
// options
createOption: (rowId, data) => api.post(`/admin/rows/${rowId}/options`, data),
updateOption: (id, data) => api.put(`/admin/options/${id}`, data),
deleteOption: (id) => api.delete(`/admin/options/${id}`),
// resources
listResources: (params) => api.get('/admin/resources', { params }),
createResource: (data) => api.post('/admin/resources', data),
updateResource: (id, data) => api.put(`/admin/resources/${id}`, data),
deleteResource: (id) => api.delete(`/admin/resources/${id}`),
// side links
listLinks: () => api.get('/admin/links'),
createLink: (data) => api.post('/admin/links', data),
updateLink: (id, data) => api.put(`/admin/links/${id}`, data),
deleteLink: (id) => api.delete(`/admin/links/${id}`),
// settings
getSettings: () => api.get('/admin/settings'),
updateSettings: (data) => api.put('/admin/settings', data),
// users
listUsers: () => api.get('/admin/users'),
createUser: (data) => api.post('/admin/users', data),
resetUserPassword: (id, password) => api.put(`/admin/users/${id}/password`, { password }),
deleteUser: (id) => api.delete(`/admin/users/${id}`),
// upload
uploadFile: (file, onProgress) => {
const formData = new FormData()
formData.append('file', file)
return api.post('/admin/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: onProgress
})
}
}
export default api
+16
View File
@@ -0,0 +1,16 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus, { locale: zhCn })
app.use(router)
app.mount('#app')
+42
View File
@@ -0,0 +1,42 @@
import { createRouter, createWebHistory } from 'vue-router'
import { getToken } from '../utils/auth'
const routes = [
{
path: '/',
name: 'portal',
component: () => import('../views/Portal.vue')
},
{
path: '/admin/login',
name: 'admin-login',
component: () => import('../views/admin/Login.vue')
},
{
path: '/admin',
component: () => import('../views/admin/Layout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', redirect: '/admin/dashboard' },
{ path: 'dashboard', name: 'admin-dashboard', component: () => import('../views/admin/Dashboard.vue') },
{ path: 'categories', name: 'admin-categories', component: () => import('../views/admin/Categories.vue') },
{ path: 'resources', name: 'admin-resources', component: () => import('../views/admin/Resources.vue') },
{ path: 'links', name: 'admin-links', component: () => import('../views/admin/Links.vue') },
{ path: 'settings', name: 'admin-settings', component: () => import('../views/admin/Settings.vue') },
{ path: 'users', name: 'admin-users', component: () => import('../views/admin/Users.vue') }
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to) => {
if (to.meta.requiresAuth && !getToken()) {
return { name: 'admin-login', query: { redirect: to.fullPath } }
}
})
export default router
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
const STORAGE_KEY = 'ops_portal_auth_token'
export function getToken() {
return localStorage.getItem(STORAGE_KEY)
}
export function setToken(token) {
localStorage.setItem(STORAGE_KEY, token)
}
export function removeToken() {
localStorage.removeItem(STORAGE_KEY)
}
+332
View File
@@ -0,0 +1,332 @@
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { portalApi } from '../api'
import '../styles/portal.css'
const loading = ref(true)
const settings = ref({})
const categories = ref([])
const links = ref([])
const activeCatId = ref(null)
const selections = ref({}) // { rowId: optionId }
const resources = ref([])
const resLoading = ref(false)
const copiedKey = ref('')
// Search
const searchMode = ref(false)
const searchKeyword = ref('')
const searchResults = ref([])
const searchLoading = ref(false)
const searchDebounce = ref(null)
async function doSearch() {
const kw = searchKeyword.value.trim()
if (!kw || kw.length < 2) {
searchResults.value = []
return
}
searchLoading.value = true
try {
const { data } = await portalApi.search(kw)
searchResults.value = data.results || []
} finally {
searchLoading.value = false
}
}
function onSearchInput() {
clearTimeout(searchDebounce.value)
searchDebounce.value = setTimeout(doSearch, 300)
}
function openSearch() {
searchMode.value = true
searchKeyword.value = ''
searchResults.value = []
}
function closeSearch() {
searchMode.value = false
searchKeyword.value = ''
searchResults.value = []
}
const activeCat = computed(() => categories.value.find(c => c.id === activeCatId.value) || null)
const stats = computed(() => ({
categories: categories.value.length,
links: links.value.length
}))
async function loadBootstrap() {
loading.value = true
try {
const { data } = await portalApi.bootstrap()
settings.value = data.settings || {}
categories.value = data.categories || []
links.value = data.links || []
document.title = settings.value.site_name || 'OPS 资源中心'
if (categories.value.length && !activeCatId.value) {
selectCategory(categories.value[0].id)
}
} finally {
loading.value = false
}
}
function selectCategory(id) {
activeCatId.value = id
selections.value = {}
loadResources()
}
function toggleOption(rowId, optId) {
if (selections.value[rowId] === optId) {
delete selections.value[rowId]
} else {
selections.value[rowId] = optId
}
selections.value = { ...selections.value }
loadResources()
}
async function loadResources() {
if (!activeCatId.value) return
resLoading.value = true
try {
const ids = Object.values(selections.value)
const { data } = await portalApi.resources(activeCatId.value, ids)
resources.value = data.resources || []
} catch {
resources.value = []
} finally {
resLoading.value = false
}
}
async function copyText(text, key) {
try {
await navigator.clipboard.writeText(text)
} catch {
const ta = document.createElement('textarea')
ta.value = text
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
copiedKey.value = key
setTimeout(() => { if (copiedKey.value === key) copiedKey.value = '' }, 1600)
}
const typeLabel = { command: 'CMD', link: 'LINK', text: 'DOC', file: 'FILE' }
onMounted(() => {
loadBootstrap()
// Keyboard shortcut for search
const handleKeydown = (e) => {
if (e.key === '/' && !searchMode.value && !['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) {
e.preventDefault()
openSearch()
setTimeout(() => document.querySelector('.search-input')?.focus(), 50)
}
if (e.key === 'Escape' && searchMode.value) {
closeSearch()
}
}
window.addEventListener('keydown', handleKeydown)
onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
})
</script>
<template>
<div class="portal">
<!-- announcement ticker -->
<div class="ticker" v-if="settings.announcement">
<div class="ticker-inner">
<span v-for="i in 2" :key="i"><b>公告</b>{{ settings.announcement }}</span>
</div>
</div>
<!-- hero -->
<header class="hero">
<div class="hero-grid-bg"></div>
<div class="hero-inner">
<div class="hero-left">
<div class="hero-tag">{{ settings.hero_tag || 'INTERNAL RESOURCES' }}</div>
<h1 v-html="(settings.hero_title || '内部资源 快速上手').replace(/(快速上手|GET STARTED)/, '<em>$1</em>')"></h1>
<p class="hero-sub">{{ settings.hero_subtitle }}</p>
<!-- Search button in hero -->
<div class="hero-search-btn" @click="openSearch">
<span class="hs-icon">🔍</span>
搜索内部资源
<span class="hs-hint"> / 快速搜索</span>
</div>
<div class="terminal" v-if="settings.hero_command">
<div class="terminal-bar">
<i></i><i></i><i></i>
<span>{{ settings.hero_command_label || 'RUN THE COMMAND TO INSTALL' }}</span>
</div>
<div class="terminal-body">
<code>{{ settings.hero_command }}</code>
<button class="copy-btn" :class="{ copied: copiedKey === 'hero' }"
@click="copyText(settings.hero_command, 'hero')">
{{ copiedKey === 'hero' ? ' 已复制' : '复制' }}
</button>
</div>
</div>
<div class="hero-stats">
<div class="hero-stat"><b>{{ stats.categories }}</b><span>资源分类</span></div>
<div class="hero-stat"><b>{{ stats.links }}</b><span>快捷入口</span></div>
<div class="hero-stat"><b>24/7</b><span>内部可用</span></div>
</div>
</div>
<div class="hero-art">
<svg viewBox="0 0 460 380" preserveAspectRatio="xMidYMid meet">
<path class="trace" d="M20,60 H160 V150 H300 V60 H440" />
<path class="trace" d="M20,190 H110 V290 H250 V190 H440" />
<path class="trace" d="M60,20 V120 H220 V330 H400 V240" />
<path class="trace-flow" d="M20,60 H160 V150 H300 V60 H440" />
<path class="trace-flow" d="M20,190 H110 V290 H250 V190 H440" style="animation-delay:-2s" />
<path class="trace-flow" d="M60,20 V120 H220 V330 H400 V240" style="animation-delay:-3.4s" />
<g v-for="(p, i) in [[160,60],[300,60],[110,190],[250,190],[220,120],[400,240],[160,150],[250,290]]" :key="i">
<rect class="node" :x="p[0]-7" :y="p[1]-7" width="14" height="14" rx="2" />
<circle class="node-core" :cx="p[0]" :cy="p[1]" r="3" :style="{ animationDelay: (-i * .35) + 's' }" />
</g>
</svg>
<div class="cube c1"></div>
<div class="cube c2"></div>
<div class="cube c3"></div>
<div class="cube c4"></div>
</div>
</div>
</header>
<!-- Search overlay -->
<div class="search-overlay" v-if="searchMode" @click.self="closeSearch">
<div class="search-modal">
<div class="search-input-wrap">
<span class="search-icon">🔍</span>
<input type="text" v-model="searchKeyword" placeholder="搜索资源标题、内容..."
class="search-input" @input="onSearchInput" ref="searchInput">
<button class="search-close" @click="closeSearch"></button>
</div>
<div class="search-results" v-loading="searchLoading">
<div v-if="searchKeyword.length && searchKeyword.length < 2" class="search-hint">
请输入至少 2 个字符
</div>
<div v-else-if="!searchLoading && !searchResults.length && searchKeyword.length >= 2" class="search-empty">
未找到相关资源
</div>
<div v-for="res in searchResults" :key="res.id" class="search-result-item"
@click="selectCategory(res.category_id); closeSearch()">
<span class="res-badge-sm" :class="res.type">{{ typeLabel[res.type] || res.type }}</span>
<span class="sr-title">{{ res.title }}</span>
<span class="sr-desc" v-if="res.description">{{ res.description }}</span>
</div>
</div>
<div class="search-footer">
<kbd>ESC</kbd> 关闭 &nbsp;&nbsp; <kbd></kbd><kbd></kbd> 导航
</div>
</div>
</div>
<!-- content -->
<div class="loading-wrap" v-if="loading">正在加载资源</div>
<main class="content-wrap" v-else>
<!-- left menu -->
<nav class="side-menu">
<div class="side-menu-title">CATEGORIES</div>
<div v-for="cat in categories" :key="cat.id"
class="menu-item" :class="{ active: cat.id === activeCatId }"
@click="selectCategory(cat.id)">
<span class="mi-icon">{{ cat.icon }}</span>
<span>{{ cat.name }}</span>
<span class="mi-count">{{ cat.resource_count }}</span>
</div>
</nav>
<!-- center -->
<section class="center-panel">
<div class="panel-head" v-if="activeCat">
<h2>{{ activeCat.icon }} {{ activeCat.name }}</h2>
<p>{{ activeCat.description }}</p>
</div>
<!-- option tiles -->
<div v-for="row in (activeCat?.rows || [])" :key="row.id" class="option-row">
<div class="option-row-title">{{ row.title }}</div>
<div class="tiles">
<div v-for="opt in row.options" :key="opt.id"
class="tile" :class="{ selected: selections[row.id] === opt.id }"
@click="toggleOption(row.id, opt.id)">
<span class="tile-icon" v-if="opt.icon">{{ opt.icon }}</span>
{{ opt.label }}
</div>
</div>
</div>
<!-- resources -->
<div class="res-list" v-if="resources.length">
<article v-for="(res, idx) in resources" :key="res.id" class="res-card"
:style="{ animationDelay: (idx * 60) + 'ms' }">
<div class="res-head">
<span class="res-badge" :class="res.type">{{ typeLabel[res.type] || res.type }}</span>
<span class="res-title">{{ res.title }}</span>
<span class="res-desc" v-if="res.description">{{ res.description }}</span>
</div>
<div v-if="res.type === 'command'" class="cmd-block">
<code>{{ res.content }}</code>
<button class="cmd-copy" :class="{ copied: copiedKey === 'res' + res.id }"
@click="copyText(res.content, 'res' + res.id)">
{{ copiedKey === 'res' + res.id ? ' 已复制' : '复制' }}
</button>
</div>
<template v-else-if="res.type === 'link'">
<div class="res-body" v-if="res.content">{{ res.content }}</div>
<a class="res-jump" :href="res.url" target="_blank" rel="noopener">
{{ res.url }} &nbsp;跳转
</a>
</template>
<template v-else-if="res.type === 'file'">
<div class="res-body" v-if="res.content">{{ res.content }}</div>
<a class="res-jump" :href="res.url" target="_blank" rel="noopener">下载文件 </a>
</template>
<div v-else class="res-body">{{ res.content }}</div>
</article>
</div>
<div class="empty-hint" v-else-if="!resLoading">
<span class="eh-icon">📭</span>
该分类下暂无资源请在管理后台添加
</div>
</section>
<!-- right links -->
<aside class="links-panel" v-if="links.length">
<h3>其他链接</h3>
<a v-for="l in links" :key="l.id" class="link-item" :href="l.url" target="_blank" rel="noopener">
{{ l.title }}
</a>
</aside>
</main>
<footer class="portal-footer">
{{ settings.footer_text || 'OPS Resource Portal' }}
<router-link to="/admin">管理后台</router-link>
</footer>
<router-link to="/admin" class="admin-fab" title="管理后台"></router-link>
</div>
</template>
+233
View File
@@ -0,0 +1,233 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi } from '../../api'
const categories = ref([])
const loading = ref(true)
const catDialog = ref(false)
const catForm = ref({ id: null, name: '', icon: '📁', description: '', sort_order: 0 })
const rowDialog = ref(false)
const rowForm = ref({ id: null, category_id: null, title: '', sort_order: 0 })
const optDialog = ref(false)
const optForm = ref({ id: null, row_id: null, label: '', icon: '', sort_order: 0 })
async function load() {
loading.value = true
try {
const { data } = await adminApi.listCategories()
categories.value = data
} finally { loading.value = false }
}
// ---------- category ----------
function openCat(cat) {
catForm.value = cat
? { id: cat.id, name: cat.name, icon: cat.icon, description: cat.description, sort_order: cat.sort_order }
: { id: null, name: '', icon: '📁', description: '', sort_order: categories.value.length }
catDialog.value = true
}
async function saveCat() {
if (!catForm.value.name.trim()) return ElMessage.warning('请输入分类名称')
const payload = { ...catForm.value }
if (payload.id) await adminApi.updateCategory(payload.id, payload)
else await adminApi.createCategory(payload)
ElMessage.success('已保存')
catDialog.value = false
load()
}
async function delCat(cat) {
await ElMessageBox.confirm(`删除分类「${cat.name}」将同时删除其下所有筛选行、选项和资源,确定?`, '警告', { type: 'warning' })
await adminApi.deleteCategory(cat.id)
ElMessage.success('已删除')
load()
}
async function moveCat(cat, dir) {
await adminApi.moveCategory(cat.id, dir)
load()
}
async function toggleCat(cat) {
await adminApi.updateCategory(cat.id, { is_active: !cat.is_active })
ElMessage.success(cat.is_active ? '已隐藏' : '已显示')
load()
}
// ---------- option row ----------
function openRow(cat, row) {
rowForm.value = row
? { id: row.id, category_id: cat.id, title: row.title, sort_order: row.sort_order }
: { id: null, category_id: cat.id, title: '', sort_order: (cat.rows || []).length }
rowDialog.value = true
}
async function saveRow() {
if (!rowForm.value.title.trim()) return ElMessage.warning('请输入筛选行名称')
const payload = { ...rowForm.value }
const cid = payload.category_id
delete payload.category_id
if (payload.id) await adminApi.updateRow(payload.id, payload)
else await adminApi.createRow(cid, payload)
ElMessage.success('已保存')
rowDialog.value = false
load()
}
async function delRow(row) {
await ElMessageBox.confirm(`删除筛选行「${row.title}」及其所有选项?`, '警告', { type: 'warning' })
await adminApi.deleteRow(row.id)
ElMessage.success('已删除')
load()
}
// ---------- option ----------
function openOpt(row, opt) {
optForm.value = opt
? { id: opt.id, row_id: row.id, label: opt.label, icon: opt.icon, sort_order: opt.sort_order }
: { id: null, row_id: row.id, label: '', icon: '', sort_order: (row.options || []).length }
optDialog.value = true
}
async function saveOpt() {
if (!optForm.value.label.trim()) return ElMessage.warning('请输入选项名称')
const payload = { ...optForm.value }
const rid = payload.row_id
delete payload.row_id
if (payload.id) await adminApi.updateOption(payload.id, payload)
else await adminApi.createOption(rid, payload)
ElMessage.success('已保存')
optDialog.value = false
load()
}
async function delOpt(opt) {
await ElMessageBox.confirm(`删除选项「${opt.label}」?`, '警告', { type: 'warning' })
await adminApi.deleteOption(opt.id)
ElMessage.success('已删除')
load()
}
onMounted(load)
</script>
<template>
<div v-loading="loading">
<el-card>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<b>分类列表门户左侧菜单</b>
<el-button type="primary" icon="Plus" @click="openCat(null)">新增分类</el-button>
</div>
</template>
<el-table :data="categories" row-key="id">
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-body">
<div class="expand-head">
<span>筛选行用户点击的瓦片分组运行环境操作系统</span>
<el-button size="small" type="primary" plain icon="Plus" @click="openRow(row, null)">添加筛选行</el-button>
</div>
<div v-if="!row.rows?.length" class="expand-empty">暂无筛选行 该分类下的资源将直接全部展示</div>
<div v-for="r in row.rows" :key="r.id" class="row-block">
<div class="row-line">
<el-tag type="info" effect="plain">{{ r.title }}</el-tag>
<div class="row-opts">
<el-tag v-for="o in r.options" :key="o.id" closable class="opt-tag"
@close="delOpt(o)" @click="openOpt(r, o)" style="cursor:pointer">
{{ o.icon ? o.icon + ' ' : '' }}{{ o.label }}
</el-tag>
<el-tag class="opt-add" @click="openOpt(r, null)">+ 选项</el-tag>
</div>
<div class="row-actions">
<el-button size="small" text icon="Edit" @click="openRow(row, r)" />
<el-button size="small" text type="danger" icon="Delete" @click="delRow(r)" />
</div>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="icon" label="图标" width="70" align="center">
<template #default="{ row }"><span style="font-size:18px">{{ row.icon }}</span></template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="resource_count" label="资源数" width="80" align="center" />
<el-table-column label="筛选行" width="80" align="center">
<template #default="{ row }">{{ row.rows?.length || 0 }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-switch :model-value="row.is_active" @change="toggleCat(row)" size="small" />
</template>
</el-table-column>
<el-table-column label="排序" width="110" align="center">
<template #default="{ row }">
<el-button size="small" text icon="Top" @click="moveCat(row, 'up')" />
<el-button size="small" text icon="Bottom" @click="moveCat(row, 'down')" />
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<el-button size="small" text icon="Edit" @click="openCat(row)" />
<el-button size="small" text type="danger" icon="Delete" @click="delCat(row)" />
</template>
</el-table-column>
</el-table>
</el-card>
<!-- category dialog -->
<el-dialog v-model="catDialog" :title="catForm.id ? '编辑分类' : '新增分类'" width="440px">
<el-form label-width="80px">
<el-form-item label="名称"><el-input v-model="catForm.name" placeholder="如:pypi源" /></el-form-item>
<el-form-item label="图标"><el-input v-model="catForm.icon" placeholder="emoji,如 🐍" maxlength="4" /></el-form-item>
<el-form-item label="描述"><el-input v-model="catForm.description" placeholder="显示在分类标题旁" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="catForm.sort_order" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="catDialog = false">取消</el-button>
<el-button type="primary" @click="saveCat">保存</el-button>
</template>
</el-dialog>
<!-- row dialog -->
<el-dialog v-model="rowDialog" :title="rowForm.id ? '编辑筛选行' : '添加筛选行'" width="400px">
<el-form label-width="80px">
<el-form-item label="名称"><el-input v-model="rowForm.title" placeholder="如:运行环境 / 操作系统" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="rowForm.sort_order" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="rowDialog = false">取消</el-button>
<el-button type="primary" @click="saveRow">保存</el-button>
</template>
</el-dialog>
<!-- option dialog -->
<el-dialog v-model="optDialog" :title="optForm.id ? '编辑选项' : '添加选项'" width="400px">
<el-form label-width="80px">
<el-form-item label="名称"><el-input v-model="optForm.label" placeholder="如:物理机 / ubuntu" /></el-form-item>
<el-form-item label="图标"><el-input v-model="optForm.icon" placeholder="可选 emoji,如 🖥️" maxlength="4" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="optForm.sort_order" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="optDialog = false">取消</el-button>
<el-button type="primary" @click="saveOpt">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.expand-body { padding: 8px 24px 16px 48px; background: #fafbfd; }
.expand-head {
display: flex; justify-content: space-between; align-items: center;
font-size: 13px; color: #8b9aab; margin-bottom: 10px;
}
.expand-empty { font-size: 13px; color: #b3bfcc; padding: 8px 0; }
.row-block { padding: 8px 0; border-bottom: 1px dashed #e8edf3; }
.row-block:last-child { border-bottom: none; }
.row-line { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.row-opts { display: flex; gap: 8px; flex-wrap: wrap; flex: 1; }
.opt-tag:hover { border-color: #409eff; }
.opt-add { cursor: pointer; border-style: dashed; }
.row-actions { display: flex; }
</style>
+373
View File
@@ -0,0 +1,373 @@
<script setup>
import { ref, onMounted } from 'vue'
import { adminApi } from '../../api'
const stats = ref({})
const loading = ref(true)
const cards = [
{ key: 'categories', label: '资源分类', icon: '🗂️', color: '#0ea5e9', gradient: 'linear-gradient(135deg, #0ea5e9 0%, #06b6d4 100%)' },
{ key: 'resources', label: '已发布资源', icon: '📦', color: '#8b5cf6', gradient: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)' },
{ key: 'active_resources', label: '启用中', icon: '✅', color: '#10b981', gradient: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)' },
{ key: 'links', label: '侧栏链接', icon: '🔗', color: '#f97316', gradient: 'linear-gradient(135deg, #f97316 0%, #fb923c 100%)' }
]
onMounted(async () => {
try {
const { data } = await adminApi.stats()
stats.value = data
} finally { loading.value = false }
})
</script>
<template>
<div v-loading="loading" class="dashboard">
<!-- Stats Cards -->
<div class="stats-grid">
<el-card v-for="c in cards" :key="c.key" shadow="never">
<div class="stat-bg" :style="{ background: c.gradient }"></div>
<div class="stat-icon-wrap" :style="{ background: c.color + '20', color: c.color }">
<span class="stat-icon">{{ c.icon }}</span>
</div>
<div class="stat-content">
<div class="stat-value">{{ stats[c.key] ?? '—' }}</div>
<div class="stat-label">{{ c.label }}</div>
</div>
<div class="stat-trend">
<el-icon><TrendCharts /></el-icon>
</div>
</el-card>
</div>
<!-- Quick Actions -->
<div class="section-card">
<div class="section-header">
<h2>快捷操作</h2>
<span class="section-desc">快速访问常用功能</span>
</div>
<div class="action-grid">
<router-link to="/admin/categories" class="action-btn">
<div class="action-icon" style="background: linear-gradient(135deg, #0ea5e9, #06b6d4)">
<el-icon><FolderAdd /></el-icon>
</div>
<span>新增分类</span>
</router-link>
<router-link to="/admin/resources" class="action-btn">
<div class="action-icon" style="background: linear-gradient(135deg, #8b5cf6, #a78bfa)">
<el-icon><DocumentAdd /></el-icon>
</div>
<span>发布资源</span>
</router-link>
<router-link to="/admin/links" class="action-btn">
<div class="action-icon" style="background: linear-gradient(135deg, #f97316, #fb923c)">
<el-icon><Link /></el-icon>
</div>
<span>管理链接</span>
</router-link>
<router-link to="/admin/settings" class="action-btn">
<div class="action-icon" style="background: linear-gradient(135deg, #10b981, #34d399)">
<el-icon><Setting /></el-icon>
</div>
<span>站点设置</span>
</router-link>
<router-link to="/" class="action-btn">
<div class="action-icon" style="background: linear-gradient(135deg, #6366f1, #818cf8)">
<el-icon><View /></el-icon>
</div>
<span>预览门户</span>
</router-link>
<router-link to="/admin/users" class="action-btn">
<div class="action-icon" style="background: linear-gradient(135deg, #ec4899, #f472b6)">
<el-icon><User /></el-icon>
</div>
<span>用户管理</span>
</router-link>
</div>
</div>
<!-- Workflow Steps -->
<div class="section-card">
<div class="section-header">
<h2>使用指南</h2>
<span class="section-desc">资源发布完整流程</span>
</div>
<div class="workflow-steps">
<div class="step-item">
<div class="step-number" style="background: linear-gradient(135deg, #0ea5e9, #06b6d4)">1</div>
<div class="step-content">
<h3>创建分类</h3>
<p>建立资源分类目录配置筛选选项</p>
</div>
</div>
<div class="step-line"></div>
<div class="step-item">
<div class="step-number" style="background: linear-gradient(135deg, #8b5cf6, #a78bfa)">2</div>
<div class="step-content">
<h3>配置筛选</h3>
<p>如运行环境操作系统版本标签</p>
</div>
</div>
<div class="step-line"></div>
<div class="step-item">
<div class="step-number" style="background: linear-gradient(135deg, #10b981, #34d399)">3</div>
<div class="step-content">
<h3>发布资源</h3>
<p>支持命令链接文档文件等类型</p>
</div>
</div>
<div class="step-line"></div>
<div class="step-item">
<div class="step-number" style="background: linear-gradient(135deg, #f97316, #fb923c)">4</div>
<div class="step-content">
<h3>门户展示</h3>
<p>用户按需筛选查看一键复制使用</p>
</div>
</div>
</div>
</div>
</div>
</template>
<style>
.dashboard {
max-width: 100%;
padding-top: 0;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stats-grid .el-card {
position: relative;
background: #ffffff;
border-radius: 12px;
padding: 20px;
border: 1px solid #e2e8f0;
display: flex;
align-items: center;
gap: 14px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
transition: all 0.3s ease;
}
.stats-grid .el-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
border-color: rgba(139, 92, 246, 0.2);
}
.stats-grid .el-card :deep(.el-card__body) {
padding: 0;
display: flex;
align-items: center;
gap: 16px;
width: 100%;
}
.stat-bg {
position: absolute;
top: 0;
right: 0;
width: 120px;
height: 120px;
opacity: 0.06;
border-radius: 50%;
transform: translate(30%, -30%);
pointer-events: none;
}
.stat-icon-wrap {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon {
font-size: 24px;
}
.stat-content {
flex: 1;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #0f172a;
line-height: 1.1;
letter-spacing: -0.02em;
}
.stat-label {
font-size: 13px;
color: #64748b;
margin-top: 6px;
font-weight: 500;
}
.stat-trend {
color: #94a3b8;
font-size: 20px;
opacity: 0.5;
}
/* Section Card */
.section-card {
background: #ffffff;
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.section-header {
margin-bottom: 20px;
display: flex;
align-items: baseline;
gap: 12px;
}
.section-header h2 {
font-size: 18px;
font-weight: 600;
color: #0f172a;
margin: 0;
}
.section-desc {
font-size: 13px;
color: #94a3b8;
}
/* Action Grid */
.action-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 16px;
}
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
text-decoration: none;
color: #475569;
font-size: 13.5px;
font-weight: 500;
transition: all 0.25s ease;
}
.action-btn:hover {
background: #ffffff;
border-color: #cbd5e1;
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
color: #0f172a;
}
.action-icon {
width: 42px;
height: 42px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 19px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
/* Workflow Steps */
.workflow-steps {
display: flex;
align-items: flex-start;
gap: 0;
}
.step-item {
display: flex;
align-items: flex-start;
gap: 16px;
flex: 1;
}
.step-number {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 18px;
font-weight: 700;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.step-content h3 {
font-size: 15px;
font-weight: 600;
color: #0f172a;
margin: 0 0 6px;
}
.step-content p {
font-size: 13px;
color: #64748b;
margin: 0;
line-height: 1.6;
}
.step-line {
width: 60px;
height: 2px;
background: linear-gradient(90deg, #e2e8f0, #e2e8f0);
margin: 20px 16px 0;
flex-shrink: 0;
}
/* Responsive */
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.action-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.action-grid {
grid-template-columns: repeat(2, 1fr);
}
.workflow-steps {
flex-direction: column;
gap: 20px;
}
.step-line {
width: 2px;
height: 30px;
margin: 0 0 0 21px;
}
}
</style>
+391
View File
@@ -0,0 +1,391 @@
<script setup>
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { removeToken } from '../../utils/auth'
import { authApi } from '../../api'
import { ref } from 'vue'
const router = useRouter()
const pwdDialog = ref(false)
const pwdForm = ref({ old_password: '', new_password: '' })
const pwdLoading = ref(false)
function logout() {
removeToken()
router.push('/admin/login')
}
async function changePwd() {
if (!pwdForm.value.old_password || !pwdForm.value.new_password) {
ElMessage.warning('请填写完整')
return
}
pwdLoading.value = true
try {
await authApi.changePassword(pwdForm.value.old_password, pwdForm.value.new_password)
ElMessage.success('密码已修改,请重新登录')
pwdDialog.value = false
logout()
} catch { /* toasted */ } finally { pwdLoading.value = false }
}
const menus = [
{ path: '/admin/dashboard', icon: 'Odometer', label: '仪表盘' },
{ path: '/admin/categories', icon: 'Menu', label: '分类管理' },
{ path: '/admin/resources', icon: 'Files', label: '资源管理' },
{ path: '/admin/links', icon: 'Link', label: '侧栏链接' },
{ path: '/admin/settings', icon: 'Setting', label: '站点设置' },
{ path: '/admin/users', icon: 'User', label: '用户管理' }
]
</script>
<template>
<el-container class="admin-layout">
<el-aside width="260px" class="aside">
<div class="brand">
<div class="brand-logo-wrap">
<span class="brand-logo"></span>
<div class="brand-glow"></div>
</div>
<div class="brand-text">
<b>OPS 资源中心</b>
<small>ADMIN CONSOLE</small>
</div>
</div>
<el-menu router :default-active="$route.path" class="aside-menu"
background-color="transparent" text-color="#94a3b8" active-text-color="#2dd9f0">
<el-menu-item v-for="m in menus" :key="m.path" :index="m.path" class="menu-item-wrapper">
<el-icon class="menu-icon"><component :is="m.icon" /></el-icon>
<span class="menu-label">{{ m.label }}</span>
</el-menu-item>
</el-menu>
<div class="aside-foot">
<router-link to="/" class="portal-link">
<el-icon><View /></el-icon>
<span>查看门户</span>
</router-link>
</div>
</el-aside>
<el-container class="main-container">
<el-header class="header">
<div class="header-left">
<h1 class="page-title">{{ menus.find(m => m.path === $route.path)?.label || '管理后台' }}</h1>
</div>
<div class="header-actions">
<el-button class="header-btn" @click="pwdDialog = true">
<el-icon><Lock /></el-icon>
修改密码
</el-button>
<el-button class="header-btn logout-btn" @click="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-button>
</div>
</el-header>
<el-main class="main">
<router-view />
</el-main>
</el-container>
</el-container>
<el-dialog v-model="pwdDialog" title="修改密码" width="440px" class="modern-dialog">
<template #header>
<div class="dialog-header">
<el-icon class="dialog-icon"><Lock /></el-icon>
<span>修改密码</span>
</div>
</template>
<el-form label-width="90px" class="pwd-form">
<el-form-item label="原密码">
<el-input v-model="pwdForm.old_password" type="password" show-password placeholder="请输入原密码" />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="pwdForm.new_password" type="password" show-password placeholder="请输入新密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pwdDialog = false" class="btn-secondary">取消</el-button>
<el-button type="primary" :loading="pwdLoading" @click="changePwd" class="btn-primary">确定</el-button>
</template>
</el-dialog>
</template>
<style>
.admin-layout {
min-height: 100vh;
background: #f8fafc;
}
.aside {
background: linear-gradient(180deg, #0a1424 0%, #0e1c33 50%, #0f172a 100%);
display: flex;
flex-direction: column;
border-right: 1px solid rgba(45, 217, 240, 0.12);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
}
.brand {
display: flex;
align-items: center;
gap: 16px;
padding: 24px 24px;
border-bottom: 1px solid rgba(45, 217, 240, 0.1);
position: relative;
margin-top: 0;
padding-top: 24px;
}
.brand-logo-wrap {
position: relative;
}
.brand-logo {
width: 50px;
height: 50px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(45, 217, 240, 0.15) 0%, rgba(45, 217, 240, 0.05) 100%);
border: 2px solid rgba(45, 217, 240, 0.4);
color: #2dd9f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
position: relative;
z-index: 1;
}
.brand-glow {
position: absolute;
inset: -8px;
background: radial-gradient(circle, rgba(45, 217, 240, 0.3) 0%, transparent 70%);
filter: blur(12px);
z-index: 0;
}
.brand-text b {
color: #f1f5f9;
font-size: 17px;
display: block;
font-weight: 700;
letter-spacing: -0.02em;
}
.brand-text small {
color: #64748b;
font-size: 11px;
letter-spacing: 0.2em;
margin-top: 4px;
display: block;
}
.aside :deep(.el-menu) {
border-right: none;
flex: 1;
padding: 16px 12px;
background: transparent;
}
.aside :deep(.el-menu-item) {
margin-bottom: 4px;
border-radius: 10px;
transition: all 0.2s ease;
height: 50px;
line-height: 50px;
color: #94a3b8;
}
.aside :deep(.el-menu-item:hover) {
background: rgba(255, 255, 255, 0.05);
color: #e2e8f0;
}
.aside :deep(.el-menu-item.is-active) {
background: linear-gradient(90deg, rgba(45, 217, 240, 0.15), transparent 70%) !important;
color: #2dd9f0 !important;
box-shadow: inset 0 0 0 1px rgba(45, 217, 240, 0.2);
}
.aside :deep(.el-menu-item .el-icon) {
color: #64748b;
transition: color 0.2s ease;
}
.aside :deep(.el-menu-item.is-active .el-icon) {
color: #2dd9f0;
}
.aside :deep(.el-menu-item:hover .el-icon) {
color: #e2e8f0;
}
.aside-foot {
padding: 20px 24px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.portal-link {
display: flex;
align-items: center;
gap: 10px;
color: #94a3b8;
font-size: 14px;
text-decoration: none;
padding: 12px 16px;
border-radius: 10px;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.portal-link:hover {
color: #2dd9f0;
background: rgba(45, 217, 240, 0.1);
border-color: rgba(45, 217, 240, 0.2);
transform: translateX(4px);
}
.main-container {
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e2e8f0;
background: #ffffff;
padding: 0 28px;
height: 68px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: #0f172a;
margin: 0;
letter-spacing: -0.02em;
}
.header-actions {
display: flex;
gap: 8px;
}
.header-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: #ffffff;
color: #475569;
font-size: 14px;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.header-btn:hover {
border-color: #cbd5e1;
background: #f8fafc;
color: #334155;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.logout-btn:hover {
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
background: rgba(239, 68, 68, 0.05);
}
.main {
background: #f8fafc;
padding: 24px 28px;
flex: 1;
}
/* Dialog styles */
.modern-dialog :deep(.el-dialog) {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.3);
}
.modern-dialog :deep(.el-dialog__header) {
padding: 24px 28px 20px;
background: linear-gradient(135deg, #f8fafc 0%, #ffffff 100%);
border-bottom: 1px solid #e2e8f0;
}
.dialog-header {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #0f172a;
}
.dialog-icon {
color: #8b5cf6;
font-size: 22px;
}
.modern-dialog :deep(.el-dialog__body) {
padding: 24px 28px;
}
.pwd-form {
margin-top: 8px;
}
.modern-dialog :deep(.el-dialog__footer) {
padding: 16px 28px 24px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.btn-secondary {
padding: 10px 24px;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: #ffffff;
color: #475569;
font-weight: 500;
transition: all 0.2s ease;
}
.btn-secondary:hover {
border-color: #cbd5e1;
background: #f8fafc;
color: #334155;
}
.btn-primary {
padding: 10px 28px;
border-radius: 10px;
font-weight: 500;
background: linear-gradient(135deg, #2dd9f0 0%, #0ea5e9 100%);
border: none;
box-shadow: 0 4px 14px rgba(45, 217, 240, 0.35);
transition: all 0.2s ease;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(45, 217, 240, 0.45);
}
</style>
+107
View File
@@ -0,0 +1,107 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi } from '../../api'
const links = ref([])
const loading = ref(true)
const dialog = ref(false)
const form = ref(emptyForm())
function emptyForm() {
return { id: null, title: '', url: '', group_name: '其他链接', sort_order: 0, is_active: true }
}
async function load() {
loading.value = true
try {
const { data } = await adminApi.listLinks()
links.value = data
} finally { loading.value = false }
}
function openForm(l) {
form.value = l ? { ...l } : emptyForm()
if (!l) form.value.sort_order = links.value.length
dialog.value = true
}
async function save() {
if (!form.value.title.trim()) return ElMessage.warning('请输入链接标题')
if (!form.value.url.trim()) return ElMessage.warning('请输入链接地址')
const payload = { ...form.value }
if (payload.id) await adminApi.updateLink(payload.id, payload)
else await adminApi.createLink(payload)
ElMessage.success('已保存')
dialog.value = false
load()
}
async function del(l) {
await ElMessageBox.confirm(`删除链接「${l.title}」?`, '警告', { type: 'warning' })
await adminApi.deleteLink(l.id)
ElMessage.success('已删除')
load()
}
async function toggleActive(l) {
await adminApi.updateLink(l.id, { is_active: !l.is_active })
ElMessage.success(l.is_active ? '已隐藏' : '已显示')
load()
}
onMounted(load)
</script>
<template>
<div v-loading="loading">
<el-card>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<b>侧栏链接门户右侧其他链接面板</b>
<el-button type="primary" icon="Plus" @click="openForm(null)">新增链接</el-button>
</div>
</template>
<el-table :data="links">
<el-table-column prop="title" label="标题" min-width="240" />
<el-table-column label="地址" min-width="280" show-overflow-tooltip>
<template #default="{ row }">
<a :href="row.url" target="_blank" class="url-link">{{ row.url }}</a>
</template>
</el-table-column>
<el-table-column prop="group_name" label="分组" width="120" />
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-switch :model-value="row.is_active" @change="toggleActive(row)" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<el-button size="small" text icon="Edit" @click="openForm(row)" />
<el-button size="small" text type="danger" icon="Delete" @click="del(row)" />
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialog" :title="form.id ? '编辑链接' : '新增链接'" width="480px">
<el-form label-width="80px">
<el-form-item label="标题"><el-input v-model="form.title" placeholder="如:Docker镜像仓库" /></el-form-item>
<el-form-item label="地址"><el-input v-model="form.url" placeholder="http://..." /></el-form-item>
<el-form-item label="分组"><el-input v-model="form.group_name" placeholder="其他链接" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="form.sort_order" :min="0" /></el-form-item>
<el-form-item label="显示"><el-switch v-model="form.is_active" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialog = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.url-link { color: #3b5bdb; text-decoration: none; font-size: 13px; }
.url-link:hover { text-decoration: underline; }
</style>
+504
View File
@@ -0,0 +1,504 @@
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { authApi } from '../../api'
import { setToken } from '../../utils/auth'
const router = useRouter()
const route = useRoute()
const form = ref({ username: '', password: '' })
const loading = ref(false)
async function submit() {
if (!form.value.username || !form.value.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
const { data } = await authApi.login(form.value.username, form.value.password)
setToken(data.token)
ElMessage.success('登录成功')
router.push(route.query.redirect || '/admin/dashboard')
} catch {
/* interceptor already toasted */
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-page">
<!-- Background -->
<div class="login-bg">
<div class="bg-gradient bg-gradient-1"></div>
<div class="bg-gradient bg-gradient-2"></div>
<div class="bg-gradient bg-gradient-3"></div>
<div class="bg-grid"></div>
<div class="bg-float">
<div class="float-shape shape-1"></div>
<div class="float-shape shape-2"></div>
<div class="float-shape shape-3"></div>
</div>
</div>
<!-- Login Card -->
<div class="login-card">
<div class="card-glow"></div>
<!-- Brand header -->
<div class="brand">
<div class="brand-logo">
<span class="logo-glyph"></span>
</div>
<div class="brand-text">
<h1>OPS 资源中心</h1>
<p>内部资源管理后台 · Admin Console</p>
</div>
</div>
<!-- Form section -->
<el-form @submit.prevent="submit" class="login-form">
<div class="form-title">
<span class="form-title-bar"></span>
<span>账号登录</span>
</div>
<div class="form-fields">
<el-input
v-model="form.username"
placeholder="请输入用户名"
size="large"
class="modern-input"
autocomplete="username"
@keyup.enter="submit"
>
<template #prefix>
<el-icon class="i"><User /></el-icon>
</template>
</el-input>
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
show-password
size="large"
class="modern-input"
autocomplete="current-password"
@keyup.enter="submit"
>
<template #prefix>
<el-icon class="i"><Lock /></el-icon>
</template>
</el-input>
</div>
<el-button type="primary" :loading="loading" class="login-btn" @click="submit">
<span v-if="!loading"> </span>
<span v-else>登录中</span>
</el-button>
<div class="form-foot">
<router-link to="/" class="back-link">
<el-icon><ArrowLeft /></el-icon>
返回门户首页
</router-link>
</div>
</el-form>
</div>
</div>
</template>
<style>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 20px;
overflow: hidden;
}
/* Background */
.login-bg {
position: absolute;
inset: 0;
background: linear-gradient(160deg, #0a1424 0%, #0e1c33 40%, #0f172a 100%);
}
.bg-gradient {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
}
.bg-gradient-1 {
width: 600px;
height: 600px;
top: -200px;
right: -100px;
background: radial-gradient(circle, #2dd9f0, transparent 70%);
animation: float-1 20s ease-in-out infinite;
}
.bg-gradient-2 {
width: 500px;
height: 500px;
bottom: -150px;
left: -100px;
background: radial-gradient(circle, #8b5cf6, transparent 70%);
animation: float-2 25s ease-in-out infinite;
}
.bg-gradient-3 {
width: 400px;
height: 400px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: radial-gradient(circle, #f97316, transparent 70%);
animation: float-3 30s ease-in-out infinite;
opacity: 0.25;
}
@keyframes float-1 {
0%, 100% { transform: translate(0, 0); }
33% { transform: translate(-50px, 50px); }
66% { transform: translate(30px, -30px); }
}
@keyframes float-2 {
0%, 100% { transform: translate(0, 0); }
33% { transform: translate(50px, -30px); }
66% { transform: translate(-30px, 40px); }
}
@keyframes float-3 {
0%, 100% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-50%, -50%) scale(1.2); }
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(45, 217, 240, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(45, 217, 240, 0.04) 1px, transparent 1px);
background-size: 50px 50px;
mask-image: radial-gradient(ellipse at center, #000 40%, transparent 70%);
}
.bg-float {
position: absolute;
inset: 0;
overflow: hidden;
}
.float-shape {
position: absolute;
border: 2px solid rgba(255, 255, 255, 0.06);
border-radius: 20px;
background: rgba(255, 255, 255, 0.02);
backdrop-filter: blur(4px);
}
.shape-1 {
width: 100px;
height: 100px;
top: 15%;
left: 10%;
transform: rotate(45deg);
animation: shape-float 15s ease-in-out infinite;
}
.shape-2 {
width: 70px;
height: 70px;
top: 60%;
right: 15%;
transform: rotate(-20deg);
animation: shape-float 18s ease-in-out infinite reverse;
border-radius: 50%;
}
.shape-3 {
width: 120px;
height: 60px;
bottom: 20%;
left: 20%;
transform: rotate(15deg);
animation: shape-float 20s ease-in-out infinite;
}
@keyframes shape-float {
0%, 100% { transform: translateY(0) rotate(var(--rotation, 0deg)); }
50% { transform: translateY(-30px) rotate(var(--rotation, 0deg)); }
}
/* Login Card */
.login-card {
position: relative;
width: 100%;
max-width: 460px;
background: rgba(255, 255, 255, 0.97);
backdrop-filter: blur(20px);
border-radius: 24px;
padding: 44px 48px 36px;
box-shadow:
0 30px 60px -15px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
overflow: hidden;
}
.card-glow {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle at 30% 20%, rgba(45, 217, 240, 0.08), transparent 50%);
pointer-events: none;
}
/* ---------- Brand header ---------- */
.brand {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
position: relative;
}
.brand-logo {
position: relative;
width: 56px;
height: 56px;
border-radius: 16px;
background: linear-gradient(135deg, #0a1424 0%, #1e293b 100%);
border: 2px solid rgba(45, 217, 240, 0.4);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.25),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.brand-logo::after {
content: '';
position: absolute;
inset: -6px;
border-radius: 20px;
background: radial-gradient(circle, rgba(45, 217, 240, 0.25) 0%, transparent 70%);
filter: blur(8px);
z-index: -1;
animation: logo-pulse 3s ease-in-out infinite;
}
.logo-glyph {
font-size: 26px;
color: #2dd9f0;
line-height: 1;
}
@keyframes logo-pulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 0.9; transform: scale(1.08); }
}
.brand-text h1 {
font-size: 22px;
font-weight: 700;
color: #0f172a;
margin: 0 0 4px;
letter-spacing: -0.01em;
line-height: 1.2;
}
.brand-text p {
font-size: 12px;
color: #94a3b8;
margin: 0;
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* ---------- Form section ---------- */
.login-form {
position: relative;
z-index: 1;
}
.form-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 600;
color: #475569;
letter-spacing: 0.04em;
margin-bottom: 14px;
}
.form-title-bar {
width: 3px;
height: 14px;
border-radius: 2px;
background: linear-gradient(180deg, #2dd9f0 0%, #8b5cf6 100%);
}
.form-fields {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 22px;
}
.modern-input :deep(.el-input__wrapper) {
padding: 4px 12px 4px 4px;
border-radius: 12px;
box-shadow: 0 0 0 1px #e2e8f0 inset;
background: #f8fafc;
transition: all 0.25s ease;
}
.modern-input :deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px #cbd5e1 inset;
background: #ffffff;
}
.modern-input :deep(.el-input__wrapper.is-focus) {
box-shadow:
0 0 0 2px rgba(45, 217, 240, 0.18),
0 0 0 1px #2dd9f0 inset;
background: #ffffff;
}
.modern-input :deep(.el-input__inner) {
font-size: 14.5px;
color: #1e293b;
height: 44px;
}
.modern-input :deep(.el-input__inner::placeholder) {
color: #94a3b8;
}
.modern-input :deep(.el-input__prefix) {
display: flex;
align-items: center;
padding: 0 4px 0 14px;
margin-right: 8px;
color: #94a3b8;
transition: color 0.2s ease;
}
.modern-input :deep(.el-input__wrapper.is-focus) .el-input__prefix {
color: #2dd9f0;
}
.modern-input :deep(.el-input__prefix .i) {
font-size: 17px;
}
.modern-input :deep(.el-input__suffix) {
padding-right: 6px;
color: #94a3b8;
}
.modern-input :deep(.el-input__suffix .el-input__icon) {
font-size: 16px;
}
.modern-input :deep(.el-input__clear) {
font-size: 15px;
}
/* ---------- Login Button ---------- */
.login-btn {
width: 100%;
height: 50px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.2em;
color: #fff;
background: linear-gradient(135deg, #2dd9f0 0%, #6366f1 55%, #8b5cf6 100%);
background-size: 200% 200%;
border: none;
box-shadow:
0 8px 24px rgba(45, 217, 240, 0.28),
0 2px 8px rgba(99, 102, 241, 0.18);
transition: all 0.3s ease;
animation: gradient-shift 5s ease infinite;
}
@keyframes gradient-shift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow:
0 14px 34px rgba(45, 217, 240, 0.4),
0 4px 12px rgba(99, 102, 241, 0.28);
}
.login-btn:active {
transform: translateY(0);
}
/* ---------- Back Link ---------- */
.form-foot {
display: flex;
justify-content: center;
margin-top: 22px;
padding-top: 18px;
border-top: 1px dashed #e2e8f0;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13.5px;
color: #64748b;
text-decoration: none;
transition: all 0.2s ease;
}
.back-link:hover {
color: #6366f1;
transform: translateX(-3px);
}
/* ---------- Responsive ---------- */
@media (max-width: 480px) {
.login-card {
padding: 36px 28px 30px;
border-radius: 20px;
}
.brand-logo {
width: 48px;
height: 48px;
}
.logo-glyph {
font-size: 22px;
}
.brand-text h1 {
font-size: 19px;
}
.brand-text p {
font-size: 11px;
}
}
</style>
+635
View File
@@ -0,0 +1,635 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi } from '../../api'
const resources = ref([])
const categories = ref([])
const loading = ref(true)
const filter = ref({ category_id: '', type: '' })
const dialog = ref(false)
const form = ref(emptyForm())
const uploading = ref(false)
const uploadProgress = ref(0)
function emptyForm() {
return {
id: null, category_id: null, title: '', type: 'command',
content: '', url: '', description: '', sort_order: 0,
is_active: true, option_ids: []
}
}
async function handleFileUpload(ev) {
const file = ev.target.files[0]
if (!file) return
uploading.value = true
uploadProgress.value = 0
try {
const res = await adminApi.uploadFile(file, (e) => {
uploadProgress.value = Math.round((e.loaded * 100) / e.total)
})
form.value.url = res.data.url
if (!form.value.title.trim()) {
form.value.title = res.data.filename.replace(/\.[^/.]+$/, '')
}
ElMessage.success('上传成功')
} finally {
uploading.value = false
uploadProgress.value = 0
}
}
const typeMap = { command: '命令', link: '链接', text: '文档', file: '文件' }
const typeColors = {
command: { bg: '#e0f2fe', text: '#0369a1', border: '#7dd3fc' },
link: { bg: '#e0e7ff', text: '#4338ca', border: '#a5b4fc' },
text: { bg: '#dcfce7', text: '#166534', border: '#86efac' },
file: { bg: '#ffedd5', text: '#9a3412', border: '#fdba74' }
}
const formCategory = computed(() => categories.value.find(c => c.id === form.value.category_id))
const needUrl = computed(() => ['link', 'file'].includes(form.value.type))
async function load() {
loading.value = true
try {
const [res, cats] = await Promise.all([
adminApi.listResources({
category_id: filter.value.category_id || undefined,
type: filter.value.type || undefined
}),
adminApi.listCategories()
])
resources.value = res.data
categories.value = cats.data
} finally { loading.value = false }
}
function catName(id) {
return categories.value.find(c => c.id === id)?.name || '—'
}
function openForm(res) {
if (res) {
form.value = {
id: res.id, category_id: res.category_id, title: res.title, type: res.type,
content: res.content, url: res.url, description: res.description,
sort_order: res.sort_order, is_active: res.is_active, option_ids: [...res.option_ids]
}
} else {
form.value = emptyForm()
if (categories.value.length) form.value.category_id = categories.value[0].id
}
dialog.value = true
}
function onCatChange() {
form.value.option_ids = []
}
function toggleOpt(optId) {
const idx = form.value.option_ids.indexOf(optId)
if (idx >= 0) form.value.option_ids.splice(idx, 1)
else form.value.option_ids.push(optId)
}
async function save() {
if (!form.value.title.trim()) return ElMessage.warning('请输入资源标题')
if (!form.value.category_id) return ElMessage.warning('请选择分类')
if (form.value.type === 'command' && !form.value.content.trim()) return ElMessage.warning('请输入命令内容')
if (needUrl.value && !form.value.url.trim()) return ElMessage.warning('请输入链接地址')
const payload = { ...form.value }
if (payload.id) await adminApi.updateResource(payload.id, payload)
else await adminApi.createResource(payload)
ElMessage.success('已保存')
dialog.value = false
load()
}
async function del(res) {
await ElMessageBox.confirm(`删除资源「${res.title}」?`, '警告', { type: 'warning' })
await adminApi.deleteResource(res.id)
ElMessage.success('已删除')
load()
}
async function toggleActive(res) {
await adminApi.updateResource(res.id, { is_active: !res.is_active })
ElMessage.success(res.is_active ? '已下架' : '已上架')
load()
}
onMounted(load)
</script>
<template>
<div v-loading="loading" class="resources-page">
<!-- Header Section -->
<div class="page-header">
<div class="header-info">
<h1>资源管理</h1>
<p>管理所有类型的资源内容</p>
</div>
<div class="header-actions">
<el-select v-model="filter.category_id" placeholder="全部分类" clearable class="filter-select" @change="load">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
<el-select v-model="filter.type" placeholder="全部类型" clearable class="filter-select" @change="load">
<el-option v-for="(v, k) in typeMap" :key="k" :label="v" :value="k" />
</el-select>
<el-button type="primary" class="btn-primary" @click="openForm(null)">
<el-icon><Plus /></el-icon>
发布资源
</el-button>
</div>
</div>
<!-- Resources Table Card -->
<div class="content-card">
<el-table :data="resources" class="modern-table" stripe>
<el-table-column label="类型" width="100" align="center">
<template #default="{ row }">
<span class="type-badge" :style="{
background: typeColors[row.type].bg,
color: typeColors[row.type].text,
borderColor: typeColors[row.type].border
}">
{{ typeMap[row.type] }}
</span>
</template>
</el-table-column>
<el-table-column prop="title" label="资源标题" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<div class="title-cell">
<span class="title-text">{{ row.title }}</span>
<span v-if="row.description" class="title-desc">{{ row.description }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="分类" width="120">
<template #default="{ row }">
<span class="cat-tag">{{ catName(row.category_id) }}</span>
</template>
</el-table-column>
<el-table-column label="内容预览" min-width="260" show-overflow-tooltip>
<template #default="{ row }">
<code v-if="row.type === 'command'" class="cmd-preview">{{ row.content }}</code>
<span v-else-if="row.type === 'link'" class="link-preview">{{ row.url }}</span>
<span v-else>{{ row.content }}</span>
</template>
</el-table-column>
<el-table-column label="筛选标签" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.option_ids.length" size="small" class="opt-tag">
{{ row.option_ids.length }}
</el-tag>
<span v-else class="no-tag">通用</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.is_active"
@change="toggleActive(row)"
size="small"
active-color="#10b981"
inactive-color="#cbd5e1"
/>
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="160" />
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<el-button size="small" class="btn-icon" @click="openForm(row)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button size="small" class="btn-icon btn-danger" @click="del(row)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- Form Dialog -->
<el-dialog v-model="dialog" :title="form.id ? '编辑资源' : '发布资源'" width="680px" class="modern-dialog">
<template #header>
<div class="dialog-header">
<div class="dialog-icon" style="background: linear-gradient(135deg, #8b5cf6, #a78bfa)">
<el-icon><DocumentAdd /></el-icon>
</div>
<div>
<span>{{ form.id ? '编辑资源' : '发布资源' }}</span>
<small>配置资源详细信息</small>
</div>
</div>
</template>
<el-form label-width="100px" class="resource-form">
<el-form-item label="资源标题">
<el-input v-model="form.title" placeholder="输入资源标题,如:配置内部 pip 源" size="large" />
</el-form-item>
<el-form-item label="所属分类">
<el-select v-model="form.category_id" style="width: 100%" size="large" @change="onCatChange">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="资源类型">
<el-radio-group v-model="form.type" class="type-radio-group">
<el-radio-button v-for="(v, k) in typeMap" :key="k" :value="k" class="type-radio">
{{ v }}
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item :label="form.type === 'command' ? '命令内容' : '正文内容'" v-if="form.type !== 'link' || true">
<el-input
v-model="form.content"
type="textarea"
:rows="form.type === 'command' ? 3 : 5"
:placeholder="form.type === 'command' ? '输入命令内容,如:mkdir -p ~/.pip && wget ...' : '输入说明文字(支持多行)'"
resize="none"
/>
</el-form-item>
<el-form-item :label="form.type === 'file' ? '文件地址' : '链接地址'" v-if="needUrl">
<div class="upload-wrap">
<el-input v-model="form.url" placeholder="http://..." style="flex: 1" />
<el-button type="primary" :loading="uploading" @click="$refs.fileInput.click()" class="upload-btn">
{{ uploading ? `上传中 ${uploadProgress}%` : '上传文件' }}
</el-button>
</div>
<input ref="fileInput" type="file" style="display:none" @change="handleFileUpload">
<div v-if="form.url && form.type === 'file'" class="file-preview">
<el-icon><Document /></el-icon>
{{ form.url }}
</div>
</el-form-item>
<el-form-item label="备注说明">
<el-input v-model="form.description" placeholder="显示在标题右侧的简短说明" />
</el-form-item>
<el-form-item label="筛选标签" v-if="formCategory?.rows?.length">
<div class="opt-groups">
<div v-for="row in formCategory.rows" :key="row.id" class="opt-group">
<span class="opt-group-title">{{ row.title }}</span>
<el-checkbox-group v-model="form.option_ids" class="opt-checkbox-group">
<el-checkbox v-for="o in row.options" :key="o.id" :label="o.id" class="opt-checkbox">
{{ o.icon ? o.icon + ' ' : '' }}{{ o.label }}
</el-checkbox>
</el-checkbox-group>
</div>
<div class="opt-tip">
<el-icon><InfoFilled /></el-icon>
不勾选 = 通用资源任何筛选条件下都展示勾选后仅在匹配时展示
</div>
</div>
</el-form-item>
<div class="form-row">
<el-form-item label="排序权重" style="flex: 1">
<el-input-number v-model="form.sort_order" :min="0" :step="1" controls-position="right" />
</el-form-item>
<el-form-item label="立即上架" style="flex: 1">
<el-switch v-model="form.is_active" active-color="#10b981" inactive-color="#cbd5e1" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="dialog = false" class="btn-secondary">取消</el-button>
<el-button type="primary" @click="save" class="btn-primary">保存资源</el-button>
</template>
</el-dialog>
</div>
</template>
<style>
.resources-page {
max-width: 100%;
}
/* Page Header */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.page-header h1 {
font-size: 22px;
font-weight: 700;
color: #0f172a;
margin: 0 0 4px;
}
.page-header p {
font-size: 14px;
color: #64748b;
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
align-items: center;
}
.filter-select {
width: 160px;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border-radius: 10px;
font-weight: 500;
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
border: none;
box-shadow: 0 4px 14px rgba(139, 92, 246, 0.35);
transition: all 0.2s ease;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.45);
}
/* Content Card */
.content-card {
background: #ffffff;
border-radius: 16px;
border: 1px solid #e2e8f0;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
/* Modern Table */
.content-card :deep(.el-table__header-wrapper) {
background: linear-gradient(to bottom, #f8fafc, #ffffff);
}
.content-card :deep(.el-table__header th) {
font-weight: 600;
color: #475569;
background: transparent;
border-bottom: 2px solid #e2e8f0;
}
.content-card :deep(.el-table__body tr) {
transition: all 0.15s ease;
}
.content-card :deep(.el-table__body tr:hover) {
background: #f8fafc !important;
}
.content-card :deep(.el-table__body tr:hover td) {
background: transparent !important;
}
.type-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
border: 1px solid;
}
.title-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.title-text {
font-weight: 500;
color: #0f172a;
}
.title-desc {
font-size: 12px;
color: #94a3b8;
}
.cat-tag {
background: #f1f5f9;
color: #475569;
padding: 3px 10px;
border-radius: 6px;
font-size: 12px;
}
.cmd-preview {
font-family: 'JetBrains Mono', ui-monospace, Consolas, monospace;
font-size: 12px;
background: #0f172a;
color: #e2e8f0;
padding: 4px 10px;
border-radius: 6px;
}
.link-preview {
color: #6366f1;
font-size: 13px;
}
.opt-tag {
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
color: #4338ca;
border: none;
font-weight: 500;
}
.no-tag {
color: #94a3b8;
font-size: 13px;
}
.btn-icon {
padding: 6px 10px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #ffffff;
color: #64748b;
transition: all 0.15s ease;
}
.btn-icon:hover {
border-color: #cbd5e1;
background: #f8fafc;
color: #475569;
}
.btn-danger:hover {
border-color: rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.05);
color: #ef4444;
}
/* Dialog Styles */
.modern-dialog :deep(.el-dialog) {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.3);
}
.modern-dialog :deep(.el-dialog__header) {
padding: 24px 28px 20px;
background: linear-gradient(135deg, #f8fafc 0%, #ffffff 100%);
border-bottom: 1px solid #e2e8f0;
margin: 0;
}
.dialog-header {
display: flex;
align-items: center;
gap: 14px;
}
.dialog-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.dialog-header > div {
display: flex;
flex-direction: column;
}
.dialog-header span {
font-size: 18px;
font-weight: 600;
color: #0f172a;
}
.dialog-header small {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
.modern-dialog :deep(.el-dialog__body) {
padding: 28px;
}
.resource-form :deep(.el-form-item__label) {
font-weight: 500;
color: #334155;
}
.type-radio-group :deep(.el-radio-button__inner) {
padding: 10px 20px;
font-weight: 500;
}
.type-radio-group :deep(.el-radio-button.is-active .el-radio-button__inner) {
background: linear-gradient(135deg, #8b5cf6, #6366f1);
border-color: #6366f1;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
.upload-wrap {
display: flex;
gap: 10px;
}
.upload-btn {
background: linear-gradient(135deg, #8b5cf6, #6366f1);
border: none;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.25);
}
.file-preview {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 10px 14px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
font-size: 13px;
color: #475569;
}
.opt-groups {
width: 100%;
}
.opt-group {
margin-bottom: 12px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.opt-group-title {
font-size: 14px;
color: #475569;
font-weight: 500;
margin-right: 8px;
}
.opt-checkbox {
margin-right: 16px;
}
.opt-tip {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #64748b;
margin-top: 8px;
padding: 10px 14px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.form-row {
display: flex;
gap: 20px;
}
.modern-dialog :deep(.el-dialog__footer) {
padding: 16px 28px 24px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.btn-secondary {
padding: 10px 24px;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: #ffffff;
color: #475569;
font-weight: 500;
transition: all 0.2s ease;
}
.btn-secondary:hover {
border-color: #cbd5e1;
background: #f8fafc;
color: #334155;
}
</style>
+59
View File
@@ -0,0 +1,59 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { adminApi } from '../../api'
const form = ref({})
const loading = ref(true)
const saving = ref(false)
const fields = [
{ key: 'site_name', label: '站点名称', placeholder: 'OPS 资源中心', type: 'input' },
{ key: 'hero_tag', label: 'Hero 标签', placeholder: 'INTERNAL RESOURCES', type: 'input' },
{ key: 'hero_title', label: 'Hero 主标题', placeholder: '内部资源 快速上手', type: 'input', tip: '「快速上手」或「GET STARTED」会高亮显示' },
{ key: 'hero_subtitle', label: 'Hero 副标题', placeholder: '执行一条命令完成初始化…', type: 'textarea' },
{ key: 'hero_command', label: 'Hero 命令', placeholder: 'curl -sSL http://ops.internal/init.sh | bash', type: 'input' },
{ key: 'hero_command_label', label: '命令栏标签', placeholder: 'RUN THE COMMAND TO INSTALL', type: 'input' },
{ key: 'announcement', label: '公告(滚动条)', placeholder: '留空则不显示公告条', type: 'textarea' },
{ key: 'footer_text', label: '页脚文字', placeholder: 'OPS Resource Portal · 内部使用', type: 'input' }
]
onMounted(async () => {
try {
const { data } = await adminApi.getSettings()
form.value = data
} finally { loading.value = false }
})
async function save() {
saving.value = true
try {
await adminApi.updateSettings(form.value)
ElMessage.success('已保存,刷新门户页面查看效果')
} finally { saving.value = false }
}
</script>
<template>
<el-card v-loading="loading" style="max-width:760px">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<b>站点设置门户首页文案</b>
<el-button type="primary" :loading="saving" icon="Check" @click="save">保存设置</el-button>
</div>
</template>
<el-form label-width="130px" label-position="right">
<el-form-item v-for="f in fields" :key="f.key" :label="f.label">
<el-input v-if="f.type === 'textarea'" v-model="form[f.key]" type="textarea" :rows="2"
:placeholder="f.placeholder" />
<el-input v-else v-model="form[f.key]" :placeholder="f.placeholder" />
<div v-if="f.tip" class="field-tip">{{ f.tip }}</div>
</el-form-item>
</el-form>
</el-card>
</template>
<style scoped>
.field-tip { font-size: 12px; color: #b3bfcc; margin-top: 4px; line-height: 1.5; }
</style>
+119
View File
@@ -0,0 +1,119 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminApi } from '../../api'
const users = ref([])
const loading = ref(true)
const dialog = ref(false)
const form = ref({ username: '', password: '' })
const resetPwdDialog = ref(false)
const resetPwdForm = ref({ id: null, password: '' })
async function load() {
loading.value = true
try {
const res = await adminApi.listUsers()
users.value = res.data
} finally { loading.value = false }
}
function openCreate() {
form.value = { username: '', password: '' }
dialog.value = true
}
async function createUser() {
if (!form.value.username.trim()) return ElMessage.warning('请输入用户名')
if (!form.value.password || form.value.password.length < 6) return ElMessage.warning('密码至少6位')
await adminApi.createUser(form.value)
ElMessage.success('用户创建成功')
dialog.value = false
load()
}
function openResetPwd(user) {
resetPwdForm.value = { id: user.id, password: '' }
resetPwdDialog.value = true
}
async function resetPassword() {
if (!resetPwdForm.value.password || resetPwdForm.value.password.length < 6) {
return ElMessage.warning('密码至少6位')
}
await adminApi.resetUserPassword(resetPwdForm.value.id, resetPwdForm.value.password)
ElMessage.success('密码已重置')
resetPwdDialog.value = false
}
async function deleteUser(user) {
if (user.username === 'admin') {
return ElMessage.warning('不能删除内置 admin 用户')
}
await ElMessageBox.confirm(`删除用户「${user.username}」?`, '警告', { type: 'warning' })
await adminApi.deleteUser(user.id)
ElMessage.success('已删除')
load()
}
onMounted(load)
</script>
<template>
<div v-loading="loading">
<el-card>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<b>用户管理</b>
<el-button type="primary" icon="Plus" @click="openCreate">添加用户</el-button>
</div>
</template>
<el-table :data="users">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" text icon="Key" @click="openResetPwd(row)">
重置密码
</el-button>
<el-button size="small" text type="danger" icon="Delete" @click="deleteUser(row)"
:disabled="row.username === 'admin'">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- Create User Dialog -->
<el-dialog v-model="dialog" title="添加用户" width="420px">
<el-form label-width="80px">
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="至少3个字符" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="至少6个字符" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialog = false">取消</el-button>
<el-button type="primary" @click="createUser">创建</el-button>
</template>
</el-dialog>
<!-- Reset Password Dialog -->
<el-dialog v-model="resetPwdDialog" title="重置密码" width="420px">
<el-form label-width="80px">
<el-form-item label="新密码">
<el-input v-model="resetPwdForm.password" type="password" placeholder="至少6个字符" show-password />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resetPwdDialog = false">取消</el-button>
<el-button type="primary" @click="resetPassword">确认重置</el-button>
</template>
</el-dialog>
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 3001,
allowedHosts: true,
proxy: {
'/api': { target: 'http://127.0.0.1:8090', changeOrigin: true }
}
},
build: {
outDir: 'dist',
chunkSizeWarningLimit: 1500
}
})