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:
+46
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""App factory. Serves API + (in production) the built Vue frontend."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Flask, send_from_directory
|
||||||
|
|
||||||
|
from config import BASE_DIR, Config, UPLOAD_DIR, UPLOAD_URL_PREFIX
|
||||||
|
from extensions import db
|
||||||
|
|
||||||
|
DIST_DIR = os.path.join(BASE_DIR, "..", "frontend", "dist")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__, static_folder=None)
|
||||||
|
app.config.from_object(Config)
|
||||||
|
|
||||||
|
os.makedirs(os.path.join(BASE_DIR, "instance"), exist_ok=True)
|
||||||
|
db.init_app(app)
|
||||||
|
db.app = app
|
||||||
|
|
||||||
|
from routes import auth as auth_routes
|
||||||
|
from routes import portal as portal_routes
|
||||||
|
from routes import admin as admin_routes
|
||||||
|
app.register_blueprint(auth_routes.bp)
|
||||||
|
app.register_blueprint(portal_routes.bp)
|
||||||
|
app.register_blueprint(admin_routes.bp)
|
||||||
|
|
||||||
|
@app.route("/api/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Serve uploaded files
|
||||||
|
os.makedirs(Config.UPLOAD_FOLDER, exist_ok=True)
|
||||||
|
@app.route(UPLOAD_URL_PREFIX + "/<path:filename>")
|
||||||
|
def uploaded_file(filename):
|
||||||
|
return send_from_directory(Config.UPLOAD_FOLDER, filename)
|
||||||
|
|
||||||
|
# ---- Serve built Vue SPA in production (after `npm run build`) ----
|
||||||
|
if os.path.isdir(DIST_DIR):
|
||||||
|
@app.route("/", defaults={"path": ""})
|
||||||
|
@app.route("/<path:path>")
|
||||||
|
def spa(path):
|
||||||
|
full = os.path.join(DIST_DIR, path)
|
||||||
|
if path and os.path.isfile(full):
|
||||||
|
return send_from_directory(DIST_DIR, path)
|
||||||
|
return send_from_directory(DIST_DIR, "index.html")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
from seed import seed_all
|
||||||
|
seed_all()
|
||||||
|
app.run(host="0.0.0.0", port=8090, debug=False)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Auth helpers: JWT issue/verify + admin guard."""
|
||||||
|
import functools
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from flask import current_app, jsonify, request
|
||||||
|
|
||||||
|
from models import User
|
||||||
|
|
||||||
|
|
||||||
|
def issue_token(user):
|
||||||
|
payload = {
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"exp": datetime.now(timezone.utc) + timedelta(hours=current_app.config["JWT_EXPIRE_HOURS"]),
|
||||||
|
"iat": datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, current_app.config["SECRET_KEY"], algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token):
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, current_app.config["SECRET_KEY"], algorithms=["HS256"])
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user():
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
payload = decode_token(auth[7:])
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
return User.query.get(payload["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(fn):
|
||||||
|
@functools.wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "未登录或登录已过期"}), 401
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
UPLOAD_DIR = os.environ.get("OPS_UPLOAD_DIR", os.path.join(BASE_DIR, "uploads"))
|
||||||
|
UPLOAD_URL_PREFIX = os.environ.get("OPS_UPLOAD_URL_PREFIX", "/uploads")
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
SECRET_KEY = os.environ.get("OPS_SECRET_KEY", "ops-portal-dev-secret-change-me-32b!")
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||||
|
"OPS_DATABASE_URL", "sqlite:///" + os.path.join(BASE_DIR, "instance", "ops_portal.db")
|
||||||
|
)
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
JWT_EXPIRE_HOURS = int(os.environ.get("OPS_JWT_EXPIRE_HOURS", "12"))
|
||||||
|
MAX_CONTENT_LENGTH = 32 * 1024 * 1024
|
||||||
|
JSON_AS_ASCII = False
|
||||||
|
UPLOAD_FOLDER = UPLOAD_DIR
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""SQLAlchemy models for OPS Resource Portal."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Association table: resource <-> option (faceted tagging)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
resource_options = db.Table(
|
||||||
|
"resource_options",
|
||||||
|
db.Column("resource_id", db.Integer, db.ForeignKey("resources.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
db.Column("option_id", db.Integer, db.ForeignKey("options.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
__tablename__ = "users"
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||||
|
password = db.Column(db.String(128), nullable=False) # bcrypt hash
|
||||||
|
created_at = db.Column(db.DateTime, default=utcnow)
|
||||||
|
|
||||||
|
def set_password(self, raw):
|
||||||
|
import bcrypt
|
||||||
|
self.password = bcrypt.hashpw(raw.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
def check_password(self, raw):
|
||||||
|
import bcrypt
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(raw.encode("utf-8"), self.password.encode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SiteSetting(db.Model):
|
||||||
|
"""Key-value site configuration editable from the admin panel."""
|
||||||
|
__tablename__ = "site_settings"
|
||||||
|
key = db.Column(db.String(64), primary_key=True)
|
||||||
|
value = db.Column(db.Text, default="")
|
||||||
|
updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Category(db.Model):
|
||||||
|
"""Left-nav menu entry, e.g. 基础设备 / pypi源."""
|
||||||
|
__tablename__ = "categories"
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(80), nullable=False)
|
||||||
|
icon = db.Column(db.String(16), default="📁") # emoji icon
|
||||||
|
description = db.Column(db.String(255), default="")
|
||||||
|
sort_order = db.Column(db.Integer, default=0)
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=utcnow)
|
||||||
|
|
||||||
|
rows = db.relationship("OptionRow", backref="category", lazy=True,
|
||||||
|
cascade="all, delete-orphan", order_by="OptionRow.sort_order")
|
||||||
|
resources = db.relationship("Resource", backref="category", lazy=True,
|
||||||
|
cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
def to_dict(self, with_rows=True):
|
||||||
|
d = {
|
||||||
|
"id": self.id, "name": self.name, "icon": self.icon,
|
||||||
|
"description": self.description, "sort_order": self.sort_order,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"resource_count": len(self.resources),
|
||||||
|
}
|
||||||
|
if with_rows:
|
||||||
|
d["rows"] = [r.to_dict() for r in self.rows]
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class OptionRow(db.Model):
|
||||||
|
"""A selection row inside a category, e.g. 运行环境 / 操作系统."""
|
||||||
|
__tablename__ = "option_rows"
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
category_id = db.Column(db.Integer, db.ForeignKey("categories.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
title = db.Column(db.String(80), nullable=False) # e.g. 运行环境
|
||||||
|
sort_order = db.Column(db.Integer, default=0)
|
||||||
|
|
||||||
|
options = db.relationship("Option", backref="row", lazy=True,
|
||||||
|
cascade="all, delete-orphan", order_by="Option.sort_order")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id, "title": self.title, "sort_order": self.sort_order,
|
||||||
|
"options": [o.to_dict() for o in self.options],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Option(db.Model):
|
||||||
|
"""A selectable tile inside a row, e.g. 物理机 / ubuntu."""
|
||||||
|
__tablename__ = "options"
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
row_id = db.Column(db.Integer, db.ForeignKey("option_rows.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
label = db.Column(db.String(80), nullable=False)
|
||||||
|
icon = db.Column(db.String(16), default="")
|
||||||
|
sort_order = db.Column(db.Integer, default=0)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"id": self.id, "label": self.label, "icon": self.icon, "sort_order": self.sort_order}
|
||||||
|
|
||||||
|
|
||||||
|
class Resource(db.Model):
|
||||||
|
"""A published internal resource: command / link / text / file."""
|
||||||
|
__tablename__ = "resources"
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
category_id = db.Column(db.Integer, db.ForeignKey("categories.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
title = db.Column(db.String(160), nullable=False)
|
||||||
|
type = db.Column(db.String(16), nullable=False, default="command") # command|link|text|file
|
||||||
|
content = db.Column(db.Text, default="") # command text / rich text / note
|
||||||
|
url = db.Column(db.String(500), default="") # link target / file URL
|
||||||
|
description = db.Column(db.String(255), default="")
|
||||||
|
sort_order = db.Column(db.Integer, default=0)
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
options = db.relationship("Option", secondary=resource_options, lazy="subquery",
|
||||||
|
backref=db.backref("resources", lazy=True))
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id, "category_id": self.category_id, "title": self.title,
|
||||||
|
"type": self.type, "content": self.content, "url": self.url,
|
||||||
|
"description": self.description, "sort_order": self.sort_order,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"option_ids": sorted(o.id for o in self.options),
|
||||||
|
"created_at": self.created_at.strftime("%Y-%m-%d %H:%M") if self.created_at else "",
|
||||||
|
"updated_at": self.updated_at.strftime("%Y-%m-%d %H:%M") if self.updated_at else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SideLink(db.Model):
|
||||||
|
"""Right-panel 其他链接 entry."""
|
||||||
|
__tablename__ = "side_links"
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(160), nullable=False)
|
||||||
|
url = db.Column(db.String(500), nullable=False)
|
||||||
|
group_name = db.Column(db.String(80), default="其他链接")
|
||||||
|
sort_order = db.Column(db.Integer, default=0)
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=utcnow)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id, "title": self.title, "url": self.url,
|
||||||
|
"group_name": self.group_name, "sort_order": self.sort_order,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Flask>=3.0
|
||||||
|
Flask-SQLAlchemy>=3.1
|
||||||
|
Flask-Cors>=4.0
|
||||||
|
PyJWT>=2.8
|
||||||
|
bcrypt>=4.0
|
||||||
|
gunicorn>=21.2
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# intentionally empty — prevents circular imports (see app factory)
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
"""Admin CRUD API — all endpoints require a valid JWT."""
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from flask import Blueprint, jsonify, request, current_app
|
||||||
|
|
||||||
|
from auth import admin_required
|
||||||
|
from config import UPLOAD_URL_PREFIX
|
||||||
|
from extensions import db
|
||||||
|
from models import Category, Option, OptionRow, Resource, SideLink, SiteSetting, User
|
||||||
|
|
||||||
|
bp = Blueprint("admin", __name__, url_prefix="/api/admin")
|
||||||
|
|
||||||
|
RESOURCE_TYPES = {"command", "link", "text", "file"}
|
||||||
|
ALLOWED_EXTENSIONS = {"txt", "pdf", "png", "jpg", "jpeg", "gif", "zip", "tar", "gz", "sh", "py", "json", "yaml", "yml", "deb", "rpm", "iso"}
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_file(filename):
|
||||||
|
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dashboard stats
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@bp.route("/stats")
|
||||||
|
@admin_required
|
||||||
|
def stats():
|
||||||
|
return jsonify({
|
||||||
|
"categories": Category.query.count(),
|
||||||
|
"resources": Resource.query.count(),
|
||||||
|
"active_resources": Resource.query.filter_by(is_active=True).count(),
|
||||||
|
"links": SideLink.query.count(),
|
||||||
|
"options": Option.query.count(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Categories
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@bp.route("/categories")
|
||||||
|
@admin_required
|
||||||
|
def list_categories():
|
||||||
|
cats = Category.query.order_by(Category.sort_order, Category.id).all()
|
||||||
|
return jsonify([c.to_dict() for c in cats])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/categories", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def create_category():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if not (data.get("name") or "").strip():
|
||||||
|
return jsonify({"error": "分类名称不能为空"}), 400
|
||||||
|
max_order = db.session.query(db.func.max(Category.sort_order)).scalar() or 0
|
||||||
|
cat = Category(
|
||||||
|
name=data["name"].strip(),
|
||||||
|
icon=data.get("icon") or "📁",
|
||||||
|
description=data.get("description") or "",
|
||||||
|
sort_order=data.get("sort_order", max_order + 1),
|
||||||
|
is_active=data.get("is_active", True),
|
||||||
|
)
|
||||||
|
db.session.add(cat)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(cat.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/categories/<int:cid>", methods=["PUT"])
|
||||||
|
@admin_required
|
||||||
|
def update_category(cid):
|
||||||
|
cat = Category.query.get_or_404(cid)
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if "name" in data:
|
||||||
|
if not (data["name"] or "").strip():
|
||||||
|
return jsonify({"error": "分类名称不能为空"}), 400
|
||||||
|
cat.name = data["name"].strip()
|
||||||
|
for f in ("icon", "description", "sort_order", "is_active"):
|
||||||
|
if f in data:
|
||||||
|
setattr(cat, f, data[f])
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(cat.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/categories/<int:cid>", methods=["DELETE"])
|
||||||
|
@admin_required
|
||||||
|
def delete_category(cid):
|
||||||
|
cat = Category.query.get_or_404(cid)
|
||||||
|
db.session.delete(cat)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "已删除"})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/categories/<int:cid>/move", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def move_category(cid):
|
||||||
|
cat = Category.query.get_or_404(cid)
|
||||||
|
direction = (request.get_json(silent=True) or {}).get("direction", "up")
|
||||||
|
ordered = Category.query.order_by(Category.sort_order, Category.id).all()
|
||||||
|
idx = next(i for i, c in enumerate(ordered) if c.id == cid)
|
||||||
|
target = idx - 1 if direction == "up" else idx + 1
|
||||||
|
if 0 <= target < len(ordered):
|
||||||
|
ordered[idx].sort_order, ordered[target].sort_order = (
|
||||||
|
ordered[target].sort_order, ordered[idx].sort_order)
|
||||||
|
# break ties if both had the same sort_order
|
||||||
|
if ordered[idx].sort_order == ordered[target].sort_order:
|
||||||
|
ordered[target].sort_order += 1 if direction == "up" else -1
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Option rows & options
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@bp.route("/categories/<int:cid>/rows", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def create_row(cid):
|
||||||
|
Category.query.get_or_404(cid)
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if not (data.get("title") or "").strip():
|
||||||
|
return jsonify({"error": "行标题不能为空"}), 400
|
||||||
|
max_order = db.session.query(db.func.max(OptionRow.sort_order)).filter_by(category_id=cid).scalar() or 0
|
||||||
|
row = OptionRow(category_id=cid, title=data["title"].strip(),
|
||||||
|
sort_order=data.get("sort_order", max_order + 1))
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(row.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/rows/<int:rid>", methods=["PUT"])
|
||||||
|
@admin_required
|
||||||
|
def update_row(rid):
|
||||||
|
row = OptionRow.query.get_or_404(rid)
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if "title" in data:
|
||||||
|
row.title = data["title"].strip()
|
||||||
|
if "sort_order" in data:
|
||||||
|
row.sort_order = data["sort_order"]
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(row.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/rows/<int:rid>", methods=["DELETE"])
|
||||||
|
@admin_required
|
||||||
|
def delete_row(rid):
|
||||||
|
row = OptionRow.query.get_or_404(rid)
|
||||||
|
db.session.delete(row)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "已删除"})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/rows/<int:rid>/options", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def create_option(rid):
|
||||||
|
OptionRow.query.get_or_404(rid)
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if not (data.get("label") or "").strip():
|
||||||
|
return jsonify({"error": "选项名称不能为空"}), 400
|
||||||
|
max_order = db.session.query(db.func.max(Option.sort_order)).filter_by(row_id=rid).scalar() or 0
|
||||||
|
opt = Option(row_id=rid, label=data["label"].strip(),
|
||||||
|
icon=data.get("icon") or "",
|
||||||
|
sort_order=data.get("sort_order", max_order + 1))
|
||||||
|
db.session.add(opt)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(opt.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/options/<int:oid>", methods=["PUT"])
|
||||||
|
@admin_required
|
||||||
|
def update_option(oid):
|
||||||
|
opt = Option.query.get_or_404(oid)
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
for f in ("label", "icon", "sort_order"):
|
||||||
|
if f in data:
|
||||||
|
setattr(opt, f, data[f])
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(opt.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/options/<int:oid>", methods=["DELETE"])
|
||||||
|
@admin_required
|
||||||
|
def delete_option(oid):
|
||||||
|
opt = Option.query.get_or_404(oid)
|
||||||
|
db.session.delete(opt)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "已删除"})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Resources
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@bp.route("/resources")
|
||||||
|
@admin_required
|
||||||
|
def list_resources():
|
||||||
|
q = Resource.query
|
||||||
|
cid = request.args.get("category_id", type=int)
|
||||||
|
if cid:
|
||||||
|
q = q.filter_by(category_id=cid)
|
||||||
|
kw = (request.args.get("keyword") or "").strip()
|
||||||
|
if kw:
|
||||||
|
q = q.filter(Resource.title.like(f"%{kw}%"))
|
||||||
|
items = q.order_by(Resource.category_id, Resource.sort_order, Resource.id).all()
|
||||||
|
return jsonify([r.to_dict() for r in items])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/resources", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def create_resource():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
err = _validate_resource(data, require_category=True)
|
||||||
|
if err:
|
||||||
|
return jsonify({"error": err}), 400
|
||||||
|
max_order = db.session.query(db.func.max(Resource.sort_order)).filter_by(
|
||||||
|
category_id=data["category_id"]).scalar() or 0
|
||||||
|
res = Resource(
|
||||||
|
category_id=data["category_id"],
|
||||||
|
title=data["title"].strip(),
|
||||||
|
type=data.get("type", "command"),
|
||||||
|
content=data.get("content") or "",
|
||||||
|
url=data.get("url") or "",
|
||||||
|
description=data.get("description") or "",
|
||||||
|
sort_order=data.get("sort_order", max_order + 1),
|
||||||
|
is_active=data.get("is_active", True),
|
||||||
|
)
|
||||||
|
_apply_option_tags(res, data.get("option_ids") or [])
|
||||||
|
db.session.add(res)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(res.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/resources/<int:rid>", methods=["PUT"])
|
||||||
|
@admin_required
|
||||||
|
def update_resource(rid):
|
||||||
|
res = Resource.query.get_or_404(rid)
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
err = _validate_resource(data, require_category=False)
|
||||||
|
if err:
|
||||||
|
return jsonify({"error": err}), 400
|
||||||
|
for f in ("category_id", "title", "type", "content", "url",
|
||||||
|
"description", "sort_order", "is_active"):
|
||||||
|
if f in data:
|
||||||
|
value = data[f]
|
||||||
|
if f == "title":
|
||||||
|
value = value.strip()
|
||||||
|
setattr(res, f, value)
|
||||||
|
if "option_ids" in data:
|
||||||
|
_apply_option_tags(res, data["option_ids"] or [])
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(res.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/resources/<int:rid>", methods=["DELETE"])
|
||||||
|
@admin_required
|
||||||
|
def delete_resource(rid):
|
||||||
|
res = Resource.query.get_or_404(rid)
|
||||||
|
db.session.delete(res)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "已删除"})
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_resource(data, require_category):
|
||||||
|
if require_category and not data.get("category_id"):
|
||||||
|
return "必须选择所属分类"
|
||||||
|
if require_category and not (data.get("title") or "").strip():
|
||||||
|
return "标题不能为空"
|
||||||
|
if "title" in data and not (data["title"] or "").strip():
|
||||||
|
return "标题不能为空"
|
||||||
|
rtype = data.get("type")
|
||||||
|
if rtype is not None and rtype not in RESOURCE_TYPES:
|
||||||
|
return f"未知资源类型: {rtype}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_option_tags(res, option_ids):
|
||||||
|
opts = Option.query.filter(Option.id.in_(option_ids)).all() if option_ids else []
|
||||||
|
res.options = opts
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Side links
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@bp.route("/links")
|
||||||
|
@admin_required
|
||||||
|
def list_links():
|
||||||
|
return jsonify([l.to_dict() for l in
|
||||||
|
SideLink.query.order_by(SideLink.sort_order, SideLink.id).all()])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/links", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def create_link():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if not (data.get("title") or "").strip() or not (data.get("url") or "").strip():
|
||||||
|
return jsonify({"error": "标题和链接地址不能为空"}), 400
|
||||||
|
max_order = db.session.query(db.func.max(SideLink.sort_order)).scalar() or 0
|
||||||
|
link = SideLink(
|
||||||
|
title=data["title"].strip(), url=data["url"].strip(),
|
||||||
|
group_name=data.get("group_name") or "其他链接",
|
||||||
|
sort_order=data.get("sort_order", max_order + 1),
|
||||||
|
is_active=data.get("is_active", True),
|
||||||
|
)
|
||||||
|
db.session.add(link)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(link.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/links/<int:lid>", methods=["PUT"])
|
||||||
|
@admin_required
|
||||||
|
def update_link(lid):
|
||||||
|
link = SideLink.query.get_or_404(lid)
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
for f in ("title", "url", "group_name", "sort_order", "is_active"):
|
||||||
|
if f in data:
|
||||||
|
setattr(link, f, data[f])
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(link.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/links/<int:lid>", methods=["DELETE"])
|
||||||
|
@admin_required
|
||||||
|
def delete_link(lid):
|
||||||
|
link = SideLink.query.get_or_404(lid)
|
||||||
|
db.session.delete(link)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "已删除"})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Site settings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@bp.route("/settings", methods=["GET"])
|
||||||
|
@admin_required
|
||||||
|
def get_settings():
|
||||||
|
return jsonify({s.key: s.value for s in SiteSetting.query.all()})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/settings", methods=["PUT"])
|
||||||
|
@admin_required
|
||||||
|
def update_settings():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
for key, value in data.items():
|
||||||
|
s = SiteSetting.query.get(key)
|
||||||
|
if s:
|
||||||
|
s.value = value if value is not None else ""
|
||||||
|
else:
|
||||||
|
db.session.add(SiteSetting(key=key, value=value or ""))
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "已保存"})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Admin users (minimal: list + reset password)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@bp.route("/users")
|
||||||
|
@admin_required
|
||||||
|
def list_users():
|
||||||
|
return jsonify([{"id": u.id, "username": u.username} for u in User.query.all()])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/users", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def create_user():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
username = (data.get("username") or "").strip()
|
||||||
|
password = (data.get("password") or "").strip()
|
||||||
|
if not username or len(username) < 3:
|
||||||
|
return jsonify({"error": "用户名至少3个字符"}), 400
|
||||||
|
if not password or len(password) < 6:
|
||||||
|
return jsonify({"error": "密码至少6个字符"}), 400
|
||||||
|
if User.query.filter_by(username=username).first():
|
||||||
|
return jsonify({"error": "用户名已存在"}), 400
|
||||||
|
u = User(username=username)
|
||||||
|
u.set_password(password)
|
||||||
|
db.session.add(u)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"id": u.id, "username": u.username}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/users/<int:uid>/password", methods=["PUT"])
|
||||||
|
@admin_required
|
||||||
|
def reset_user_password(uid):
|
||||||
|
u = User.query.get_or_404(uid)
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
password = (data.get("password") or "").strip()
|
||||||
|
if not password or len(password) < 6:
|
||||||
|
return jsonify({"error": "密码至少6个字符"}), 400
|
||||||
|
u.set_password(password)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "密码已重置"})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/users/<int:uid>", methods=["DELETE"])
|
||||||
|
@admin_required
|
||||||
|
def delete_user(uid):
|
||||||
|
u = User.query.get_or_404(uid)
|
||||||
|
if u.username == "admin":
|
||||||
|
return jsonify({"error": "不能删除内置 admin 用户"}), 400
|
||||||
|
db.session.delete(u)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "用户已删除"})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File upload
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@bp.route("/upload", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def upload_file():
|
||||||
|
if "file" not in request.files:
|
||||||
|
return jsonify({"error": "未找到文件"}), 400
|
||||||
|
f = request.files["file"]
|
||||||
|
if not f or not f.filename:
|
||||||
|
return jsonify({"error": "未选择文件"}), 400
|
||||||
|
if not allowed_file(f.filename):
|
||||||
|
return jsonify({"error": "不支持的文件类型"}), 400
|
||||||
|
|
||||||
|
filename = secure_filename(f.filename)
|
||||||
|
# Add unique prefix
|
||||||
|
name, ext = os.path.splitext(filename)
|
||||||
|
unique_name = f"{name}_{uuid.uuid4().hex[:8]}{ext}"
|
||||||
|
os.makedirs(current_app.config["UPLOAD_FOLDER"], exist_ok=True)
|
||||||
|
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], unique_name)
|
||||||
|
f.save(filepath)
|
||||||
|
|
||||||
|
file_url = f"{UPLOAD_URL_PREFIX}/{unique_name}"
|
||||||
|
return jsonify({
|
||||||
|
"url": file_url,
|
||||||
|
"filename": filename,
|
||||||
|
"size": os.path.getsize(filepath)
|
||||||
|
}), 201
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Auth API."""
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from auth import get_current_user, issue_token
|
||||||
|
from models import User
|
||||||
|
|
||||||
|
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/login", methods=["POST"])
|
||||||
|
def login():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
username = (data.get("username") or "").strip()
|
||||||
|
password = data.get("password") or ""
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
if not user or not user.check_password(password):
|
||||||
|
return jsonify({"error": "用户名或密码错误"}), 401
|
||||||
|
return jsonify({"token": issue_token(user), "username": user.username})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/me")
|
||||||
|
def me():
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "未登录"}), 401
|
||||||
|
return jsonify({"username": user.username})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/password", methods=["POST"])
|
||||||
|
def change_password():
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "未登录"}), 401
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
old = data.get("old_password") or ""
|
||||||
|
new = data.get("new_password") or ""
|
||||||
|
if not user.check_password(old):
|
||||||
|
return jsonify({"error": "原密码错误"}), 400
|
||||||
|
if len(new) < 6:
|
||||||
|
return jsonify({"error": "新密码至少 6 位"}), 400
|
||||||
|
from extensions import db
|
||||||
|
user.set_password(new)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"message": "密码已修改"})
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Public portal API — no auth required."""
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from models import Category, Option, Resource, SideLink, SiteSetting
|
||||||
|
|
||||||
|
bp = Blueprint("portal", __name__, url_prefix="/api/portal")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/bootstrap")
|
||||||
|
def bootstrap():
|
||||||
|
"""Everything the portal page needs in one request."""
|
||||||
|
settings = {s.key: s.value for s in SiteSetting.query.all()}
|
||||||
|
categories = (
|
||||||
|
Category.query.filter_by(is_active=True)
|
||||||
|
.order_by(Category.sort_order, Category.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
links = (
|
||||||
|
SideLink.query.filter_by(is_active=True)
|
||||||
|
.order_by(SideLink.sort_order, SideLink.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
"settings": settings,
|
||||||
|
"categories": [c.to_dict() for c in categories],
|
||||||
|
"links": [l.to_dict() for l in links],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/categories/<int:cid>/resources")
|
||||||
|
def category_resources(cid):
|
||||||
|
"""Resources for a category, faceted by selected option ids."""
|
||||||
|
category = Category.query.get_or_404(cid)
|
||||||
|
selected = []
|
||||||
|
raw = request.args.get("option_ids", "")
|
||||||
|
keyword = request.args.get("keyword", "").strip()
|
||||||
|
if raw.strip():
|
||||||
|
try:
|
||||||
|
selected = [int(x) for x in raw.split(",") if x.strip()]
|
||||||
|
except ValueError:
|
||||||
|
selected = []
|
||||||
|
|
||||||
|
query = Resource.query.filter_by(category_id=cid, is_active=True)
|
||||||
|
if keyword:
|
||||||
|
query = query.filter(
|
||||||
|
Resource.title.like(f"%{keyword}%") |
|
||||||
|
Resource.description.like(f"%{keyword}%") |
|
||||||
|
Resource.content.like(f"%{keyword}%")
|
||||||
|
)
|
||||||
|
resources = query.order_by(Resource.sort_order, Resource.id).all()
|
||||||
|
|
||||||
|
if selected:
|
||||||
|
chosen = Option.query.filter(Option.id.in_(selected)).all()
|
||||||
|
by_row = {}
|
||||||
|
for o in chosen:
|
||||||
|
by_row.setdefault(o.row_id, []).append(o.id)
|
||||||
|
|
||||||
|
def matches(res):
|
||||||
|
tagged_ids = {o.id for o in res.options}
|
||||||
|
tagged_row_ids = {o.row_id for o in res.options}
|
||||||
|
for row_id, opt_ids in by_row.items():
|
||||||
|
if row_id not in tagged_row_ids:
|
||||||
|
continue # untagged in this row -> applies to all
|
||||||
|
if not tagged_ids.intersection(opt_ids):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
resources = [r for r in resources if matches(r)]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"category": category.to_dict(),
|
||||||
|
"resources": [r.to_dict() for r in resources],
|
||||||
|
"selected_option_ids": selected,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/search")
|
||||||
|
def global_search():
|
||||||
|
"""Global search across all resources."""
|
||||||
|
keyword = request.args.get("keyword", "").strip()
|
||||||
|
if not keyword or len(keyword) < 2:
|
||||||
|
return jsonify({"results": [], "keyword": keyword})
|
||||||
|
|
||||||
|
results = Resource.query.filter_by(is_active=True).filter(
|
||||||
|
Resource.title.like(f"%{keyword}%") |
|
||||||
|
Resource.description.like(f"%{keyword}%") |
|
||||||
|
Resource.content.like(f"%{keyword}%")
|
||||||
|
).order_by(Resource.category_id, Resource.sort_order).limit(50).all()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"keyword": keyword,
|
||||||
|
"count": len(results),
|
||||||
|
"results": [r.to_dict() for r in results]
|
||||||
|
})
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
"""Seed default admin + demo content mirroring the reference design."""
|
||||||
|
from extensions import db
|
||||||
|
from models import Category, Option, OptionRow, Resource, SideLink, SiteSetting, User
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
"site_name": "OPS 资源中心",
|
||||||
|
"hero_tag": "INTERNAL RESOURCES",
|
||||||
|
"hero_title": "内部资源 快速上手",
|
||||||
|
"hero_subtitle": "执行一条命令完成初始化,发现更多面向开发者的内部镜像与工具",
|
||||||
|
"hero_command": "curl -sSL http://ops.internal/init.sh | bash",
|
||||||
|
"hero_command_label": "RUN THE COMMAND TO INSTALL",
|
||||||
|
"announcement": "欢迎使用内部资源中心 · 新增资源请联系运维组",
|
||||||
|
"footer_text": "OPS Resource Portal · 内部使用",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def seed_admin():
|
||||||
|
if not User.query.filter_by(username="admin").first():
|
||||||
|
u = User(username="admin")
|
||||||
|
u.set_password("admin123")
|
||||||
|
db.session.add(u)
|
||||||
|
db.session.commit()
|
||||||
|
print("[seed] admin user created (admin / admin123)")
|
||||||
|
|
||||||
|
|
||||||
|
def seed_settings():
|
||||||
|
changed = False
|
||||||
|
for k, v in DEFAULT_SETTINGS.items():
|
||||||
|
if not SiteSetting.query.get(k):
|
||||||
|
db.session.add(SiteSetting(key=k, value=v))
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def seed_demo():
|
||||||
|
"""Populate demo content only on a completely empty database."""
|
||||||
|
if Category.query.first():
|
||||||
|
return
|
||||||
|
|
||||||
|
# ---------------- Categories ----------------
|
||||||
|
cats = {}
|
||||||
|
for i, (name, icon, desc) in enumerate([
|
||||||
|
("基础设备", "🖥️", "物理机 / KVM / Docker 等基础环境入口"),
|
||||||
|
("基础系统", "💿", "各操作系统的初始化与配置"),
|
||||||
|
("初始化执行命令", "⚡", "新机器上架后执行的标准命令"),
|
||||||
|
("软件列表", "📦", "内部常用软件与工具清单"),
|
||||||
|
("pypi源", "🐍", "内部 PyPI 镜像源配置"),
|
||||||
|
("镜像源", "🪞", "apt / yum / npm 等镜像源汇总"),
|
||||||
|
]):
|
||||||
|
c = Category(name=name, icon=icon, description=desc, sort_order=i)
|
||||||
|
db.session.add(c)
|
||||||
|
cats[name] = c
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# ---------------- Option rows ----------------
|
||||||
|
def add_rows(cat, rows):
|
||||||
|
out = {}
|
||||||
|
for i, (title, labels) in enumerate(rows):
|
||||||
|
row = OptionRow(category_id=cat.id, title=title, sort_order=i)
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.flush()
|
||||||
|
opts = []
|
||||||
|
for j, (label, icon) in enumerate(labels):
|
||||||
|
o = Option(row_id=row.id, label=label, icon=icon, sort_order=j)
|
||||||
|
db.session.add(o)
|
||||||
|
opts.append(o)
|
||||||
|
db.session.flush()
|
||||||
|
out[title] = opts
|
||||||
|
return out
|
||||||
|
|
||||||
|
env_labels = [("物理机", "🖥️"), ("KVM虚拟机", "🧩"), ("Docker容器", "🐳")]
|
||||||
|
os_labels = [("ubuntu", ""), ("debian", ""), ("centos", ""), ("kylin", "")]
|
||||||
|
|
||||||
|
opts_base = add_rows(cats["基础设备"], [("运行环境", env_labels)])
|
||||||
|
opts_sys = add_rows(cats["基础系统"], [("运行环境", env_labels), ("操作系统", os_labels)])
|
||||||
|
opts_pypi = add_rows(cats["pypi源"], [("运行环境", env_labels), ("操作系统", os_labels)])
|
||||||
|
|
||||||
|
# ---------------- Resources ----------------
|
||||||
|
r = Resource(
|
||||||
|
category_id=cats["pypi源"].id,
|
||||||
|
title="配置内部 pip 源",
|
||||||
|
type="command",
|
||||||
|
content="mkdir -p ~/.pip && wget http://ops.streamcomputing.com/repo_list/pip.conf -O ~/.pip/pip.conf",
|
||||||
|
description="一键写入 pip.conf,立即生效",
|
||||||
|
sort_order=0,
|
||||||
|
)
|
||||||
|
db.session.add(r)
|
||||||
|
|
||||||
|
db.session.add(Resource(
|
||||||
|
category_id=cats["pypi源"].id,
|
||||||
|
title="Docker 构建时注入 pip 源",
|
||||||
|
type="command",
|
||||||
|
content="RUN mkdir -p /root/.pip && wget -q http://ops.streamcomputing.com/repo_list/pip.conf -O /root/.pip/pip.conf",
|
||||||
|
description="适用于 Dockerfile 构建场景",
|
||||||
|
sort_order=1,
|
||||||
|
options=[opts_pypi["运行环境"][2]], # Docker容器
|
||||||
|
))
|
||||||
|
|
||||||
|
db.session.add(Resource(
|
||||||
|
category_id=cats["pypi源"].id,
|
||||||
|
title="PyPI 包索引浏览",
|
||||||
|
type="link",
|
||||||
|
url="http://ops.streamcomputing.com/pypi/",
|
||||||
|
description="Index of / — 内部镜像的全量包列表",
|
||||||
|
sort_order=2,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.session.add(Resource(
|
||||||
|
category_id=cats["基础系统"].id,
|
||||||
|
title="Ubuntu / Debian 初始化",
|
||||||
|
type="command",
|
||||||
|
content="wget http://ops.streamcomputing.com/repo_list/ubuntu.sources -O /etc/apt/sources.list.d/internal.sources && apt-get update",
|
||||||
|
description="替换为内部 apt 源并刷新索引",
|
||||||
|
sort_order=0,
|
||||||
|
options=[opts_sys["操作系统"][0], opts_sys["操作系统"][1]], # ubuntu, debian
|
||||||
|
))
|
||||||
|
db.session.add(Resource(
|
||||||
|
category_id=cats["基础系统"].id,
|
||||||
|
title="CentOS / Kylin 初始化",
|
||||||
|
type="command",
|
||||||
|
content="wget http://ops.streamcomputing.com/repo_list/CentOS-Base.repo -O /etc/yum.repos.d/CentOS-Base.repo && yum makecache",
|
||||||
|
description="替换为内部 yum 源并建立缓存",
|
||||||
|
sort_order=1,
|
||||||
|
options=[opts_sys["操作系统"][2], opts_sys["操作系统"][3]], # centos, kylin
|
||||||
|
))
|
||||||
|
db.session.add(Resource(
|
||||||
|
category_id=cats["基础系统"].id,
|
||||||
|
title="KVM 虚拟机 cloud-init 说明",
|
||||||
|
type="text",
|
||||||
|
content="KVM 模板镜像已内置 cloud-init,首次启动会自动扩容磁盘并注入 SSH 公钥。\n如需自定义主机名,请在创建时传入 metadata。",
|
||||||
|
description="适用于 KVM 模板镜像",
|
||||||
|
sort_order=2,
|
||||||
|
options=[opts_sys["运行环境"][1]], # KVM虚拟机
|
||||||
|
))
|
||||||
|
|
||||||
|
db.session.add(Resource(
|
||||||
|
category_id=cats["初始化执行命令"].id,
|
||||||
|
title="标准初始化脚本",
|
||||||
|
type="command",
|
||||||
|
content="curl -sSL http://ops.streamcomputing.com/init/bootstrap.sh | bash",
|
||||||
|
description="包含时区、内核参数、监控 agent 安装",
|
||||||
|
sort_order=0,
|
||||||
|
))
|
||||||
|
db.session.add(Resource(
|
||||||
|
category_id=cats["初始化执行命令"].id,
|
||||||
|
title="初始化自检",
|
||||||
|
type="command",
|
||||||
|
content="ops-check --all --report",
|
||||||
|
description="初始化完成后执行,输出自检报告",
|
||||||
|
sort_order=1,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.session.add(Resource(
|
||||||
|
category_id=cats["软件列表"].id,
|
||||||
|
title="内部软件清单",
|
||||||
|
type="link",
|
||||||
|
url="http://ops.streamcomputing.com/software/",
|
||||||
|
description="常用软件版本与下载入口",
|
||||||
|
sort_order=0,
|
||||||
|
))
|
||||||
|
db.session.add(Resource(
|
||||||
|
category_id=cats["软件列表"].id,
|
||||||
|
title="CLI 工具合集",
|
||||||
|
type="text",
|
||||||
|
content="ops-cli / dbtool / netprobe / logtail\n安装方式见各软件详情页。",
|
||||||
|
sort_order=1,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.session.add(Resource(
|
||||||
|
category_id=cats["基础设备"].id,
|
||||||
|
title="设备申领入口",
|
||||||
|
type="link",
|
||||||
|
url="http://ops.streamcomputing.com/devices/",
|
||||||
|
description="物理机 / 虚拟机 / 容器资源申领",
|
||||||
|
sort_order=0,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.session.add(Resource(
|
||||||
|
category_id=cats["镜像源"].id,
|
||||||
|
title="镜像源总览",
|
||||||
|
type="link",
|
||||||
|
url="http://ops.streamcomputing.com/mirrors/",
|
||||||
|
description="apt / yum / pypi / npm / docker registry",
|
||||||
|
sort_order=0,
|
||||||
|
))
|
||||||
|
|
||||||
|
# ---------------- Side links ----------------
|
||||||
|
for i, (title, url) in enumerate([
|
||||||
|
("Docker镜像仓库", "http://ops.streamcomputing.com/docker/"),
|
||||||
|
("Docker基础镜像使用方法以及镜像列表", "http://ops.streamcomputing.com/docker/guide"),
|
||||||
|
("KVM模板镜像下载", "http://ops.streamcomputing.com/kvm/images"),
|
||||||
|
("KVM模板镜像信息一览表", "http://ops.streamcomputing.com/kvm/list"),
|
||||||
|
("iso镜像下载", "http://ops.streamcomputing.com/iso/"),
|
||||||
|
("grafana网址", "http://grafana.streamcomputing.com/"),
|
||||||
|
("ops文档中心", "http://docs.streamcomputing.com/"),
|
||||||
|
]):
|
||||||
|
db.session.add(SideLink(title=title, url=url, sort_order=i))
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
print("[seed] demo content created")
|
||||||
|
|
||||||
|
|
||||||
|
def seed_all():
|
||||||
|
seed_admin()
|
||||||
|
seed_settings()
|
||||||
|
seed_demo()
|
||||||
@@ -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>
|
||||||
Generated
+1870
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
@@ -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
|
||||||
@@ -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')
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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> 关闭 <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 }} 跳转 →
|
||||||
|
</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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user