Initial commit

This commit is contained in:
liumangmang
2026-05-12 17:51:53 +08:00
commit b564ca4797
55 changed files with 6407 additions and 0 deletions
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+112
View File
@@ -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
```
+1
View File
@@ -0,0 +1 @@
# app package
+20
View File
@@ -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()
+30
View File
@@ -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)
+93
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
# models package
+16
View File
@@ -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)
)
+25
View File
@@ -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),
)
+21
View File
@@ -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)
+13
View File
@@ -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)
+32
View File
@@ -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)
)
+22
View File
@@ -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)
)
+1
View File
@@ -0,0 +1 @@
# routers package
+28
View File
@@ -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"}
+178
View File
@@ -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,
)
+46
View File
@@ -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]
+282
View File
@@ -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
+110
View File
@@ -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}
+1
View File
@@ -0,0 +1 @@
# schemas package
+15
View File
@@ -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
+16
View File
@@ -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}
+80
View File
@@ -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
+37
View File
@@ -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}
+1
View File
@@ -0,0 +1 @@
# services package
+156
View File
@@ -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)
+39
View File
@@ -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
+217
View File
@@ -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)
+158
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
# utils package
+74
View File
@@ -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
+59
View File
@@ -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),
},
}
+10
View File
@@ -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
+24
View File
@@ -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
+16
View File
@@ -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>
+2049
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+18
View File
@@ -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>
+150
View File
@@ -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}`),
}
+66
View File
@@ -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; }
+287
View File
@@ -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>
+21
View File
@@ -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')
+40
View File
@@ -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
+31
View File
@@ -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 }
})
+325
View File
@@ -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>
+140
View File
@@ -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>
+176
View File
@@ -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>
+208
View File
@@ -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>
+587
View File
@@ -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>
+224
View File
@@ -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>
+23
View File
@@ -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" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+20
View File
@@ -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,
},
})