Initial commit
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# ===== 必填 =====
|
||||
# 管理员账号(首次启动自动创建)
|
||||
ADMIN_EMAIL=admin@smartup.local
|
||||
ADMIN_PASSWORD=changeme123
|
||||
|
||||
# ===== 推荐配置 =====
|
||||
# JWT 签名密钥(生产环境请替换): openssl rand -hex 32
|
||||
JWT_SECRET=change-me-in-production
|
||||
|
||||
# ===== 可选 =====
|
||||
# 监听端口(默认 8899)
|
||||
SERVER_PORT=8899
|
||||
# 绑定地址(默认 0.0.0.0)
|
||||
BIND_HOST=0.0.0.0
|
||||
# 时区
|
||||
TZ=Asia/Shanghai
|
||||
# 连续失败多少次判定为 unhealthy(默认 3)
|
||||
UNHEALTHY_THRESHOLD=3
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.vite/
|
||||
|
||||
backend/static/
|
||||
backend/data/
|
||||
|
||||
*.log
|
||||
.DS_Store
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# ---- Stage 1: Build frontend ----
|
||||
FROM node:20-alpine AS frontend-build
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# ---- Stage 2: Python backend ----
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy backend source
|
||||
COPY backend/ .
|
||||
|
||||
# Copy built frontend into backend/static
|
||||
COPY --from=frontend-build /frontend/dist ./static
|
||||
|
||||
# Data directory for SQLite
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
ENV DATABASE_URL=sqlite:////app/data/app.db
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,112 @@
|
||||
# SmartUp — API 上游管理与 Webhook 通知系统
|
||||
|
||||
SmartUp 是一个独立的 Web 后台,用于管理多个 API 上游的分组倍率监听,并通过 Webhook(通用 JSON / 钉钉机器人)发送变更通知。
|
||||
|
||||
## 功能
|
||||
|
||||
- **上游管理**:支持 none / bearer / api_key / 邮箱密码 四种认证方式
|
||||
- **定时检测**:每个上游独立配置检测间隔,APScheduler 后台运行
|
||||
- **倍率快照**:检测变化后保存快照,diff 比对历史
|
||||
- **Webhook 通知**:支持通用 JSON 和钉钉机器人(带签名)
|
||||
- **通知日志**:记录每次发送结果,支持筛选查看
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 后端:FastAPI + SQLite + APScheduler
|
||||
- 前端:Vue 3 + Element Plus + Vite
|
||||
- 部署:Docker Compose 单容器
|
||||
|
||||
## 快速部署
|
||||
|
||||
### 1. 准备配置文件
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 编辑 .env,至少填写 ADMIN_PASSWORD 和 JWT_SECRET
|
||||
```
|
||||
|
||||
### 2. 启动
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
首次启动时自动:
|
||||
- 创建 SQLite 数据库(`./data/app.db`)
|
||||
- 初始化管理员账号
|
||||
|
||||
### 3. 访问
|
||||
|
||||
打开浏览器:http://localhost:8899
|
||||
|
||||
默认账号:`.env` 中配置的 `ADMIN_EMAIL` / `ADMIN_PASSWORD`
|
||||
|
||||
## 本地开发
|
||||
|
||||
### 后端
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 创建 .env(可复制根目录的 .env.example)
|
||||
cat > .env << 'EOF'
|
||||
ADMIN_EMAIL=admin@smartup.local
|
||||
ADMIN_PASSWORD=dev123
|
||||
JWT_SECRET=dev-secret
|
||||
DATABASE_URL=sqlite:///./data/app.db
|
||||
EOF
|
||||
|
||||
mkdir -p data
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # 代理到 localhost:8000
|
||||
```
|
||||
|
||||
## 数据备份
|
||||
|
||||
SQLite 数据库位于 `./data/app.db`,直接复制即可备份:
|
||||
|
||||
```bash
|
||||
cp ./data/app.db ./data/app.db.$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `ADMIN_EMAIL` | 管理员邮箱 | `admin@smartup.local` |
|
||||
| `ADMIN_PASSWORD` | 管理员密码(必填) | — |
|
||||
| `JWT_SECRET` | JWT 签名密钥 | `change-me-in-production` |
|
||||
| `SERVER_PORT` | 宿主机端口 | `8899` |
|
||||
| `TZ` | 时区 | `Asia/Shanghai` |
|
||||
| `UNHEALTHY_THRESHOLD` | 连续失败多少次标记为异常 | `3` |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
SmartUp/
|
||||
├── backend/ # FastAPI 应用
|
||||
│ └── app/
|
||||
│ ├── models/ # SQLAlchemy ORM
|
||||
│ ├── schemas/ # Pydantic schemas
|
||||
│ ├── routers/ # API 路由
|
||||
│ ├── services/ # 业务逻辑(client/scheduler/webhook)
|
||||
│ └── utils/ # JWT / 钉钉签名
|
||||
├── frontend/ # Vue 3 前端
|
||||
│ └── src/
|
||||
│ ├── views/ # 页面组件
|
||||
│ ├── api/ # Axios 封装
|
||||
│ └── stores/ # Pinia 状态
|
||||
├── data/ # SQLite 数据目录(Docker volume)
|
||||
├── Dockerfile # 多阶段构建
|
||||
├── docker-compose.yml
|
||||
└── .env.example
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
# app package
|
||||
@@ -0,0 +1,20 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
admin_email: str = "admin@smartup.local"
|
||||
admin_password: str = "changeme"
|
||||
jwt_secret: str = "change-me-in-production"
|
||||
jwt_expire_hours: int = 24
|
||||
database_url: str = "sqlite:////app/data/app.db"
|
||||
tz: str = "Asia/Shanghai"
|
||||
# consecutive failures before upstream goes unhealthy
|
||||
unhealthy_threshold: int = 3
|
||||
|
||||
model_config = {"env_file": ".env", "case_sensitive": False}
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,30 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Create all tables."""
|
||||
# import models so SQLAlchemy registers them
|
||||
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page # noqa: F401
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""SmartUp FastAPI application entry point."""
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import init_db
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.database import SessionLocal
|
||||
from app.utils.auth import hash_password
|
||||
from app.services.scheduler import start_scheduler, stop_scheduler
|
||||
from app.routers import auth, upstreams, webhooks, logs, custom_pages
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _init_admin() -> None:
|
||||
settings = get_settings()
|
||||
if not settings.admin_password:
|
||||
logger.warning("ADMIN_PASSWORD not set, skip admin init")
|
||||
return
|
||||
db = SessionLocal()
|
||||
try:
|
||||
exists = db.query(AdminUser).filter(AdminUser.email == settings.admin_email).first()
|
||||
if not exists:
|
||||
user = AdminUser(
|
||||
email=settings.admin_email,
|
||||
password_hash=hash_password(settings.admin_password),
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
logger.info("admin user created: %s", settings.admin_email)
|
||||
else:
|
||||
logger.info("admin user already exists: %s", settings.admin_email)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
_init_admin()
|
||||
start_scheduler()
|
||||
yield
|
||||
stop_scheduler()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="SmartUp",
|
||||
description="API 上游管理与 Webhook 通知系统",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# API routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(upstreams.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(logs.router)
|
||||
app.include_router(custom_pages.router)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# Serve frontend static files
|
||||
STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets")
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
def serve_spa(full_path: str):
|
||||
index = STATIC_DIR / "index.html"
|
||||
return FileResponse(str(index))
|
||||
@@ -0,0 +1 @@
|
||||
# models package
|
||||
@@ -0,0 +1,16 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Integer, String, DateTime
|
||||
from sqlalchemy.orm import mapped_column, Mapped
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
__tablename__ = "admin_users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Custom embedded pages model."""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import Integer, String, Boolean, DateTime, Text
|
||||
from sqlalchemy.orm import mapped_column, Mapped
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class CustomPage(Base):
|
||||
__tablename__ = "custom_pages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
icon: Mapped[str] = mapped_column(String(64), default="Link")
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
use_proxy: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import Integer, String, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.orm import mapped_column, Mapped
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class NotificationLog(Base):
|
||||
__tablename__ = "notification_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
webhook_config_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("webhook_configs.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
webhook_name: Mapped[str] = mapped_column(String(255), default="")
|
||||
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
payload_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
# success | failed
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
response_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
|
||||
@@ -0,0 +1,13 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Integer, Text, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import mapped_column, Mapped
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class UpstreamRateSnapshot(Base):
|
||||
__tablename__ = "upstream_rate_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
upstream_id: Mapped[int] = mapped_column(Integer, ForeignKey("upstreams.id", ondelete="CASCADE"), index=True)
|
||||
snapshot_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
captured_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
|
||||
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import Integer, String, Boolean, DateTime, Text
|
||||
from sqlalchemy.orm import mapped_column, Mapped
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Upstream(Base):
|
||||
__tablename__ = "upstreams"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
base_url: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
api_prefix: Mapped[str] = mapped_column(String(128), default="/api/v1")
|
||||
# none | bearer | api_key | login_password
|
||||
auth_type: Mapped[str] = mapped_column(String(32), default="login_password")
|
||||
# JSON: {"email":"..","password":".."} or {"token":".."} etc.
|
||||
auth_config_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
rate_endpoint: Mapped[str] = mapped_column(String(256), default="/groups/rates")
|
||||
groups_endpoint: Mapped[str] = mapped_column(String(256), default="/groups/available")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
check_interval_seconds: Mapped[int] = mapped_column(Integer, default=600)
|
||||
timeout_seconds: Mapped[int] = mapped_column(Integer, default=30)
|
||||
# unknown | healthy | unhealthy
|
||||
last_status: Mapped[str] = mapped_column(String(32), default="unknown")
|
||||
last_checked_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
consecutive_failures: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Integer, String, Boolean, DateTime, Text
|
||||
from sqlalchemy.orm import mapped_column, Mapped
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class WebhookConfig(Base):
|
||||
__tablename__ = "webhook_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
# generic | dingtalk
|
||||
type: Mapped[str] = mapped_column(String(32), default="generic")
|
||||
url: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
secret: Mapped[str] = mapped_column(String(512), default="")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
# JSON array: ["upstream_rate_changed","upstream_unhealthy","upstream_recovered"]
|
||||
events_json: Mapped[str] = mapped_column(Text, default='["upstream_rate_changed"]')
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
# routers package
|
||||
@@ -0,0 +1,28 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.schemas.auth import LoginRequest, TokenResponse, UserInfo
|
||||
from app.utils.auth import verify_password, create_access_token, get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(req: LoginRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(AdminUser).filter(AdminUser.email == req.email).first()
|
||||
if not user or not verify_password(req.password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
|
||||
token = create_access_token(user.email)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserInfo)
|
||||
def me(current_user: AdminUser = Depends(get_current_user)):
|
||||
return UserInfo(email=current_user.email)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout():
|
||||
# JWT is stateless — client discards token
|
||||
return {"message": "logged out"}
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Custom pages CRUD router + transparent iframe proxy."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urljoin, urlparse, urlencode, quote
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.custom_page import CustomPage
|
||||
from app.utils.auth import get_current_user, get_user_from_token_param
|
||||
|
||||
router = APIRouter(prefix="/api/custom-pages", tags=["custom-pages"])
|
||||
|
||||
# Headers that prevent iframe embedding — strip them from proxied responses
|
||||
_STRIP_RESPONSE_HEADERS = {
|
||||
"x-frame-options",
|
||||
"content-security-policy",
|
||||
"content-security-policy-report-only",
|
||||
}
|
||||
# Headers we should NOT forward to the upstream (hop-by-hop + host)
|
||||
_STRIP_REQUEST_HEADERS = {"host", "connection", "transfer-encoding", "te",
|
||||
"trailers", "upgrade", "proxy-authorization"}
|
||||
|
||||
|
||||
# ---- Schemas ----
|
||||
class CustomPageCreate(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
icon: str = "Link"
|
||||
sort_order: int = 0
|
||||
enabled: bool = True
|
||||
use_proxy: bool = False
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class CustomPageUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
enabled: Optional[bool] = None
|
||||
use_proxy: Optional[bool] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class CustomPageResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
url: str
|
||||
icon: str
|
||||
sort_order: int
|
||||
enabled: bool
|
||||
use_proxy: bool
|
||||
description: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ---- CRUD Endpoints ----
|
||||
|
||||
@router.get("", response_model=List[CustomPageResponse])
|
||||
def list_pages(db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
return db.query(CustomPage).order_by(CustomPage.sort_order, CustomPage.id).all()
|
||||
|
||||
|
||||
@router.post("", response_model=CustomPageResponse, status_code=201)
|
||||
def create_page(body: CustomPageCreate, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
page = CustomPage(**body.model_dump())
|
||||
db.add(page)
|
||||
db.commit()
|
||||
db.refresh(page)
|
||||
return page
|
||||
|
||||
|
||||
@router.put("/{pid}", response_model=CustomPageResponse)
|
||||
def update_page(pid: int, body: CustomPageUpdate, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
page = db.query(CustomPage).filter(CustomPage.id == pid).first()
|
||||
if not page:
|
||||
raise HTTPException(404, "page not found")
|
||||
for k, v in body.model_dump(exclude_none=True).items():
|
||||
setattr(page, k, v)
|
||||
page.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(page)
|
||||
return page
|
||||
|
||||
|
||||
@router.delete("/{pid}", status_code=204)
|
||||
def delete_page(pid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
page = db.query(CustomPage).filter(CustomPage.id == pid).first()
|
||||
if not page:
|
||||
raise HTTPException(404, "page not found")
|
||||
db.delete(page)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---- Frame Proxy (simple: strip X-Frame-Options / CSP, pass through content) ----
|
||||
|
||||
_STRIP_RESP = {
|
||||
"x-frame-options",
|
||||
"content-security-policy",
|
||||
"content-security-policy-report-only",
|
||||
}
|
||||
_STRIP_REQ = {
|
||||
"host", "connection", "transfer-encoding", "te",
|
||||
"trailers", "upgrade", "proxy-authorization", "authorization",
|
||||
}
|
||||
|
||||
|
||||
@router.api_route("/frame-proxy", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"])
|
||||
async def frame_proxy(
|
||||
request: Request,
|
||||
url: str = Query(..., description="Target URL to proxy"),
|
||||
token: Optional[str] = Query(default=None),
|
||||
_=Depends(get_user_from_token_param),
|
||||
):
|
||||
"""
|
||||
Simple transparent proxy: strips X-Frame-Options and CSP headers so the
|
||||
response can be embedded in an iframe.
|
||||
|
||||
NOTE: For full SPA (React/Vue) sites, install the 'Requestly' browser
|
||||
extension and set a rule to remove X-Frame-Options on the target domain —
|
||||
that works reliably without any server-side complexity.
|
||||
"""
|
||||
if not url.startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Only http/https URLs are allowed")
|
||||
|
||||
# Forward browser headers (cookies, language, accept, etc.)
|
||||
fwd: dict[str, str] = {}
|
||||
for k, v in request.headers.items():
|
||||
if k.lower() in _STRIP_REQ or k.lower().startswith("x-forwarded"):
|
||||
continue
|
||||
fwd[k] = v
|
||||
fwd["user-agent"] = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
fwd.setdefault("accept", "text/html,application/xhtml+xml,*/*;q=0.8")
|
||||
|
||||
body = await request.body() if request.method not in ("GET", "HEAD") else None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=30) as client:
|
||||
resp = await client.request(
|
||||
method=request.method,
|
||||
url=url,
|
||||
headers=fwd,
|
||||
content=body,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(502, f"Proxy error: {exc}")
|
||||
|
||||
# Pass through content unchanged — just strip the iframe-blocking headers
|
||||
out: dict[str, str] = {}
|
||||
for k, v in resp.headers.items():
|
||||
kl = k.lower()
|
||||
if kl in _STRIP_RESP:
|
||||
continue
|
||||
if kl in ("content-encoding", "transfer-encoding", "content-length"):
|
||||
continue
|
||||
out[k] = v
|
||||
|
||||
return Response(
|
||||
content=resp.content,
|
||||
status_code=resp.status_code,
|
||||
media_type=resp.headers.get("content-type"),
|
||||
headers=out,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Notification logs listing with filters."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.notification_log import NotificationLog
|
||||
from app.schemas.log import NotificationLogResponse
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/notification-logs", tags=["logs"])
|
||||
|
||||
|
||||
def _to_response(log: NotificationLog) -> NotificationLogResponse:
|
||||
return NotificationLogResponse(
|
||||
id=log.id,
|
||||
webhook_config_id=log.webhook_config_id,
|
||||
webhook_name=log.webhook_name,
|
||||
event_type=log.event_type,
|
||||
payload=json.loads(log.payload_json or "{}"),
|
||||
status=log.status,
|
||||
response_text=log.response_text,
|
||||
created_at=log.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[NotificationLogResponse])
|
||||
def list_logs(
|
||||
status: Optional[str] = Query(None),
|
||||
event_type: Optional[str] = Query(None),
|
||||
limit: int = Query(100, le=500),
|
||||
offset: int = Query(0),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
q = db.query(NotificationLog)
|
||||
if status:
|
||||
q = q.filter(NotificationLog.status == status)
|
||||
if event_type:
|
||||
q = q.filter(NotificationLog.event_type == event_type)
|
||||
logs = q.order_by(NotificationLog.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return [_to_response(log) for log in logs]
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Upstream management CRUD + test + check-now + snapshots."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.upstream import Upstream
|
||||
from app.models.snapshot import UpstreamRateSnapshot
|
||||
from app.schemas.upstream import (
|
||||
UpstreamCreate, UpstreamUpdate, UpstreamResponse, SnapshotResponse, TestResult
|
||||
)
|
||||
from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot
|
||||
from app.services.snapshot_service import diff_snapshots
|
||||
from app.services import scheduler as sched_svc
|
||||
from app.services import webhook_service
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/upstreams", tags=["upstreams"])
|
||||
|
||||
MASK = "***"
|
||||
SECRET_KEYS = {"password", "token", "key", "secret"}
|
||||
|
||||
|
||||
def _mask_auth_config(auth_type: str, cfg: dict) -> dict:
|
||||
masked = {}
|
||||
for k, v in cfg.items():
|
||||
if k.lower() in SECRET_KEYS and v:
|
||||
masked[k] = MASK
|
||||
else:
|
||||
masked[k] = v
|
||||
return masked
|
||||
|
||||
|
||||
def _to_response(u: Upstream) -> UpstreamResponse:
|
||||
cfg = json.loads(u.auth_config_json or "{}")
|
||||
return UpstreamResponse(
|
||||
id=u.id,
|
||||
name=u.name,
|
||||
base_url=u.base_url,
|
||||
api_prefix=u.api_prefix,
|
||||
auth_type=u.auth_type,
|
||||
auth_config_masked=_mask_auth_config(u.auth_type, cfg),
|
||||
rate_endpoint=u.rate_endpoint,
|
||||
groups_endpoint=u.groups_endpoint,
|
||||
enabled=u.enabled,
|
||||
check_interval_seconds=u.check_interval_seconds,
|
||||
timeout_seconds=u.timeout_seconds,
|
||||
last_status=u.last_status,
|
||||
last_checked_at=u.last_checked_at,
|
||||
last_error=u.last_error,
|
||||
created_at=u.created_at,
|
||||
updated_at=u.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[UpstreamResponse])
|
||||
def list_upstreams(db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
return [_to_response(u) for u in db.query(Upstream).order_by(Upstream.id).all()]
|
||||
|
||||
|
||||
@router.post("", response_model=UpstreamResponse, status_code=201)
|
||||
def create_upstream(
|
||||
body: UpstreamCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
u = Upstream(
|
||||
name=body.name,
|
||||
base_url=body.base_url.rstrip("/"),
|
||||
api_prefix=body.api_prefix,
|
||||
auth_type=body.auth_type,
|
||||
auth_config_json=json.dumps(body.auth_config, ensure_ascii=False),
|
||||
rate_endpoint=body.rate_endpoint,
|
||||
groups_endpoint=body.groups_endpoint,
|
||||
enabled=body.enabled,
|
||||
check_interval_seconds=body.check_interval_seconds,
|
||||
timeout_seconds=body.timeout_seconds,
|
||||
)
|
||||
db.add(u)
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
sched_svc.refresh_upstream(u.id, u.check_interval_seconds, u.enabled)
|
||||
return _to_response(u)
|
||||
|
||||
|
||||
@router.get("/{uid}", response_model=UpstreamResponse)
|
||||
def get_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
u = db.query(Upstream).filter(Upstream.id == uid).first()
|
||||
if not u:
|
||||
raise HTTPException(404, "upstream not found")
|
||||
return _to_response(u)
|
||||
|
||||
|
||||
@router.put("/{uid}", response_model=UpstreamResponse)
|
||||
def update_upstream(
|
||||
uid: int,
|
||||
body: UpstreamUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
u = db.query(Upstream).filter(Upstream.id == uid).first()
|
||||
if not u:
|
||||
raise HTTPException(404, "upstream not found")
|
||||
data = body.model_dump(exclude_none=True)
|
||||
if "auth_config" in data:
|
||||
# merge with existing config to avoid overwriting masked fields
|
||||
existing = json.loads(u.auth_config_json or "{}")
|
||||
incoming = data.pop("auth_config")
|
||||
for k, v in incoming.items():
|
||||
if v != MASK: # don't overwrite with mask placeholder
|
||||
existing[k] = v
|
||||
u.auth_config_json = json.dumps(existing, ensure_ascii=False)
|
||||
if "base_url" in data:
|
||||
data["base_url"] = data["base_url"].rstrip("/")
|
||||
for k, v in data.items():
|
||||
setattr(u, k, v)
|
||||
u.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
sched_svc.refresh_upstream(u.id, u.check_interval_seconds, u.enabled)
|
||||
return _to_response(u)
|
||||
|
||||
|
||||
@router.delete("/{uid}", status_code=204)
|
||||
def delete_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
u = db.query(Upstream).filter(Upstream.id == uid).first()
|
||||
if not u:
|
||||
raise HTTPException(404, "upstream not found")
|
||||
sched_svc.refresh_upstream(uid, 0, False) # remove job
|
||||
db.delete(u)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{uid}/test", response_model=TestResult)
|
||||
def test_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
u = db.query(Upstream).filter(Upstream.id == uid).first()
|
||||
if not u:
|
||||
raise HTTPException(404, "upstream not found")
|
||||
auth_config = json.loads(u.auth_config_json or "{}")
|
||||
client = UpstreamClient(
|
||||
base_url=u.base_url,
|
||||
api_prefix=u.api_prefix,
|
||||
auth_type=u.auth_type,
|
||||
auth_config=auth_config,
|
||||
timeout=float(u.timeout_seconds),
|
||||
)
|
||||
try:
|
||||
client.login()
|
||||
groups = client.get_available_groups(u.groups_endpoint)
|
||||
return TestResult(success=True, message=f"连接成功,获取到 {len(groups)} 个分组")
|
||||
except Exception as exc:
|
||||
return TestResult(success=False, message="连接失败", detail=str(exc))
|
||||
|
||||
|
||||
@router.post("/{uid}/check-now", response_model=TestResult)
|
||||
def check_now(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
u = db.query(Upstream).filter(Upstream.id == uid).first()
|
||||
if not u:
|
||||
raise HTTPException(404, "upstream not found")
|
||||
auth_config = json.loads(u.auth_config_json or "{}")
|
||||
client = UpstreamClient(
|
||||
base_url=u.base_url,
|
||||
api_prefix=u.api_prefix,
|
||||
auth_type=u.auth_type,
|
||||
auth_config=auth_config,
|
||||
timeout=float(u.timeout_seconds),
|
||||
)
|
||||
try:
|
||||
client.login()
|
||||
groups = client.get_available_groups(u.groups_endpoint)
|
||||
raw_rates = client.get_group_rates(u.rate_endpoint)
|
||||
snapshot = build_snapshot(u.id, u.base_url, u.api_prefix, groups, raw_rates)
|
||||
except Exception as exc:
|
||||
u.consecutive_failures = (u.consecutive_failures or 0) + 1
|
||||
u.last_error = str(exc)
|
||||
u.last_checked_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return TestResult(success=False, message="检测失败", detail=str(exc))
|
||||
|
||||
prev_row = (
|
||||
db.query(UpstreamRateSnapshot)
|
||||
.filter(UpstreamRateSnapshot.upstream_id == uid)
|
||||
.order_by(UpstreamRateSnapshot.captured_at.desc())
|
||||
.first()
|
||||
)
|
||||
previous = json.loads(prev_row.snapshot_json) if prev_row else None
|
||||
changes = diff_snapshots(previous, snapshot)
|
||||
|
||||
new_row = UpstreamRateSnapshot(
|
||||
upstream_id=uid,
|
||||
snapshot_json=json.dumps(snapshot, ensure_ascii=False),
|
||||
captured_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(new_row)
|
||||
was_unhealthy = u.last_status == "unhealthy"
|
||||
u.last_status = "healthy"
|
||||
u.last_checked_at = datetime.now(timezone.utc)
|
||||
u.last_error = None
|
||||
u.consecutive_failures = 0
|
||||
db.commit()
|
||||
|
||||
if was_unhealthy:
|
||||
webhook_service.send_status_event(db, u.id, u.name, u.base_url, "upstream_recovered")
|
||||
if changes:
|
||||
webhook_service.send_rate_changed(db, u.id, u.name, u.base_url, changes)
|
||||
|
||||
msg = f"检测成功,{len(groups)} 个分组"
|
||||
if changes:
|
||||
msg += f",发现 {len(changes)} 处倍率变化"
|
||||
elif previous is None:
|
||||
msg += ",初始化快照完成"
|
||||
else:
|
||||
msg += ",无变化"
|
||||
return TestResult(success=True, message=msg)
|
||||
|
||||
|
||||
@router.get("/{uid}/snapshots/latest", response_model=SnapshotResponse)
|
||||
def latest_snapshot(uid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
row = (
|
||||
db.query(UpstreamRateSnapshot)
|
||||
.filter(UpstreamRateSnapshot.upstream_id == uid)
|
||||
.order_by(UpstreamRateSnapshot.captured_at.desc())
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "no snapshot found")
|
||||
return SnapshotResponse(
|
||||
id=row.id,
|
||||
upstream_id=row.upstream_id,
|
||||
snapshot=json.loads(row.snapshot_json),
|
||||
captured_at=row.captured_at,
|
||||
)
|
||||
|
||||
|
||||
from fastapi import Query as QueryParam
|
||||
|
||||
@router.get("/{uid}/snapshots", response_model=List[SnapshotResponse])
|
||||
def list_snapshots(
|
||||
uid: int,
|
||||
limit: int = QueryParam(20, le=100),
|
||||
offset: int = QueryParam(0),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
"""Return paginated snapshot history with diff vs previous snapshot embedded."""
|
||||
rows = (
|
||||
db.query(UpstreamRateSnapshot)
|
||||
.filter(UpstreamRateSnapshot.upstream_id == uid)
|
||||
.order_by(UpstreamRateSnapshot.captured_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit + 1) # fetch one extra to get the "previous" for diffing
|
||||
.all()
|
||||
)
|
||||
# We need the snapshot just before each one to compute changes count.
|
||||
# rows are desc order; rows[i+1] is older than rows[i]
|
||||
results = []
|
||||
for i, row in enumerate(rows[:limit]):
|
||||
snap = json.loads(row.snapshot_json)
|
||||
# try to diff against the next row (which is older)
|
||||
changes_count: int | None = None
|
||||
if i + 1 < len(rows):
|
||||
older = json.loads(rows[i + 1].snapshot_json)
|
||||
from app.services.snapshot_service import diff_snapshots
|
||||
ch = diff_snapshots(older, snap)
|
||||
changes_count = len(ch)
|
||||
groups_count = len(snap.get("groups", {}))
|
||||
# embed lightweight summary into snapshot dict so frontend can display it
|
||||
snap["_groups_count"] = groups_count
|
||||
snap["_changes_count"] = changes_count # None means first ever snapshot
|
||||
results.append(SnapshotResponse(
|
||||
id=row.id,
|
||||
upstream_id=row.upstream_id,
|
||||
snapshot=snap,
|
||||
captured_at=row.captured_at,
|
||||
))
|
||||
return results
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Webhook configuration CRUD + test notification."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.webhook_config import WebhookConfig
|
||||
from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse
|
||||
from app.services.webhook_service import send_test_notification
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
||||
|
||||
MASK = "***"
|
||||
|
||||
|
||||
def _to_response(w: WebhookConfig) -> WebhookResponse:
|
||||
return WebhookResponse(
|
||||
id=w.id,
|
||||
name=w.name,
|
||||
type=w.type,
|
||||
url=w.url,
|
||||
secret_masked=MASK if w.secret else "",
|
||||
enabled=w.enabled,
|
||||
events=json.loads(w.events_json or "[]"),
|
||||
created_at=w.created_at,
|
||||
updated_at=w.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[WebhookResponse])
|
||||
def list_webhooks(db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
return [_to_response(w) for w in db.query(WebhookConfig).order_by(WebhookConfig.id).all()]
|
||||
|
||||
|
||||
@router.post("", response_model=WebhookResponse, status_code=201)
|
||||
def create_webhook(
|
||||
body: WebhookCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
w = WebhookConfig(
|
||||
name=body.name,
|
||||
type=body.type,
|
||||
url=body.url,
|
||||
secret=body.secret,
|
||||
enabled=body.enabled,
|
||||
events_json=json.dumps(body.events, ensure_ascii=False),
|
||||
)
|
||||
db.add(w)
|
||||
db.commit()
|
||||
db.refresh(w)
|
||||
return _to_response(w)
|
||||
|
||||
|
||||
@router.get("/{wid}", response_model=WebhookResponse)
|
||||
def get_webhook(wid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first()
|
||||
if not w:
|
||||
raise HTTPException(404, "webhook not found")
|
||||
return _to_response(w)
|
||||
|
||||
|
||||
@router.put("/{wid}", response_model=WebhookResponse)
|
||||
def update_webhook(
|
||||
wid: int,
|
||||
body: WebhookUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first()
|
||||
if not w:
|
||||
raise HTTPException(404, "webhook not found")
|
||||
data = body.model_dump(exclude_none=True)
|
||||
if "events" in data:
|
||||
w.events_json = json.dumps(data.pop("events"), ensure_ascii=False)
|
||||
if "secret" in data:
|
||||
if data["secret"] != MASK: # only update if not mask placeholder
|
||||
w.secret = data.pop("secret")
|
||||
else:
|
||||
data.pop("secret")
|
||||
for k, v in data.items():
|
||||
setattr(w, k, v)
|
||||
w.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(w)
|
||||
return _to_response(w)
|
||||
|
||||
|
||||
@router.delete("/{wid}", status_code=204)
|
||||
def delete_webhook(wid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first()
|
||||
if not w:
|
||||
raise HTTPException(404, "webhook not found")
|
||||
db.delete(w)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{wid}/test")
|
||||
def test_webhook(wid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||
w = db.query(WebhookConfig).filter(WebhookConfig.id == wid).first()
|
||||
if not w:
|
||||
raise HTTPException(404, "webhook not found")
|
||||
ok, msg = send_test_notification(db, w)
|
||||
return {"success": ok, "message": msg}
|
||||
@@ -0,0 +1 @@
|
||||
# schemas package
|
||||
@@ -0,0 +1,15 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
email: str
|
||||
@@ -0,0 +1,16 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class NotificationLogResponse(BaseModel):
|
||||
id: int
|
||||
webhook_config_id: int
|
||||
webhook_name: str
|
||||
event_type: str
|
||||
payload: dict[str, Any]
|
||||
status: str
|
||||
response_text: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,80 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AuthConfigBearer(BaseModel):
|
||||
token: str = ""
|
||||
|
||||
|
||||
class AuthConfigApiKey(BaseModel):
|
||||
key: str = ""
|
||||
header: str = "Authorization"
|
||||
|
||||
|
||||
class AuthConfigLoginPassword(BaseModel):
|
||||
email: str = ""
|
||||
password: str = ""
|
||||
login_path: str = "/auth/login"
|
||||
|
||||
|
||||
class UpstreamCreate(BaseModel):
|
||||
name: str
|
||||
base_url: str
|
||||
api_prefix: str = "/api/v1"
|
||||
auth_type: str = "login_password" # none | bearer | api_key | login_password
|
||||
auth_config: dict[str, Any] = {}
|
||||
rate_endpoint: str = "/groups/rates"
|
||||
groups_endpoint: str = "/groups/available"
|
||||
enabled: bool = True
|
||||
check_interval_seconds: int = 600
|
||||
timeout_seconds: int = 30
|
||||
|
||||
|
||||
class UpstreamUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
base_url: Optional[str] = None
|
||||
api_prefix: Optional[str] = None
|
||||
auth_type: Optional[str] = None
|
||||
auth_config: Optional[dict[str, Any]] = None
|
||||
rate_endpoint: Optional[str] = None
|
||||
groups_endpoint: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
check_interval_seconds: Optional[int] = None
|
||||
timeout_seconds: Optional[int] = None
|
||||
|
||||
|
||||
class UpstreamResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
base_url: str
|
||||
api_prefix: str
|
||||
auth_type: str
|
||||
auth_config_masked: dict[str, Any] # secrets replaced with ***
|
||||
rate_endpoint: str
|
||||
groups_endpoint: str
|
||||
enabled: bool
|
||||
check_interval_seconds: int
|
||||
timeout_seconds: int
|
||||
last_status: str
|
||||
last_checked_at: Optional[datetime]
|
||||
last_error: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SnapshotResponse(BaseModel):
|
||||
id: int
|
||||
upstream_id: int
|
||||
snapshot: dict[str, Any]
|
||||
captured_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TestResult(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
detail: Optional[str] = None
|
||||
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
ALLOWED_EVENTS = ["upstream_rate_changed", "upstream_unhealthy", "upstream_recovered"]
|
||||
|
||||
|
||||
class WebhookCreate(BaseModel):
|
||||
name: str
|
||||
type: str = "generic" # generic | dingtalk
|
||||
url: str
|
||||
secret: str = ""
|
||||
enabled: bool = True
|
||||
events: List[str] = ["upstream_rate_changed"]
|
||||
|
||||
|
||||
class WebhookUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
secret: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
events: Optional[List[str]] = None
|
||||
|
||||
|
||||
class WebhookResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
type: str
|
||||
url: str
|
||||
secret_masked: str # always "***" if set, else ""
|
||||
enabled: bool
|
||||
events: List[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1 @@
|
||||
# services package
|
||||
@@ -0,0 +1,156 @@
|
||||
"""APScheduler background scheduler for upstream checks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.upstream import Upstream
|
||||
from app.models.snapshot import UpstreamRateSnapshot
|
||||
from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot
|
||||
from app.services.snapshot_service import diff_snapshots
|
||||
from app.services import webhook_service
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler = BackgroundScheduler(timezone="UTC")
|
||||
|
||||
|
||||
def get_scheduler() -> BackgroundScheduler:
|
||||
return _scheduler
|
||||
|
||||
|
||||
def _check_upstream(upstream_id: int) -> None:
|
||||
"""Full upstream check executed by scheduler (runs in thread)."""
|
||||
settings = get_settings()
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
upstream = db.query(Upstream).filter(Upstream.id == upstream_id).first()
|
||||
if not upstream or not upstream.enabled:
|
||||
_remove_job(upstream_id)
|
||||
return
|
||||
|
||||
auth_config = json.loads(upstream.auth_config_json or "{}")
|
||||
client = UpstreamClient(
|
||||
base_url=upstream.base_url,
|
||||
api_prefix=upstream.api_prefix,
|
||||
auth_type=upstream.auth_type,
|
||||
auth_config=auth_config,
|
||||
timeout=float(upstream.timeout_seconds),
|
||||
)
|
||||
|
||||
was_unhealthy = upstream.last_status == "unhealthy"
|
||||
|
||||
try:
|
||||
client.login()
|
||||
groups = client.get_available_groups(upstream.groups_endpoint)
|
||||
raw_rates = client.get_group_rates(upstream.rate_endpoint)
|
||||
snapshot = build_snapshot(
|
||||
upstream.id, upstream.base_url, upstream.api_prefix, groups, raw_rates
|
||||
)
|
||||
except Exception as exc:
|
||||
# failure path
|
||||
upstream.consecutive_failures = (upstream.consecutive_failures or 0) + 1
|
||||
upstream.last_error = str(exc)
|
||||
upstream.last_checked_at = datetime.now(timezone.utc)
|
||||
threshold = settings.unhealthy_threshold
|
||||
if upstream.consecutive_failures >= threshold and upstream.last_status != "unhealthy":
|
||||
upstream.last_status = "unhealthy"
|
||||
db.commit()
|
||||
webhook_service.send_status_event(
|
||||
db, upstream.id, upstream.name, upstream.base_url,
|
||||
"upstream_unhealthy", str(exc)
|
||||
)
|
||||
else:
|
||||
db.commit()
|
||||
logger.warning("upstream %s check failed: %s", upstream.name, exc)
|
||||
return
|
||||
|
||||
# success path
|
||||
prev_snapshot_row = (
|
||||
db.query(UpstreamRateSnapshot)
|
||||
.filter(UpstreamRateSnapshot.upstream_id == upstream_id)
|
||||
.order_by(UpstreamRateSnapshot.captured_at.desc())
|
||||
.first()
|
||||
)
|
||||
previous = json.loads(prev_snapshot_row.snapshot_json) if prev_snapshot_row else None
|
||||
changes = diff_snapshots(previous, snapshot)
|
||||
|
||||
# save new snapshot
|
||||
new_row = UpstreamRateSnapshot(
|
||||
upstream_id=upstream_id,
|
||||
snapshot_json=json.dumps(snapshot, ensure_ascii=False),
|
||||
captured_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(new_row)
|
||||
|
||||
# update upstream status
|
||||
upstream.last_status = "healthy"
|
||||
upstream.last_checked_at = datetime.now(timezone.utc)
|
||||
upstream.last_error = None
|
||||
upstream.consecutive_failures = 0
|
||||
db.commit()
|
||||
|
||||
if was_unhealthy:
|
||||
webhook_service.send_status_event(
|
||||
db, upstream.id, upstream.name, upstream.base_url, "upstream_recovered"
|
||||
)
|
||||
|
||||
if changes:
|
||||
webhook_service.send_rate_changed(
|
||||
db, upstream.id, upstream.name, upstream.base_url, changes
|
||||
)
|
||||
logger.info("upstream %s: %d rate change(s)", upstream.name, len(changes))
|
||||
else:
|
||||
logger.debug("upstream %s: no changes", upstream.name)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _remove_job(upstream_id: int) -> None:
|
||||
job_id = f"upstream_{upstream_id}"
|
||||
if _scheduler.get_job(job_id):
|
||||
_scheduler.remove_job(job_id)
|
||||
|
||||
|
||||
def refresh_upstream(upstream_id: int, interval_seconds: int = 0, enabled: bool = True) -> None:
|
||||
"""Add/update/remove a scheduler job for the given upstream."""
|
||||
job_id = f"upstream_{upstream_id}"
|
||||
if not enabled or interval_seconds <= 0:
|
||||
_remove_job(upstream_id)
|
||||
return
|
||||
_scheduler.add_job(
|
||||
_check_upstream,
|
||||
"interval",
|
||||
seconds=interval_seconds,
|
||||
id=job_id,
|
||||
args=[upstream_id],
|
||||
replace_existing=True,
|
||||
coalesce=True,
|
||||
max_instances=1,
|
||||
)
|
||||
logger.info("scheduler job %s set to %ds interval", job_id, interval_seconds)
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
"""Start scheduler and load all enabled upstreams."""
|
||||
_scheduler.start()
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
upstreams = db.query(Upstream).filter(Upstream.enabled == True).all()
|
||||
for u in upstreams:
|
||||
refresh_upstream(u.id, u.check_interval_seconds, u.enabled)
|
||||
logger.info("scheduler started with %d upstream job(s)", len(upstreams))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
if _scheduler.running:
|
||||
_scheduler.shutdown(wait=False)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Snapshot diff logic."""
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def diff_snapshots(
|
||||
previous: Optional[dict[str, Any]],
|
||||
current: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return list of rate changes between previous and current snapshots.
|
||||
Returns empty list if previous is None (first check)."""
|
||||
if not previous:
|
||||
return []
|
||||
old_groups: dict[str, Any] = previous.get("groups") or {}
|
||||
new_groups: dict[str, Any] = current.get("groups") or {}
|
||||
changes: list[dict[str, Any]] = []
|
||||
for gid, new_g in sorted(new_groups.items()):
|
||||
if not isinstance(new_g, dict):
|
||||
continue
|
||||
old_g = old_groups.get(gid)
|
||||
old_rate = old_g.get("rate") if isinstance(old_g, dict) else None
|
||||
new_rate = new_g.get("rate")
|
||||
if old_rate != new_rate:
|
||||
changes.append({
|
||||
"group_id": gid,
|
||||
"group_name": new_g.get("group_name", ""),
|
||||
"platform": new_g.get("platform", ""),
|
||||
"old_rate": old_rate,
|
||||
"new_rate": new_rate,
|
||||
})
|
||||
for gid, old_g in sorted(old_groups.items()):
|
||||
if gid not in new_groups and isinstance(old_g, dict):
|
||||
changes.append({
|
||||
"group_id": gid,
|
||||
"group_name": old_g.get("group_name", ""),
|
||||
"platform": old_g.get("platform", ""),
|
||||
"old_rate": old_g.get("rate"),
|
||||
"new_rate": None,
|
||||
})
|
||||
return changes
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Upstream HTTP client — ported from monitor_ai98pro_group_rates.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urljoin
|
||||
import httpx
|
||||
|
||||
|
||||
class UpstreamError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _find_token(value: Any) -> str:
|
||||
if isinstance(value, str) and value.count(".") >= 2:
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
for key in ("token", "access_token", "accessToken", "jwt", "auth_token", "authToken"):
|
||||
candidate = value.get(key)
|
||||
if isinstance(candidate, str) and candidate:
|
||||
return candidate
|
||||
for key in ("data", "result", "user", "session"):
|
||||
tok = _find_token(value.get(key))
|
||||
if tok:
|
||||
return tok
|
||||
return ""
|
||||
|
||||
|
||||
def _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]:
|
||||
if isinstance(value, list):
|
||||
return [i for i in value if isinstance(i, dict)]
|
||||
if isinstance(value, dict):
|
||||
for key in ("data", "items", "groups", "available_groups", "availableGroups"):
|
||||
nested = value.get(key)
|
||||
if isinstance(nested, list):
|
||||
return [i for i in nested if isinstance(i, dict)]
|
||||
return None
|
||||
|
||||
|
||||
def _decimal_str(value: Any) -> str:
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
try:
|
||||
d = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
return str(value)
|
||||
n = d.normalize()
|
||||
if n == n.to_integral():
|
||||
return str(n.quantize(Decimal("1")))
|
||||
return format(n, "f")
|
||||
|
||||
|
||||
def _group_id(group: dict[str, Any]) -> str:
|
||||
for key in ("id", "group_id", "groupId"):
|
||||
v = group.get(key)
|
||||
if v is not None:
|
||||
return str(v)
|
||||
name = str(group.get("name") or group.get("group_name") or "")
|
||||
platform = str(group.get("platform") or "")
|
||||
return f"{platform}:{name}"
|
||||
|
||||
|
||||
def _rate_from_group(group: dict[str, Any]) -> str:
|
||||
for key in (
|
||||
"user_rate_multiplier", "userRateMultiplier",
|
||||
"effective_rate_multiplier", "effectiveRateMultiplier",
|
||||
"rate_multiplier", "rateMultiplier",
|
||||
):
|
||||
r = _decimal_str(group.get(key))
|
||||
if r:
|
||||
return r
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_rates_map(raw: Any) -> dict[str, str]:
|
||||
if raw is None:
|
||||
return {}
|
||||
if isinstance(raw, dict):
|
||||
candidates = raw
|
||||
for key in ("data", "rates", "group_rates", "groupRates"):
|
||||
nested = raw.get(key)
|
||||
if isinstance(nested, dict):
|
||||
candidates = nested
|
||||
break
|
||||
result: dict[str, str] = {}
|
||||
for k, v in candidates.items():
|
||||
if isinstance(v, dict):
|
||||
r = _decimal_str(
|
||||
v.get("rate_multiplier") or v.get("rateMultiplier")
|
||||
or v.get("user_rate_multiplier") or v.get("userRateMultiplier")
|
||||
)
|
||||
else:
|
||||
r = _decimal_str(v)
|
||||
if r:
|
||||
result[str(k)] = r
|
||||
return result
|
||||
if isinstance(raw, list):
|
||||
result = {}
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
gid = _group_id(item)
|
||||
rate = _rate_from_group(item)
|
||||
if gid and rate:
|
||||
result[gid] = rate
|
||||
return result
|
||||
return {}
|
||||
|
||||
|
||||
def build_snapshot(upstream_id: int, base_url: str, api_prefix: str,
|
||||
groups: list[dict[str, Any]], raw_rates: Any) -> dict[str, Any]:
|
||||
from datetime import datetime, timezone
|
||||
override_rates = _extract_rates_map(raw_rates)
|
||||
entries: dict[str, dict[str, Any]] = {}
|
||||
for g in groups:
|
||||
gid = _group_id(g)
|
||||
default_rate = _rate_from_group(g)
|
||||
effective_rate = override_rates.get(gid, default_rate)
|
||||
entries[gid] = {
|
||||
"group_id": gid,
|
||||
"group_name": g.get("name") or g.get("group_name") or "",
|
||||
"platform": g.get("platform") or "",
|
||||
"rate": effective_rate,
|
||||
"default_rate": default_rate,
|
||||
"override_rate": override_rates.get(gid, ""),
|
||||
}
|
||||
return {
|
||||
"upstream_id": upstream_id,
|
||||
"base_url": base_url.rstrip("/"),
|
||||
"api_prefix": api_prefix,
|
||||
"captured_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||
"groups": entries,
|
||||
}
|
||||
|
||||
|
||||
class UpstreamClient:
|
||||
"""Sync HTTP client that handles all auth types."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_prefix: str,
|
||||
auth_type: str,
|
||||
auth_config: dict[str, Any],
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_prefix = api_prefix.strip("/")
|
||||
self.auth_type = auth_type
|
||||
self.auth_config = auth_config
|
||||
self.timeout = timeout
|
||||
self._token: str = ""
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
prefix = f"/{self.api_prefix}" if self.api_prefix else ""
|
||||
return f"{self.base_url}{prefix}/{path.lstrip('/')}"
|
||||
|
||||
def _headers(self, auth: bool = True) -> dict[str, str]:
|
||||
headers: dict[str, str] = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "SmartUp/1.0",
|
||||
}
|
||||
if not auth:
|
||||
return headers
|
||||
if self.auth_type == "bearer":
|
||||
token = self.auth_config.get("token", "")
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
elif self.auth_type == "api_key":
|
||||
key = self.auth_config.get("key", "")
|
||||
header = self.auth_config.get("header", "Authorization")
|
||||
if key:
|
||||
headers[header] = key
|
||||
elif self.auth_type == "login_password" and self._token:
|
||||
headers["Authorization"] = f"Bearer {self._token}"
|
||||
return headers
|
||||
|
||||
def _request(self, method: str, path: str, body: Any = None, auth: bool = True) -> Any:
|
||||
url = self._url(path)
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
if body is not None:
|
||||
resp = client.request(method, url, json=body, headers=self._headers(auth))
|
||||
else:
|
||||
resp = client.request(method, url, headers=self._headers(auth))
|
||||
resp.raise_for_status()
|
||||
ct = resp.headers.get("content-type", "")
|
||||
if not resp.content:
|
||||
return None
|
||||
text = resp.text
|
||||
if "application/json" not in ct and text.lstrip().startswith("<"):
|
||||
raise UpstreamError(f"{method} {path} returned HTML, not JSON")
|
||||
return resp.json()
|
||||
|
||||
def login(self) -> None:
|
||||
if self.auth_type != "login_password":
|
||||
return
|
||||
email = self.auth_config.get("email", "")
|
||||
password = self.auth_config.get("password", "")
|
||||
login_path = self.auth_config.get("login_path", "/auth/login")
|
||||
if not email or not password:
|
||||
raise UpstreamError("login_password auth requires email and password in auth_config")
|
||||
resp = self._request("POST", login_path, {"email": email, "password": password}, auth=False)
|
||||
token = _find_token(resp)
|
||||
if not token:
|
||||
raise UpstreamError("login succeeded but no token found in response")
|
||||
self._token = token
|
||||
|
||||
def get_available_groups(self, endpoint: str) -> list[dict[str, Any]]:
|
||||
resp = self._request("GET", endpoint)
|
||||
groups = _unwrap_list(resp)
|
||||
if groups is None:
|
||||
raise UpstreamError(f"{endpoint} did not return a list")
|
||||
return groups
|
||||
|
||||
def get_group_rates(self, endpoint: str) -> Any:
|
||||
return self._request("GET", endpoint)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Send webhook notifications and write notification logs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.webhook_config import WebhookConfig
|
||||
from app.models.notification_log import NotificationLog
|
||||
from app.utils.dingtalk import (
|
||||
dingtalk_signed_url,
|
||||
format_dingtalk_rate_changed,
|
||||
format_dingtalk_status,
|
||||
)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _log(
|
||||
db: Session,
|
||||
webhook: WebhookConfig,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
status: str,
|
||||
response_text: str,
|
||||
) -> None:
|
||||
entry = NotificationLog(
|
||||
webhook_config_id=webhook.id,
|
||||
webhook_name=webhook.name,
|
||||
event_type=event_type,
|
||||
payload_json=json.dumps(payload, ensure_ascii=False),
|
||||
status=status,
|
||||
response_text=response_text[:2000] if response_text else None,
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _send_generic(url: str, payload: dict[str, Any], timeout: float = 15.0) -> str:
|
||||
resp = httpx.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json", "User-Agent": "SmartUp/1.0"},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.text[:500]
|
||||
|
||||
|
||||
def _send_dingtalk(url: str, secret: str, payload: dict[str, Any], timeout: float = 15.0) -> str:
|
||||
signed = dingtalk_signed_url(url, secret) if secret else url
|
||||
resp = httpx.post(
|
||||
signed,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json", "User-Agent": "SmartUp/1.0"},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
if result.get("errcode", 0) != 0:
|
||||
raise RuntimeError(f"DingTalk error: {resp.text[:300]}")
|
||||
return resp.text[:500]
|
||||
|
||||
|
||||
def send_rate_changed(
|
||||
db: Session,
|
||||
upstream_id: int,
|
||||
upstream_name: str,
|
||||
base_url: str,
|
||||
changes: list[dict[str, Any]],
|
||||
) -> None:
|
||||
webhooks = (
|
||||
db.query(WebhookConfig)
|
||||
.filter(WebhookConfig.enabled == True)
|
||||
.all()
|
||||
)
|
||||
changed_at = _now_iso()
|
||||
generic_payload = {
|
||||
"event": "upstream_rate_changed",
|
||||
"upstream": {"id": upstream_id, "name": upstream_name, "base_url": base_url},
|
||||
"changed_at": changed_at,
|
||||
"changes": changes,
|
||||
}
|
||||
for wh in webhooks:
|
||||
events = json.loads(wh.events_json or "[]")
|
||||
if "upstream_rate_changed" not in events:
|
||||
continue
|
||||
try:
|
||||
if wh.type == "dingtalk":
|
||||
msg = format_dingtalk_rate_changed(upstream_name, changed_at, changes)
|
||||
resp_text = _send_dingtalk(wh.url, wh.secret, msg)
|
||||
else:
|
||||
resp_text = _send_generic(wh.url, generic_payload)
|
||||
_log(db, wh, "upstream_rate_changed", generic_payload, "success", resp_text)
|
||||
except Exception as exc:
|
||||
_log(db, wh, "upstream_rate_changed", generic_payload, "failed", str(exc))
|
||||
|
||||
|
||||
def send_status_event(
|
||||
db: Session,
|
||||
upstream_id: int,
|
||||
upstream_name: str,
|
||||
base_url: str,
|
||||
event: str,
|
||||
error: str = "",
|
||||
) -> None:
|
||||
webhooks = (
|
||||
db.query(WebhookConfig)
|
||||
.filter(WebhookConfig.enabled == True)
|
||||
.all()
|
||||
)
|
||||
changed_at = _now_iso()
|
||||
generic_payload = {
|
||||
"event": event,
|
||||
"upstream": {"id": upstream_id, "name": upstream_name, "base_url": base_url},
|
||||
"changed_at": changed_at,
|
||||
"error": error,
|
||||
}
|
||||
for wh in webhooks:
|
||||
events = json.loads(wh.events_json or "[]")
|
||||
if event not in events:
|
||||
continue
|
||||
try:
|
||||
if wh.type == "dingtalk":
|
||||
msg = format_dingtalk_status(upstream_name, event, changed_at, error)
|
||||
resp_text = _send_dingtalk(wh.url, wh.secret, msg)
|
||||
else:
|
||||
resp_text = _send_generic(wh.url, generic_payload)
|
||||
_log(db, wh, event, generic_payload, "success", resp_text)
|
||||
except Exception as exc:
|
||||
_log(db, wh, event, generic_payload, "failed", str(exc))
|
||||
|
||||
|
||||
def send_test_notification(db: Session, webhook: WebhookConfig) -> tuple[bool, str]:
|
||||
payload = {
|
||||
"event": "test",
|
||||
"message": "SmartUp webhook test notification",
|
||||
"sent_at": _now_iso(),
|
||||
}
|
||||
try:
|
||||
if webhook.type == "dingtalk":
|
||||
msg = {
|
||||
"msgtype": "text",
|
||||
"text": {"content": "✅ SmartUp webhook 测试通知\n配置正常,连接成功。"},
|
||||
}
|
||||
resp_text = _send_dingtalk(webhook.url, webhook.secret, msg)
|
||||
else:
|
||||
resp_text = _send_generic(webhook.url, payload)
|
||||
_log(db, webhook, "test", payload, "success", resp_text)
|
||||
return True, "发送成功"
|
||||
except Exception as exc:
|
||||
_log(db, webhook, "test", payload, "failed", str(exc))
|
||||
return False, str(exc)
|
||||
@@ -0,0 +1 @@
|
||||
# utils package
|
||||
@@ -0,0 +1,74 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
import bcrypt
|
||||
from fastapi import Depends, HTTPException, Query, Request, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models.admin_user import AdminUser
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
pw = password.encode("utf-8")[:72]
|
||||
return bcrypt.hashpw(pw, bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
pw = plain.encode("utf-8")[:72]
|
||||
return bcrypt.checkpw(pw, hashed.encode("utf-8"))
|
||||
|
||||
|
||||
def create_access_token(email: str, expires_hours: Optional[int] = None) -> str:
|
||||
settings = get_settings()
|
||||
hours = expires_hours or settings.jwt_expire_hours
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=hours)
|
||||
data = {"sub": email, "exp": expire}
|
||||
return jwt.encode(data, settings.jwt_secret, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[str]:
|
||||
settings = get_settings()
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
|
||||
return payload.get("sub")
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AdminUser:
|
||||
token = credentials.credentials if credentials else None
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
email = decode_token(token)
|
||||
if not email:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
user = db.query(AdminUser).filter(AdminUser.email == email).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
def get_user_from_token_param(
|
||||
token: Optional[str] = Query(default=None),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AdminUser:
|
||||
"""Accept JWT from ?token= query param (for iframe src) OR Authorization header."""
|
||||
raw = token or (credentials.credentials if credentials else None)
|
||||
if not raw:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
email = decode_token(raw)
|
||||
if not email:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
user = db.query(AdminUser).filter(AdminUser.email == email).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
return user
|
||||
@@ -0,0 +1,59 @@
|
||||
"""DingTalk webhook signing and message formatting (ported from monitor script)."""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
|
||||
def dingtalk_signed_url(webhook_url: str, secret: str) -> str:
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
string_to_sign = f"{timestamp}\n{secret}".encode("utf-8")
|
||||
digest = hmac.new(secret.encode("utf-8"), string_to_sign, hashlib.sha256).digest()
|
||||
sign = quote_plus(base64.b64encode(digest).decode("utf-8"))
|
||||
sep = "&" if "?" in webhook_url else "?"
|
||||
return f"{webhook_url}{sep}timestamp={timestamp}&sign={sign}"
|
||||
|
||||
|
||||
def format_dingtalk_rate_changed(upstream_name: str, changed_at: str, changes: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
lines = [
|
||||
f"### 📊 {upstream_name} 分组倍率变更",
|
||||
"",
|
||||
f"- **时间**:{changed_at}",
|
||||
f"- **变化数量**:{len(changes)}",
|
||||
"",
|
||||
]
|
||||
for ch in changes:
|
||||
name = ch.get("group_name") or ch.get("group_id") or "unknown"
|
||||
platform = ch.get("platform") or "-"
|
||||
old = ch.get("old_rate")
|
||||
new = ch.get("new_rate")
|
||||
lines.append(f"- `{name}` ({platform}):`{old}` → `{new}`")
|
||||
return {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"title": f"{upstream_name} 分组倍率变更",
|
||||
"text": "\n".join(lines),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def format_dingtalk_status(upstream_name: str, event: str, changed_at: str, error: str = "") -> dict[str, Any]:
|
||||
emoji = "🔴" if event == "upstream_unhealthy" else "🟢"
|
||||
label = "服务异常" if event == "upstream_unhealthy" else "服务恢复"
|
||||
lines = [
|
||||
f"### {emoji} {upstream_name} {label}",
|
||||
"",
|
||||
f"- **时间**:{changed_at}",
|
||||
]
|
||||
if error:
|
||||
lines.append(f"- **错误**:{error}")
|
||||
return {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"title": f"{upstream_name} {label}",
|
||||
"text": "\n".join(lines),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
fastapi==0.115.5
|
||||
uvicorn[standard]==0.32.1
|
||||
sqlalchemy==2.0.36
|
||||
python-jose[cryptography]==3.3.0
|
||||
bcrypt==4.2.1
|
||||
httpx==0.28.1
|
||||
apscheduler==3.10.4
|
||||
python-dotenv==1.0.1
|
||||
pydantic-settings==2.6.1
|
||||
python-multipart==0.0.20
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
smartup:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: smartup
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${BIND_HOST:-0.0.0.0}:${SERVER_PORT:-8899}:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@smartup.local}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD is required}
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- DATABASE_URL=sqlite:////app/data/app.db
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- UNHEALTHY_THRESHOLD=${UNHEALTHY_THRESHOLD:-3}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-T", "5", "-O", "/dev/null", "http://localhost:8000/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="SmartUp — API 上游管理与 Webhook 通知系统" />
|
||||
<title>SmartUp 管理后台</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2049
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "smartup-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.4.5",
|
||||
"pinia": "^2.2.6",
|
||||
"element-plus": "^2.8.8",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (!auth.token && router.currentRoute.value.meta.requiresAuth) {
|
||||
router.push('/login')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,150 @@
|
||||
import axios from 'axios'
|
||||
import router from '@/router'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(r) => r,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('smartup_token')
|
||||
localStorage.removeItem('smartup_email')
|
||||
router.push('/login')
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
// ——— Auth ———
|
||||
export const authApi = {
|
||||
login: (email: string, password: string) =>
|
||||
api.post<{ access_token: string }>('/api/auth/login', { email, password }),
|
||||
me: () => api.get<{ email: string }>('/api/auth/me'),
|
||||
}
|
||||
|
||||
// ——— Upstreams ———
|
||||
export interface UpstreamData {
|
||||
id: number
|
||||
name: string
|
||||
base_url: string
|
||||
api_prefix: string
|
||||
auth_type: string
|
||||
auth_config_masked: Record<string, any>
|
||||
rate_endpoint: string
|
||||
groups_endpoint: string
|
||||
enabled: boolean
|
||||
check_interval_seconds: number
|
||||
timeout_seconds: number
|
||||
last_status: string
|
||||
last_checked_at: string | null
|
||||
last_error: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface UpstreamForm {
|
||||
name: string
|
||||
base_url: string
|
||||
api_prefix: string
|
||||
auth_type: string
|
||||
auth_config: Record<string, any>
|
||||
rate_endpoint: string
|
||||
groups_endpoint: string
|
||||
enabled: boolean
|
||||
check_interval_seconds: number
|
||||
timeout_seconds: number
|
||||
}
|
||||
|
||||
export const upstreamsApi = {
|
||||
list: () => api.get<UpstreamData[]>('/api/upstreams'),
|
||||
create: (data: UpstreamForm) => api.post<UpstreamData>('/api/upstreams', data),
|
||||
update: (id: number, data: Partial<UpstreamForm>) => api.put<UpstreamData>(`/api/upstreams/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/api/upstreams/${id}`),
|
||||
test: (id: number) => api.post<{ success: boolean; message: string; detail?: string }>(`/api/upstreams/${id}/test`),
|
||||
checkNow: (id: number) => api.post<{ success: boolean; message: string }>(`/api/upstreams/${id}/check-now`),
|
||||
latestSnapshot: (id: number) => api.get(`/api/upstreams/${id}/snapshots/latest`),
|
||||
listSnapshots: (id: number, limit = 20, offset = 0) =>
|
||||
api.get<any[]>(`/api/upstreams/${id}/snapshots`, { params: { limit, offset } }),
|
||||
}
|
||||
|
||||
// ——— Webhooks ———
|
||||
export interface WebhookData {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
url: string
|
||||
secret_masked: string
|
||||
enabled: boolean
|
||||
events: string[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface WebhookForm {
|
||||
name: string
|
||||
type: string
|
||||
url: string
|
||||
secret: string
|
||||
enabled: boolean
|
||||
events: string[]
|
||||
}
|
||||
|
||||
export const webhooksApi = {
|
||||
list: () => api.get<WebhookData[]>('/api/webhooks'),
|
||||
create: (data: WebhookForm) => api.post<WebhookData>('/api/webhooks', data),
|
||||
update: (id: number, data: Partial<WebhookForm>) => api.put<WebhookData>(`/api/webhooks/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/api/webhooks/${id}`),
|
||||
test: (id: number) => api.post<{ success: boolean; message: string }>(`/api/webhooks/${id}/test`),
|
||||
}
|
||||
|
||||
// ——— Logs ———
|
||||
export interface LogData {
|
||||
id: number
|
||||
webhook_config_id: number
|
||||
webhook_name: string
|
||||
event_type: string
|
||||
payload: Record<string, any>
|
||||
status: string
|
||||
response_text: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const logsApi = {
|
||||
list: (params?: { status?: string; event_type?: string; limit?: number; offset?: number }) =>
|
||||
api.get<LogData[]>('/api/notification-logs', { params }),
|
||||
}
|
||||
|
||||
// ——— Custom Pages ———
|
||||
export interface CustomPageData {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
icon: string
|
||||
sort_order: number
|
||||
enabled: boolean
|
||||
use_proxy: boolean
|
||||
description: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CustomPageForm {
|
||||
name: string
|
||||
url: string
|
||||
icon: string
|
||||
sort_order: number
|
||||
enabled: boolean
|
||||
use_proxy: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const customPagesApi = {
|
||||
list: () => api.get<CustomPageData[]>('/api/custom-pages'),
|
||||
listPublic: () => axios.get<CustomPageData[]>('/api/custom-pages/public'),
|
||||
create: (data: CustomPageForm) => api.post<CustomPageData>('/api/custom-pages', data),
|
||||
update: (id: number, data: Partial<CustomPageForm>) => api.put<CustomPageData>(`/api/custom-pages/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/api/custom-pages/${id}`),
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/* SmartUp — Global CSS */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-base: #0d1117;
|
||||
--bg-surface: #161b22;
|
||||
--bg-card: #1c2128;
|
||||
--bg-elevated: #22272e;
|
||||
--border-color: #30363d;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #6e7681;
|
||||
--color-primary: #6366f1;
|
||||
--color-primary-hover: #818cf8;
|
||||
--color-success: #22c55e;
|
||||
--color-danger: #ef4444;
|
||||
--color-warning: #f59e0b;
|
||||
--color-info: #38bdf8;
|
||||
--sidebar-width: 220px;
|
||||
--topbar-height: 56px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background-color: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Element Plus dark mode overrides */
|
||||
.el-table { --el-table-bg-color: var(--bg-card); --el-table-tr-bg-color: var(--bg-card); --el-table-header-bg-color: var(--bg-elevated); --el-table-border-color: var(--border-color); --el-table-text-color: var(--text-primary); --el-table-header-text-color: var(--text-secondary); }
|
||||
.el-dialog { --el-dialog-bg-color: var(--bg-surface); --el-dialog-border-color: var(--border-color); }
|
||||
.el-drawer { --el-drawer-bg-color: var(--bg-surface); }
|
||||
.el-form-item__label { color: var(--text-secondary); }
|
||||
.el-input__wrapper { background-color: var(--bg-elevated) !important; box-shadow: 0 0 0 1px var(--border-color) inset !important; }
|
||||
.el-input__inner { color: var(--text-primary) !important; }
|
||||
.el-select-dropdown { background-color: var(--bg-elevated); border-color: var(--border-color); }
|
||||
.el-select-dropdown__item { color: var(--text-primary); }
|
||||
.el-select-dropdown__item.hover, .el-select-dropdown__item:hover { background-color: var(--bg-surface); }
|
||||
.el-tag { border-radius: 6px; }
|
||||
.el-button--primary { background-color: var(--color-primary); border-color: var(--color-primary); }
|
||||
.el-button--primary:hover { background-color: var(--color-primary-hover); border-color: var(--color-primary-hover); }
|
||||
|
||||
/* Status badges */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-badge.healthy { background: rgba(34,197,94,0.15); color: var(--color-success); }
|
||||
.status-badge.unhealthy { background: rgba(239,68,68,0.15); color: var(--color-danger); }
|
||||
.status-badge.unknown { background: rgba(110,118,129,0.2); color: var(--text-muted); }
|
||||
.status-badge.enabled { background: rgba(99,102,241,0.15); color: var(--color-primary); }
|
||||
.status-badge.disabled { background: rgba(110,118,129,0.15); color: var(--text-muted); }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
|
||||
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<span class="logo-icon">⚡</span>
|
||||
<span class="logo-text">SmartUp</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<!-- Fixed nav -->
|
||||
<div class="nav-group-label">监控</div>
|
||||
<router-link to="/upstreams" class="nav-item" active-class="active">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<span>上游管理</span>
|
||||
</router-link>
|
||||
<router-link to="/webhooks" class="nav-item" active-class="active">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>Webhook 通知</span>
|
||||
</router-link>
|
||||
<router-link to="/logs" class="nav-item" active-class="active">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>通知日志</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="nav-divider" />
|
||||
|
||||
<!-- Custom pages section -->
|
||||
<div class="nav-group-label">
|
||||
自定义页面
|
||||
<router-link to="/custom-pages" class="nav-manage-link" title="管理自定义页面">
|
||||
<el-icon :size="12"><Setting /></el-icon>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<template v-if="customPages.length > 0">
|
||||
<router-link
|
||||
v-for="page in customPages"
|
||||
:key="page.id"
|
||||
:to="`/page/${page.id}`"
|
||||
class="nav-item"
|
||||
active-class="active"
|
||||
>
|
||||
<el-icon><component :is="iconMap[page.icon] || LinkIcon" /></el-icon>
|
||||
<span class="nav-custom-label">{{ page.name }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
<div v-else class="nav-empty-pages">
|
||||
<router-link to="/custom-pages" class="add-page-link">
|
||||
<el-icon><Plus /></el-icon> 添加页面
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="main-wrap">
|
||||
<!-- Topbar -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-title">{{ pageTitle }}</div>
|
||||
<div class="topbar-right">
|
||||
<span class="user-email">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ auth.email }}
|
||||
</span>
|
||||
<el-button size="small" text @click="handleLogout" style="color:var(--text-secondary)">
|
||||
<el-icon><SwitchButton /></el-icon> 退出
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Page content -->
|
||||
<main class="page-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, markRaw } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
Link as LinkIcon, Plus, Setting,
|
||||
Monitor, SetUp, Reading, Cpu, DataLine,
|
||||
Grid, Ticket, Wallet, Key, Tools, Star, House,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { customPagesApi, type CustomPageData } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
// ---- icon map (must match CustomPages.vue) ----
|
||||
const iconMap: Record<string, any> = {
|
||||
Link: markRaw(LinkIcon), Monitor: markRaw(Monitor), SetUp: markRaw(SetUp),
|
||||
Reading: markRaw(Reading), Cpu: markRaw(Cpu), DataLine: markRaw(DataLine),
|
||||
Grid: markRaw(Grid), Ticket: markRaw(Ticket), Wallet: markRaw(Wallet),
|
||||
Key: markRaw(Key), Tools: markRaw(Tools), Star: markRaw(Star), House: markRaw(House),
|
||||
}
|
||||
|
||||
// ---- custom pages nav ----
|
||||
const customPages = ref<CustomPageData[]>([])
|
||||
|
||||
async function loadCustomPages() {
|
||||
try {
|
||||
const res = await customPagesApi.list()
|
||||
customPages.value = res.data.filter(p => p.enabled)
|
||||
} catch { /* sidebar load failure is non-critical */ }
|
||||
}
|
||||
|
||||
// Refresh when management page emits update event
|
||||
function onPagesUpdated() { loadCustomPages() }
|
||||
|
||||
// ---- page title ----
|
||||
const staticTitles: Record<string, string> = {
|
||||
'/upstreams': '上游管理',
|
||||
'/webhooks': 'Webhook 通知',
|
||||
'/logs': '通知日志',
|
||||
'/custom-pages': '自定义页面',
|
||||
}
|
||||
const pageTitle = computed(() => {
|
||||
if (route.path.startsWith('/page/')) {
|
||||
const id = Number(route.params.id)
|
||||
const found = customPages.value.find(p => p.id === id)
|
||||
return found ? found.name : '嵌入页面'
|
||||
}
|
||||
return staticTitles[route.path] || 'SmartUp'
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
auth.clear()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomPages()
|
||||
window.addEventListener('custom-pages-updated', onPagesUpdated)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('custom-pages-updated', onPagesUpdated)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
height: var(--topbar-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-surface);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo-icon { font-size: 22px; }
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #6366f1, #818cf8);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-group-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 8px 14px 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nav-manage-link {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.nav-manage-link:hover { color: var(--color-primary); background: var(--bg-elevated); }
|
||||
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 8px 10px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.nav-item:hover { background: var(--bg-elevated); color: var(--text-primary); }
|
||||
.nav-item.active { background: rgba(99,102,241,0.15); color: var(--color-primary); }
|
||||
|
||||
.nav-custom-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-empty-pages {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.add-page-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
border: 1px dashed var(--border-color);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.add-page-link:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
/* ---- Main wrap ---- */
|
||||
.main-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: var(--topbar-height);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||||
.topbar-right { display: flex; align-items: center; gap: 12px; }
|
||||
.user-email { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); }
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import * as ElIcons from '@element-plus/icons-vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Register all Element Plus icons globally
|
||||
for (const [name, component] of Object.entries(ElIcons)) {
|
||||
app.component(name, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/components/AppLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
redirect: '/upstreams',
|
||||
children: [
|
||||
{ path: 'upstreams', component: () => import('@/views/Upstreams.vue') },
|
||||
{ path: 'webhooks', component: () => import('@/views/Webhooks.vue') },
|
||||
{ path: 'logs', component: () => import('@/views/NotificationLogs.vue') },
|
||||
{ path: 'custom-pages', component: () => import('@/views/CustomPages.vue') },
|
||||
{ path: 'page/:id', component: () => import('@/views/PageViewer.vue') },
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const auth = useAuthStore()
|
||||
if (to.meta.requiresAuth && !auth.token) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && auth.token) {
|
||||
next('/upstreams')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string>(localStorage.getItem('smartup_token') || '')
|
||||
const email = ref<string>(localStorage.getItem('smartup_email') || '')
|
||||
|
||||
function setToken(t: string, e: string) {
|
||||
token.value = t
|
||||
email.value = e
|
||||
localStorage.setItem('smartup_token', t)
|
||||
localStorage.setItem('smartup_email', e)
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${t}`
|
||||
}
|
||||
|
||||
function clear() {
|
||||
token.value = ''
|
||||
email.value = ''
|
||||
localStorage.removeItem('smartup_token')
|
||||
localStorage.removeItem('smartup_email')
|
||||
delete api.defaults.headers.common['Authorization']
|
||||
}
|
||||
|
||||
// Restore token on load
|
||||
if (token.value) {
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
|
||||
}
|
||||
|
||||
return { token, email, setToken, clear }
|
||||
})
|
||||
@@ -0,0 +1,325 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">自定义页面</h2>
|
||||
<p class="page-desc">嵌入外部网页到侧边栏,统一管理上游平台</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-icon><Plus /></el-icon> 添加页面
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Cards grid -->
|
||||
<div v-loading="loading" class="pages-grid">
|
||||
<div v-for="page in list" :key="page.id" class="page-card" :class="{ disabled: !page.enabled }">
|
||||
<!-- Icon + title -->
|
||||
<div class="card-head">
|
||||
<div class="card-icon">
|
||||
<el-icon :size="22"><component :is="iconMap[page.icon] || LinkIcon" /></el-icon>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-name">{{ page.name }}</div>
|
||||
<div class="card-url" :title="page.url">{{ page.url }}</div>
|
||||
</div>
|
||||
<div class="tag-group">
|
||||
<el-tag v-if="page.use_proxy" size="small" type="warning" class="proxy-tag">代理</el-tag>
|
||||
<el-tag v-if="!page.enabled" size="small" type="info" class="disabled-tag">已停用</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="page.description" class="card-desc">{{ page.description }}</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card-actions">
|
||||
<el-button size="small" text type="primary" @click="openViewer(page)">
|
||||
<el-icon><Monitor /></el-icon> 打开
|
||||
</el-button>
|
||||
<el-button size="small" text @click="openEdit(page)">
|
||||
<el-icon><Edit /></el-icon> 编辑
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="confirmDelete(page)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
<div class="sort-hint">排序: {{ page.sort_order }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && list.length === 0" class="empty-state">
|
||||
<el-icon :size="48" class="empty-icon"><Monitor /></el-icon>
|
||||
<p>还没有自定义页面</p>
|
||||
<p class="empty-sub">添加后可在侧边栏快速访问上游管理平台</p>
|
||||
<el-button type="primary" @click="openCreate">立即添加</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create / Edit dialog -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="editingId ? '编辑页面' : '添加自定义页面'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="例:ai98pro 管理台" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网址 URL" prop="url">
|
||||
<el-input v-model="form.url" placeholder="https://ai98pro.xyz/home" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标">
|
||||
<el-select v-model="form.icon" style="width:100%">
|
||||
<el-option v-for="(_, key) in iconMap" :key="key" :label="key" :value="key">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<el-icon><component :is="iconMap[key]" /></el-icon>
|
||||
<span>{{ key }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" placeholder="可选描述" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort_order" :min="0" :max="999" style="width:140px" />
|
||||
<span class="form-hint">数字越小越靠前</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="代理模式">
|
||||
<el-switch v-model="form.use_proxy" />
|
||||
<span class="form-hint">网站拒绝嵌入时开启,由服务端转发请求</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, markRaw } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import {
|
||||
Link as LinkIcon, Monitor, Edit, Delete, Plus,
|
||||
SetUp, Reading, Cpu, DataLine, Grid, Connection,
|
||||
Ticket, Wallet, Key, Tools, Star, House,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { customPagesApi, type CustomPageData } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// ---- icon map ----
|
||||
const iconMap: Record<string, any> = {
|
||||
Link: markRaw(LinkIcon),
|
||||
Monitor: markRaw(Monitor),
|
||||
SetUp: markRaw(SetUp),
|
||||
Reading: markRaw(Reading),
|
||||
Cpu: markRaw(Cpu),
|
||||
DataLine: markRaw(DataLine),
|
||||
Grid: markRaw(Grid),
|
||||
Connection: markRaw(Connection),
|
||||
Ticket: markRaw(Ticket),
|
||||
Wallet: markRaw(Wallet),
|
||||
Key: markRaw(Key),
|
||||
Tools: markRaw(Tools),
|
||||
Star: markRaw(Star),
|
||||
House: markRaw(House),
|
||||
}
|
||||
|
||||
// ---- state ----
|
||||
const list = ref<CustomPageData[]>([])
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '',
|
||||
url: '',
|
||||
icon: 'Link',
|
||||
sort_order: 0,
|
||||
enabled: true,
|
||||
use_proxy: false,
|
||||
description: '',
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入页面名称', trigger: 'blur' }],
|
||||
url: [{ required: true, message: '请输入网址', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await customPagesApi.list()
|
||||
list.value = res.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
form.value = defaultForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(page: CustomPageData) {
|
||||
editingId.value = page.id
|
||||
form.value = {
|
||||
name: page.name,
|
||||
url: page.url,
|
||||
icon: page.icon,
|
||||
sort_order: page.sort_order,
|
||||
enabled: page.enabled,
|
||||
use_proxy: page.use_proxy,
|
||||
description: page.description || '',
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await customPagesApi.update(editingId.value, form.value)
|
||||
} else {
|
||||
await customPagesApi.create(form.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadList()
|
||||
// Notify AppLayout to refresh its nav
|
||||
window.dispatchEvent(new Event('custom-pages-updated'))
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(page: CustomPageData) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除 "${page.name}" ?`, '删除确认', { type: 'warning' })
|
||||
await customPagesApi.delete(page.id)
|
||||
ElMessage.success('已删除')
|
||||
loadList()
|
||||
window.dispatchEvent(new Event('custom-pages-updated'))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function openViewer(page: CustomPageData) {
|
||||
router.push(`/page/${page.id}`)
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
|
||||
.page-desc { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
.pages-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.page-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(99,102,241,0.1);
|
||||
}
|
||||
.page-card.disabled { opacity: 0.55; }
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
background: rgba(99,102,241,0.12);
|
||||
color: var(--color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-info { flex: 1; min-width: 0; }
|
||||
.card-name { font-size: 14px; font-weight: 600; color: var(--text-primary); }
|
||||
.card-url {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.disabled-tag { flex-shrink: 0; }
|
||||
.tag-group { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.proxy-tag { flex-shrink: 0; }
|
||||
|
||||
.card-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.sort-hint {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-hint { font-size: 12px; color: var(--text-muted); margin-left: 8px; }
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-muted);
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-icon { color: var(--border-color); margin-bottom: 8px; }
|
||||
.empty-sub { font-size: 13px; color: var(--text-muted); margin-bottom: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-bg">
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
</div>
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<span class="login-logo">⚡</span>
|
||||
<h1 class="login-title">SmartUp</h1>
|
||||
<p class="login-subtitle">API 上游管理与 Webhook 通知系统</p>
|
||||
</div>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input
|
||||
v-model="form.email"
|
||||
placeholder="admin@example.com"
|
||||
size="large"
|
||||
prefix-icon="Message"
|
||||
autocomplete="email"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
size="large"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
style="width:100%;margin-top:8px"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
<p v-if="errorMsg" class="login-error">{{ errorMsg }}</p>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { authApi } from '@/api'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
const form = ref({ email: '', password: '' })
|
||||
const rules = {
|
||||
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res = await authApi.login(form.value.email, form.value.password)
|
||||
auth.setToken(res.data.access_token, form.value.email)
|
||||
router.push('/upstreams')
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e.response?.data?.detail || '登录失败,请检查账号密码'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-base);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.25;
|
||||
}
|
||||
.orb-1 { width: 400px; height: 400px; background: #6366f1; top: -100px; left: -100px; }
|
||||
.orb-2 { width: 300px; height: 300px; background: #818cf8; bottom: -80px; right: -80px; }
|
||||
|
||||
.login-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 40px 36px;
|
||||
width: 380px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.login-header { text-align: center; margin-bottom: 32px; }
|
||||
.login-logo { font-size: 36px; display: block; margin-bottom: 8px; }
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #6366f1, #818cf8);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.login-subtitle { font-size: 13px; color: var(--text-muted); }
|
||||
.login-error { color: var(--color-danger); font-size: 13px; text-align: center; margin-top: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">通知日志</h2>
|
||||
<p class="page-desc">查看所有 Webhook 通知的发送记录</p>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<el-select v-model="filterStatus" placeholder="状态" clearable style="width:110px" @change="loadList">
|
||||
<el-option label="成功" value="success" />
|
||||
<el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
<el-select v-model="filterEvent" placeholder="事件类型" clearable style="width:150px" @change="loadList">
|
||||
<el-option label="倍率变更" value="upstream_rate_changed" />
|
||||
<el-option label="服务异常" value="upstream_unhealthy" />
|
||||
<el-option label="服务恢复" value="upstream_recovered" />
|
||||
<el-option label="测试" value="test" />
|
||||
</el-select>
|
||||
<el-button @click="loadList" :loading="tableLoading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<el-table :data="list" v-loading="tableLoading" style="width:100%">
|
||||
<el-table-column label="时间" width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Webhook" width="140">
|
||||
<template #default="{ row }">{{ row.webhook_name }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="事件" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="eventTagType(row.event_type)">{{ eventLabel(row.event_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-badge', row.status === 'success' ? 'healthy' : 'unhealthy']">
|
||||
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="响应摘要" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="muted small">{{ row.response_text?.substring(0, 80) || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="详情" width="80" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text @click="viewDetail(row)"><el-icon><View /></el-icon></el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination">
|
||||
<el-button :disabled="offset === 0" @click="prevPage" size="small">上一页</el-button>
|
||||
<span class="page-info">第 {{ offset / limit + 1 }} 页</span>
|
||||
<el-button :disabled="list.length < limit" @click="nextPage" size="small">下一页</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Dialog -->
|
||||
<el-dialog v-model="detailVisible" title="通知详情" width="640px" destroy-on-close>
|
||||
<div v-if="detailRow" class="detail-wrap">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="时间">{{ fmtTime(detailRow.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Webhook">{{ detailRow.webhook_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="事件">{{ eventLabel(detailRow.event_type) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="detailRow.status === 'success' ? 'success' : 'danger'" size="small">
|
||||
{{ detailRow.status === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="detail-section">
|
||||
<div class="detail-label">Payload</div>
|
||||
<pre class="code-block">{{ JSON.stringify(detailRow.payload, null, 2) }}</pre>
|
||||
</div>
|
||||
<div v-if="detailRow.response_text" class="detail-section">
|
||||
<div class="detail-label">响应</div>
|
||||
<pre class="code-block">{{ detailRow.response_text }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { logsApi, type LogData } from '@/api'
|
||||
|
||||
const list = ref<LogData[]>([])
|
||||
const tableLoading = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const detailRow = ref<LogData | null>(null)
|
||||
const filterStatus = ref('')
|
||||
const filterEvent = ref('')
|
||||
const offset = ref(0)
|
||||
const limit = 50
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
upstream_rate_changed: '倍率变更',
|
||||
upstream_unhealthy: '服务异常',
|
||||
upstream_recovered: '服务恢复',
|
||||
test: '测试通知',
|
||||
}
|
||||
const eventLabel = (e: string) => EVENT_LABELS[e] || e
|
||||
const eventTagType = (e: string) =>
|
||||
({ upstream_rate_changed: 'primary', upstream_unhealthy: 'danger', upstream_recovered: 'success', test: 'info' }[e] || '')
|
||||
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : t + 'Z'
|
||||
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm:ss')
|
||||
|
||||
|
||||
async function loadList() {
|
||||
tableLoading.value = true
|
||||
try {
|
||||
const res = await logsApi.list({
|
||||
status: filterStatus.value || undefined,
|
||||
event_type: filterEvent.value || undefined,
|
||||
limit,
|
||||
offset: offset.value,
|
||||
})
|
||||
list.value = res.data
|
||||
} finally {
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function viewDetail(row: LogData) {
|
||||
detailRow.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
offset.value = Math.max(0, offset.value - limit)
|
||||
loadList()
|
||||
}
|
||||
function nextPage() {
|
||||
offset.value += limit
|
||||
loadList()
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
|
||||
.page-desc { font-size: 13px; color: var(--text-muted); }
|
||||
.filters { display: flex; gap: 8px; align-items: center; }
|
||||
.card { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; }
|
||||
.time-text { font-size: 12px; color: var(--text-secondary); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.small { font-size: 12px; }
|
||||
.pagination { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 14px; border-top: 1px solid var(--border-color); }
|
||||
.page-info { font-size: 13px; color: var(--text-secondary); }
|
||||
.detail-section { margin-top: 16px; }
|
||||
.detail-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; font-weight: 500; }
|
||||
.code-block {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div class="viewer-wrap">
|
||||
<!-- Toolbar -->
|
||||
<div class="viewer-bar">
|
||||
<el-button size="small" text @click="router.back()">
|
||||
<el-icon><ArrowLeft /></el-icon> 返回
|
||||
</el-button>
|
||||
<div class="viewer-title">
|
||||
<el-icon><component :is="pageIcon" /></el-icon>
|
||||
<span>{{ page?.name || '...' }}</span>
|
||||
<el-tag v-if="page?.use_proxy" size="small" type="warning" style="margin-left:4px">代理</el-tag>
|
||||
</div>
|
||||
<div class="viewer-url">{{ page?.url }}</div>
|
||||
<div class="viewer-actions">
|
||||
<el-tooltip content="在新标签页打开">
|
||||
<el-button size="small" text @click="openExternal">
|
||||
<el-icon><TopRight /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="刷新">
|
||||
<el-button size="small" text @click="reload">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div v-if="iframeLoading" class="iframe-loading">
|
||||
<el-icon class="spin" :size="32"><Loading /></el-icon>
|
||||
<p>正在加载页面…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state (removed to let browser natively show iframe state) -->
|
||||
|
||||
<iframe
|
||||
v-show="!iframeLoading"
|
||||
ref="iframeRef"
|
||||
:src="iframeSrc"
|
||||
class="page-iframe"
|
||||
@load="onLoad"
|
||||
@error="onError"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, markRaw } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
ArrowLeft, TopRight, Refresh, Loading, WarningFilled,
|
||||
Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine,
|
||||
Grid, Connection, Ticket, Wallet, Key, Tools, Star, House,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { customPagesApi, type CustomPageData } from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const iconMap: Record<string, any> = {
|
||||
Link: markRaw(LinkIcon), Monitor: markRaw(Monitor), SetUp: markRaw(SetUp),
|
||||
Reading: markRaw(Reading), Cpu: markRaw(Cpu), DataLine: markRaw(DataLine),
|
||||
Grid: markRaw(Grid), Connection: markRaw(Connection), Ticket: markRaw(Ticket),
|
||||
Wallet: markRaw(Wallet), Key: markRaw(Key), Tools: markRaw(Tools),
|
||||
Star: markRaw(Star), House: markRaw(House),
|
||||
}
|
||||
|
||||
const page = ref<CustomPageData | null>(null)
|
||||
const iframeRef = ref<HTMLIFrameElement>()
|
||||
const iframeLoading = ref(true)
|
||||
const loadError = ref(false)
|
||||
|
||||
const pageIcon = computed(() => iconMap[page.value?.icon || 'Link'] || LinkIcon)
|
||||
|
||||
// Build the iframe src: use backend proxy if use_proxy=true
|
||||
// Pass JWT token as query param since iframe can't set Authorization header
|
||||
const iframeSrc = computed(() => {
|
||||
if (!page.value) return ''
|
||||
if (page.value.use_proxy) {
|
||||
const encoded = encodeURIComponent(page.value.url)
|
||||
const token = encodeURIComponent(auth.token || '')
|
||||
return `/api/custom-pages/frame-proxy?url=${encoded}&token=${token}`
|
||||
}
|
||||
return page.value.url
|
||||
})
|
||||
|
||||
async function loadPage(id: number) {
|
||||
iframeLoading.value = true
|
||||
loadError.value = false
|
||||
try {
|
||||
const res = await customPagesApi.list()
|
||||
page.value = res.data.find(p => p.id === id) || null
|
||||
if (!page.value) {
|
||||
ElMessage.error('页面不存在')
|
||||
router.push('/custom-pages')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onLoad() {
|
||||
iframeLoading.value = false
|
||||
}
|
||||
|
||||
function onError() {
|
||||
iframeLoading.value = false
|
||||
}
|
||||
|
||||
function openExternal() {
|
||||
if (page.value?.url) window.open(page.value.url, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (!iframeRef.value || !page.value) return
|
||||
iframeLoading.value = true
|
||||
loadError.value = false
|
||||
iframeRef.value.src = iframeSrc.value
|
||||
}
|
||||
|
||||
watch(() => route.params.id, (id) => {
|
||||
if (id) loadPage(Number(id))
|
||||
}, { immediate: false })
|
||||
|
||||
onMounted(() => {
|
||||
loadPage(Number(route.params.id))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.viewer-wrap {
|
||||
/* Expand to full viewport minus topbar, ignoring parent padding */
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
top: var(--topbar-height);
|
||||
left: var(--sidebar-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-base);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.viewer-bar {
|
||||
height: 44px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.viewer-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewer-url {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-elevated);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.viewer-actions { display: flex; gap: 4px; }
|
||||
|
||||
.page-iframe {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.iframe-loading, .iframe-error {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.error-icon { color: #f59e0b; }
|
||||
.error-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
|
||||
.error-sub { font-size: 13px; color: var(--text-muted); margin-bottom: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,587 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">上游管理</h2>
|
||||
<p class="page-desc">管理 API 上游服务,支持多种认证方式和定时监听</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-icon><Plus /></el-icon> 新增上游
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card">
|
||||
<el-table :data="list" v-loading="tableLoading" row-key="id" style="width:100%">
|
||||
<el-table-column label="名称" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-name">{{ row.name }}</div>
|
||||
<div class="cell-url">{{ row.base_url }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-badge', row.last_status]">
|
||||
<span class="dot"></span>
|
||||
{{ statusLabel(row.last_status) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" @change="toggleEnabled(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="检测间隔" width="100">
|
||||
<template #default="{ row }">{{ row.check_interval_seconds }}s</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近检测" min-width="145">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.last_checked_at" class="time-text">{{ fmtTime(row.last_checked_at) }}</span>
|
||||
<span v-else class="muted">未检测</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近错误" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip v-if="row.last_error" :content="row.last_error" placement="top" :show-after="300">
|
||||
<span class="error-text">{{ row.last_error.substring(0, 40) }}…</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="250">
|
||||
<template #default="{ row }">
|
||||
<div class="action-row">
|
||||
<el-button size="small" text @click="openEdit(row)" title="编辑"><el-icon><Edit /></el-icon></el-button>
|
||||
<el-button size="small" text type="success" @click="testUpstream(row)" :loading="row._testing">测试</el-button>
|
||||
<el-button size="small" text type="primary" @click="checkNow(row)" :loading="row._checking">检测</el-button>
|
||||
<el-button size="small" text type="info" @click="openDetail(row)">
|
||||
<el-icon style="margin-right:2px"><List /></el-icon>详情
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="confirmDelete(row)" title="删除"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- ======= Create / Edit Drawer ======= -->
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
:title="editingId ? '编辑上游' : '新增上游'"
|
||||
size="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="例:ai98pro" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Base URL" prop="base_url">
|
||||
<el-input v-model="form.base_url" placeholder="https://example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API Prefix">
|
||||
<el-input v-model="form.api_prefix" placeholder="/api/v1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="认证方式">
|
||||
<el-select v-model="form.auth_type" style="width:100%">
|
||||
<el-option label="无认证" value="none" />
|
||||
<el-option label="Bearer Token" value="bearer" />
|
||||
<el-option label="API Key" value="api_key" />
|
||||
<el-option label="邮箱密码登录" value="login_password" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template v-if="form.auth_type === 'bearer'">
|
||||
<el-form-item label="Bearer Token">
|
||||
<el-input v-model="form.auth_config.token" type="password" show-password placeholder="***" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-else-if="form.auth_type === 'api_key'">
|
||||
<el-form-item label="API Key">
|
||||
<el-input v-model="form.auth_config.key" type="password" show-password placeholder="***" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Header 名称">
|
||||
<el-input v-model="form.auth_config.header" placeholder="Authorization" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-else-if="form.auth_type === 'login_password'">
|
||||
<el-form-item label="登录邮箱">
|
||||
<el-input v-model="form.auth_config.email" placeholder="admin@example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="登录密码">
|
||||
<el-input v-model="form.auth_config.password" type="password" show-password placeholder="***" />
|
||||
</el-form-item>
|
||||
<el-form-item label="登录接口路径">
|
||||
<el-input v-model="form.auth_config.login_path" placeholder="/auth/login" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="分组接口">
|
||||
<el-input v-model="form.groups_endpoint" placeholder="/groups/available" />
|
||||
</el-form-item>
|
||||
<el-form-item label="倍率接口">
|
||||
<el-input v-model="form.rate_endpoint" placeholder="/groups/rates" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="检测间隔(秒)">
|
||||
<el-input-number v-model="form.check_interval_seconds" :min="60" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="超时(秒)">
|
||||
<el-input-number v-model="form.timeout_seconds" :min="5" :max="120" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<!-- ======= Detail Drawer ======= -->
|
||||
<el-drawer
|
||||
v-model="detailVisible"
|
||||
:title="`检测详情 — ${detailUpstream?.name || ''}`"
|
||||
size="700px"
|
||||
destroy-on-close
|
||||
@open="loadSnapshots"
|
||||
>
|
||||
<!-- Info cards -->
|
||||
<div v-if="detailUpstream" class="info-cards">
|
||||
<div class="info-card">
|
||||
<div class="info-label">状态</div>
|
||||
<span :class="['status-badge', detailUpstream.last_status]">
|
||||
<span class="dot"></span>{{ statusLabel(detailUpstream.last_status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">最近检测</div>
|
||||
<div class="info-value">{{ detailUpstream.last_checked_at ? fmtTimeFull(detailUpstream.last_checked_at) : '未检测' }}</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">检测间隔</div>
|
||||
<div class="info-value">{{ detailUpstream.check_interval_seconds }}s</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">超时</div>
|
||||
<div class="info-value">{{ detailUpstream.timeout_seconds }}s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detailUpstream?.last_error" class="error-box">
|
||||
<el-icon><Warning /></el-icon> {{ detailUpstream.last_error }}
|
||||
</div>
|
||||
|
||||
<!-- Snapshot history -->
|
||||
<div class="section-title">
|
||||
<el-icon><Clock /></el-icon> 检测历史
|
||||
<span class="section-sub">最近 {{ snapshots.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<div v-loading="snapshotLoading" class="snapshot-list">
|
||||
<div
|
||||
v-for="snap in snapshots"
|
||||
:key="snap.id"
|
||||
class="snap-item"
|
||||
:class="{ expanded: expandedId === snap.id }"
|
||||
>
|
||||
<!-- Row header -->
|
||||
<div class="snap-header" @click="toggleExpand(snap)">
|
||||
<div class="snap-left">
|
||||
<el-icon class="expand-icon"><ArrowRight /></el-icon>
|
||||
<span class="snap-time">{{ fmtTimeFull(snap.captured_at) }}</span>
|
||||
</div>
|
||||
<div class="snap-right">
|
||||
<el-tag size="small" type="info">{{ snap.snapshot._groups_count }} 个分组</el-tag>
|
||||
<template v-if="snap.snapshot._changes_count !== null && snap.snapshot._changes_count !== undefined">
|
||||
<el-tag size="small" :type="snap.snapshot._changes_count > 0 ? 'warning' : 'success'">
|
||||
{{ snap.snapshot._changes_count > 0 ? `${snap.snapshot._changes_count} 处变化` : '无变化' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<el-tag v-else size="small" type="primary">初始快照</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded rate table -->
|
||||
<div v-if="expandedId === snap.id" class="snap-body">
|
||||
<el-table
|
||||
:data="groupRows(snap.snapshot)"
|
||||
size="small"
|
||||
:header-cell-style="{ background: 'var(--bg-elevated)', color: 'var(--text-secondary)' }"
|
||||
:cell-style="{ background: 'var(--bg-card)', color: 'var(--text-primary)' }"
|
||||
>
|
||||
<el-table-column prop="group_name" label="分组名称" min-width="140" />
|
||||
<el-table-column prop="platform" label="平台" width="100" />
|
||||
<el-table-column label="当前倍率" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="rate-value">{{ row.rate || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="default_rate" label="默认倍率" width="100" />
|
||||
<el-table-column prop="override_rate" label="覆盖倍率" width="100">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.override_rate" class="override-value">{{ row.override_rate }}</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!snapshotLoading && snapshots.length === 0" class="empty-hint">
|
||||
暂无检测历史,请先触发「立即检测」
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="snapshots.length > 0 || snapshotOffset > 0" class="snap-pagination">
|
||||
<el-button size="small" :disabled="snapshotOffset === 0" @click="prevSnapPage">上一页</el-button>
|
||||
<span class="page-info">第 {{ snapshotOffset / snapshotLimit + 1 }} 页</span>
|
||||
<el-button size="small" :disabled="snapshots.length < snapshotLimit" @click="nextSnapPage">下一页</el-button>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import { upstreamsApi, type UpstreamData } from '@/api'
|
||||
|
||||
// ---- list state ----
|
||||
const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([])
|
||||
const tableLoading = ref(false)
|
||||
|
||||
// ---- create/edit drawer ----
|
||||
const drawerVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '',
|
||||
base_url: '',
|
||||
api_prefix: '/api/v1',
|
||||
auth_type: 'login_password',
|
||||
auth_config: { email: '', password: '', login_path: '/auth/login' } as Record<string, any>,
|
||||
rate_endpoint: '/groups/rates',
|
||||
groups_endpoint: '/groups/available',
|
||||
enabled: true,
|
||||
check_interval_seconds: 600,
|
||||
timeout_seconds: 30,
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
base_url: [{ required: true, message: '请输入 Base URL', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// ---- detail drawer ----
|
||||
const detailVisible = ref(false)
|
||||
const detailUpstream = ref<UpstreamData | null>(null)
|
||||
const snapshots = ref<any[]>([])
|
||||
const snapshotLoading = ref(false)
|
||||
const expandedId = ref<number | null>(null)
|
||||
const snapshotOffset = ref(0)
|
||||
const snapshotLimit = 20
|
||||
|
||||
// ---- helpers ----
|
||||
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
|
||||
// Treat server timestamps as UTC (add Z if no timezone info present)
|
||||
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : t + 'Z'
|
||||
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm:ss')
|
||||
const fmtTimeFull = (t: string) => dayjs(toUTC(t)).format('YYYY-MM-DD HH:mm:ss')
|
||||
|
||||
|
||||
function groupRows(snapshot: any) {
|
||||
if (!snapshot?.groups) return []
|
||||
return Object.values(snapshot.groups) as any[]
|
||||
}
|
||||
|
||||
// ---- load list ----
|
||||
async function loadList() {
|
||||
tableLoading.value = true
|
||||
try {
|
||||
const res = await upstreamsApi.list()
|
||||
list.value = res.data
|
||||
} finally {
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- create / edit ----
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
form.value = defaultForm()
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: UpstreamData) {
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
name: row.name,
|
||||
base_url: row.base_url,
|
||||
api_prefix: row.api_prefix,
|
||||
auth_type: row.auth_type,
|
||||
auth_config: { ...(row.auth_config_masked as Record<string, any>) },
|
||||
rate_endpoint: row.rate_endpoint,
|
||||
groups_endpoint: row.groups_endpoint,
|
||||
enabled: row.enabled,
|
||||
check_interval_seconds: row.check_interval_seconds,
|
||||
timeout_seconds: row.timeout_seconds,
|
||||
}
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await upstreamsApi.update(editingId.value, form.value)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
await upstreamsApi.create(form.value as any)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
drawerVisible.value = false
|
||||
loadList()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- toggle enabled ----
|
||||
async function toggleEnabled(row: UpstreamData) {
|
||||
try {
|
||||
await upstreamsApi.update(row.id, { enabled: row.enabled })
|
||||
ElMessage.success(row.enabled ? '已启用' : '已停用')
|
||||
} catch {
|
||||
row.enabled = !row.enabled
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ---- test / check-now ----
|
||||
async function testUpstream(row: any) {
|
||||
row._testing = true
|
||||
try {
|
||||
const res = await upstreamsApi.test(row.id)
|
||||
if (res.data.success) ElMessage.success(res.data.message)
|
||||
else ElMessage.error(res.data.detail || res.data.message)
|
||||
} finally {
|
||||
row._testing = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkNow(row: any) {
|
||||
row._checking = true
|
||||
try {
|
||||
const res = await upstreamsApi.checkNow(row.id)
|
||||
ElMessage[res.data.success ? 'success' : 'error'](res.data.message)
|
||||
loadList()
|
||||
} finally {
|
||||
row._checking = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- detail drawer ----
|
||||
function openDetail(row: UpstreamData) {
|
||||
detailUpstream.value = row
|
||||
snapshots.value = []
|
||||
snapshotOffset.value = 0
|
||||
expandedId.value = null
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
async function loadSnapshots() {
|
||||
if (!detailUpstream.value) return
|
||||
snapshotLoading.value = true
|
||||
try {
|
||||
const res = await upstreamsApi.listSnapshots(detailUpstream.value.id, snapshotLimit, snapshotOffset.value)
|
||||
snapshots.value = res.data
|
||||
// auto-expand the first (latest) snapshot
|
||||
if (res.data.length > 0 && expandedId.value === null) {
|
||||
expandedId.value = res.data[0].id
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加载历史失败')
|
||||
} finally {
|
||||
snapshotLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand(snap: any) {
|
||||
expandedId.value = expandedId.value === snap.id ? null : snap.id
|
||||
}
|
||||
|
||||
function prevSnapPage() {
|
||||
snapshotOffset.value = Math.max(0, snapshotOffset.value - snapshotLimit)
|
||||
expandedId.value = null
|
||||
loadSnapshots()
|
||||
}
|
||||
|
||||
function nextSnapPage() {
|
||||
snapshotOffset.value += snapshotLimit
|
||||
expandedId.value = null
|
||||
loadSnapshots()
|
||||
}
|
||||
|
||||
// ---- delete ----
|
||||
async function confirmDelete(row: UpstreamData) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除上游 "${row.name}" ?`, '删除确认', { type: 'warning' })
|
||||
await upstreamsApi.delete(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
|
||||
.page-desc { font-size: 13px; color: var(--text-muted); }
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cell-name { font-weight: 500; font-size: 14px; }
|
||||
.cell-url { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
.muted { color: var(--text-muted); font-size: 12px; }
|
||||
.time-text { font-size: 12px; color: var(--text-secondary); }
|
||||
.error-text { font-size: 12px; color: var(--color-danger); cursor: pointer; }
|
||||
.status-badge .dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; background: currentColor; display: inline-block;
|
||||
}
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Detail drawer */
|
||||
.info-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.info-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.info-label { font-size: 11px; color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.info-value { font-size: 14px; font-weight: 500; color: var(--text-primary); }
|
||||
.error-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
background: rgba(239,68,68,0.08);
|
||||
border: 1px solid rgba(239,68,68,0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-sub { font-size: 12px; color: var(--text-muted); font-weight: 400; margin-left: 4px; }
|
||||
|
||||
/* Snapshot list */
|
||||
.snapshot-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 60px;
|
||||
}
|
||||
.snap-item {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.snap-item.expanded { border-color: var(--color-primary); }
|
||||
|
||||
.snap-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-elevated);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.snap-header:hover { background: var(--bg-surface); }
|
||||
|
||||
.snap-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.expand-icon {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.snap-item.expanded .expand-icon { transform: rotate(90deg); }
|
||||
.snap-time { font-size: 13px; color: var(--text-primary); font-weight: 500; font-family: monospace; }
|
||||
|
||||
.snap-right { display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
.snap-body {
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.rate-value { font-weight: 600; color: var(--color-primary); font-family: monospace; }
|
||||
.override-value { color: var(--color-warning); font-family: monospace; }
|
||||
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.snap-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 12px;
|
||||
}
|
||||
.page-info { font-size: 13px; color: var(--text-secondary); }
|
||||
</style>
|
||||
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">Webhook 通知</h2>
|
||||
<p class="page-desc">配置 Webhook 接收器,支持通用 JSON 和钉钉机器人</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-icon><Plus /></el-icon> 新增 Webhook
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<el-table :data="list" v-loading="tableLoading" style="width:100%">
|
||||
<el-table-column label="名称" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-name">{{ row.name }}</div>
|
||||
<div class="cell-type">{{ row.type === 'dingtalk' ? '钉钉机器人' : '通用 JSON' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" @change="toggleEnabled(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订阅事件" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-for="ev in row.events"
|
||||
:key="ev"
|
||||
size="small"
|
||||
style="margin:2px"
|
||||
:type="eventTagType(ev)"
|
||||
>{{ eventLabel(ev) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text @click="openEdit(row)"><el-icon><Edit /></el-icon></el-button>
|
||||
<el-button size="small" text type="success" @click="testWebhook(row)" :loading="row._testing">
|
||||
测试
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="confirmDelete(row)"><el-icon><Delete /></el-icon></el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- Drawer -->
|
||||
<el-drawer v-model="drawerVisible" :title="editingId ? '编辑 Webhook' : '新增 Webhook'" size="460px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="例:钉钉告警机器人" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="form.type" style="width:100%">
|
||||
<el-option label="通用 JSON Webhook" value="generic" />
|
||||
<el-option label="钉钉机器人" value="dingtalk" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Webhook URL" prop="url">
|
||||
<el-input v-model="form.url" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
<el-form-item :label="form.type === 'dingtalk' ? '加签 Secret' : 'Secret(可选)'">
|
||||
<el-input v-model="form.secret" type="password" show-password :placeholder="editingId ? '留空保持不变' : '可留空'" />
|
||||
</el-form-item>
|
||||
<el-form-item label="订阅事件">
|
||||
<el-checkbox-group v-model="form.events">
|
||||
<el-checkbox label="upstream_rate_changed">倍率变更</el-checkbox>
|
||||
<el-checkbox label="upstream_unhealthy">服务异常</el-checkbox>
|
||||
<el-checkbox label="upstream_recovered">服务恢复</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import { webhooksApi, type WebhookData } from '@/api'
|
||||
|
||||
const list = ref<(WebhookData & { _testing?: boolean })[]>([])
|
||||
const tableLoading = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '',
|
||||
type: 'generic',
|
||||
url: '',
|
||||
secret: '',
|
||||
enabled: true,
|
||||
events: ['upstream_rate_changed'] as string[],
|
||||
})
|
||||
const form = ref(defaultForm())
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
url: [{ required: true, message: '请输入 URL', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
upstream_rate_changed: '倍率变更',
|
||||
upstream_unhealthy: '服务异常',
|
||||
upstream_recovered: '服务恢复',
|
||||
}
|
||||
const eventLabel = (e: string) => EVENT_LABELS[e] || e
|
||||
const eventTagType = (e: string) =>
|
||||
({ upstream_rate_changed: 'primary', upstream_unhealthy: 'danger', upstream_recovered: 'success' }[e] || '')
|
||||
|
||||
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : t + 'Z'
|
||||
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm')
|
||||
|
||||
|
||||
async function loadList() {
|
||||
tableLoading.value = true
|
||||
try {
|
||||
const res = await webhooksApi.list()
|
||||
list.value = res.data
|
||||
} finally {
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
form.value = defaultForm()
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: WebhookData) {
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
url: row.url,
|
||||
secret: '',
|
||||
enabled: row.enabled,
|
||||
events: [...row.events],
|
||||
}
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: any = { ...form.value }
|
||||
if (editingId.value) {
|
||||
if (!payload.secret) delete payload.secret
|
||||
await webhooksApi.update(editingId.value, payload)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
await webhooksApi.create(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
drawerVisible.value = false
|
||||
loadList()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(row: WebhookData) {
|
||||
try {
|
||||
await webhooksApi.update(row.id, { enabled: row.enabled })
|
||||
ElMessage.success(row.enabled ? '已启用' : '已停用')
|
||||
} catch {
|
||||
row.enabled = !row.enabled
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function testWebhook(row: any) {
|
||||
row._testing = true
|
||||
try {
|
||||
const res = await webhooksApi.test(row.id)
|
||||
ElMessage[res.data.success ? 'success' : 'error'](res.data.message)
|
||||
} finally {
|
||||
row._testing = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(row: WebhookData) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除 "${row.name}" ?`, '删除确认', { type: 'warning' })
|
||||
await webhooksApi.delete(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
|
||||
.page-desc { font-size: 13px; color: var(--text-muted); }
|
||||
.card { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; }
|
||||
.cell-name { font-weight: 500; font-size: 14px; }
|
||||
.cell-type { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
.time-text { font-size: 12px; color: var(--text-secondary); }
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: { '@': resolve(__dirname, 'src') },
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:8000', changeOrigin: true },
|
||||
'/healthz': { target: 'http://localhost:8000', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user