Compare commits
4 Commits
d5344e244e
...
e715ca7350
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e715ca7350 | ||
|
|
7f581e3fbb | ||
|
|
53bc48fee6 | ||
|
|
1a5f32dc4e |
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# backend package
|
||||
BIN
backend/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
backend/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/config.cpython-310.pyc
Normal file
BIN
backend/__pycache__/config.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/crud.cpython-310.pyc
Normal file
BIN
backend/__pycache__/crud.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/database.cpython-310.pyc
Normal file
BIN
backend/__pycache__/database.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/main.cpython-310.pyc
Normal file
BIN
backend/__pycache__/main.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/models.cpython-310.pyc
Normal file
BIN
backend/__pycache__/models.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/notify_service.cpython-310.pyc
Normal file
BIN
backend/__pycache__/notify_service.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/schemas.cpython-310.pyc
Normal file
BIN
backend/__pycache__/schemas.cpython-310.pyc
Normal file
Binary file not shown.
3
backend/config.py
Normal file
3
backend/config.py
Normal file
@ -0,0 +1,3 @@
|
||||
import os
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./notify_center.db")
|
||||
130
backend/crud.py
Normal file
130
backend/crud.py
Normal file
@ -0,0 +1,130 @@
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from backend.models import Channel, Notification
|
||||
|
||||
async def get_channel(db: AsyncSession, channel_id: int) -> Optional[Channel]:
|
||||
result = await db.execute(select(Channel).where(Channel.id == channel_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_channel_by_name(db: AsyncSession, name: str) -> Optional[Channel]:
|
||||
result = await db.execute(select(Channel).where(Channel.name == name))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_channels(db: AsyncSession, skip: int = 0, limit: int = 100) -> List[Channel]:
|
||||
result = await db.execute(
|
||||
select(Channel).offset(skip).limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_channels_by_tags(db: AsyncSession, tags: List[str]) -> List[Channel]:
|
||||
"""获取包含任一标签的所有活动通道"""
|
||||
# SQLite JSON 查询:使用 json_extract 或 like 匹配
|
||||
all_channels = await get_channels(db, limit=1000)
|
||||
result = []
|
||||
for channel in all_channels:
|
||||
if not channel.is_active:
|
||||
continue
|
||||
channel_tags = channel.tags or []
|
||||
if any(tag in channel_tags for tag in tags):
|
||||
result.append(channel)
|
||||
return result
|
||||
|
||||
async def create_channel(db: AsyncSession, channel_data: Dict[str, Any]) -> Channel:
|
||||
db_channel = Channel(**channel_data)
|
||||
db.add(db_channel)
|
||||
await db.commit()
|
||||
await db.refresh(db_channel)
|
||||
return db_channel
|
||||
|
||||
async def update_channel(
|
||||
db: AsyncSession,
|
||||
channel_id: int,
|
||||
channel_data: Dict[str, Any]
|
||||
) -> Optional[Channel]:
|
||||
db_channel = await get_channel(db, channel_id)
|
||||
if not db_channel:
|
||||
return None
|
||||
|
||||
for key, value in channel_data.items():
|
||||
if value is not None:
|
||||
setattr(db_channel, key, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_channel)
|
||||
return db_channel
|
||||
|
||||
async def delete_channel(db: AsyncSession, channel_id: int) -> bool:
|
||||
db_channel = await get_channel(db, channel_id)
|
||||
if not db_channel:
|
||||
return False
|
||||
|
||||
await db.delete(db_channel)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
# Notification CRUD
|
||||
async def create_notification(
|
||||
db: AsyncSession,
|
||||
channel_id: int,
|
||||
notification_data: Dict[str, Any]
|
||||
) -> Notification:
|
||||
db_notification = Notification(channel_id=channel_id, **notification_data)
|
||||
db.add(db_notification)
|
||||
await db.commit()
|
||||
await db.refresh(db_notification)
|
||||
return db_notification
|
||||
|
||||
async def update_notification_status(
|
||||
db: AsyncSession,
|
||||
notification_id: int,
|
||||
status: str,
|
||||
error_msg: Optional[str] = None,
|
||||
sent_at = None
|
||||
) -> Optional[Notification]:
|
||||
result = await db.execute(
|
||||
select(Notification).where(Notification.id == notification_id)
|
||||
)
|
||||
notification = result.scalar_one_or_none()
|
||||
if notification:
|
||||
notification.status = status
|
||||
notification.error_msg = error_msg
|
||||
notification.sent_at = sent_at
|
||||
await db.commit()
|
||||
await db.refresh(notification)
|
||||
return notification
|
||||
|
||||
async def get_notifications(
|
||||
db: AsyncSession,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
channel_id: Optional[int] = None,
|
||||
status: Optional[str] = None
|
||||
) -> List[Notification]:
|
||||
query = select(Notification)
|
||||
|
||||
if channel_id:
|
||||
query = query.where(Notification.channel_id == channel_id)
|
||||
if status:
|
||||
query = query.where(Notification.status == status)
|
||||
|
||||
query = query.order_by(Notification.created_at.desc())
|
||||
query = query.offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_notification_stats(db: AsyncSession) -> Dict[str, int]:
|
||||
from sqlalchemy import func
|
||||
|
||||
result = await db.execute(
|
||||
select(Notification.status, func.count(Notification.id))
|
||||
.group_by(Notification.status)
|
||||
)
|
||||
stats = {status: count for status, count in result.fetchall()}
|
||||
|
||||
total = await db.execute(select(func.count(Notification.id)))
|
||||
stats['total'] = total.scalar()
|
||||
|
||||
return stats
|
||||
22
backend/database.py
Normal file
22
backend/database.py
Normal file
@ -0,0 +1,22 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from sqlalchemy import text
|
||||
from backend.config import DATABASE_URL
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
Base = declarative_base()
|
||||
|
||||
AsyncSessionLocal = sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
49
backend/main.py
Normal file
49
backend/main.py
Normal file
@ -0,0 +1,49 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import os
|
||||
|
||||
from backend.database import init_db
|
||||
from backend.routers import channels, notify, history
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
await init_db()
|
||||
yield
|
||||
# Shutdown
|
||||
|
||||
app = FastAPI(
|
||||
title="Apprise Notify Center",
|
||||
description="多通道通知中心 - 支持 80+ 种通知服务",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 注册路由
|
||||
app.include_router(channels.router)
|
||||
app.include_router(notify.router)
|
||||
app.include_router(history.router)
|
||||
|
||||
# 静态文件(前端)
|
||||
frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend")
|
||||
if os.path.exists(frontend_path):
|
||||
app.mount("/", StaticFiles(directory=frontend_path, html=True), name="frontend")
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
return {"status": "ok"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("backend.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
34
backend/models.py
Normal file
34
backend/models.py
Normal file
@ -0,0 +1,34 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from backend.database import Base
|
||||
|
||||
class Channel(Base):
|
||||
__tablename__ = "channels"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, nullable=False, index=True)
|
||||
type = Column(String, nullable=False)
|
||||
config = Column(JSON, default=dict)
|
||||
tags = Column(JSON, default=list)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
notifications = relationship("Notification", back_populates="channel")
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
channel_id = Column(Integer, ForeignKey("channels.id"))
|
||||
title = Column(String, nullable=True)
|
||||
body = Column(Text, nullable=False)
|
||||
priority = Column(String, default="normal")
|
||||
status = Column(String, default="pending")
|
||||
error_msg = Column(Text, nullable=True)
|
||||
sent_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
channel = relationship("Channel", back_populates="notifications")
|
||||
189
backend/notify_service.py
Normal file
189
backend/notify_service.py
Normal file
@ -0,0 +1,189 @@
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import apprise
|
||||
from backend import crud
|
||||
from backend.models import Channel
|
||||
|
||||
class NotifyService:
|
||||
def __init__(self):
|
||||
self.apobj = apprise.Apprise()
|
||||
|
||||
def _build_apprise_url(self, channel: Channel) -> Optional[str]:
|
||||
"""根据通道类型构建 apprise URL"""
|
||||
config = channel.config or {}
|
||||
|
||||
if channel.type == "discord":
|
||||
webhook_url = config.get("webhook_url", "")
|
||||
if webhook_url:
|
||||
return f"{webhook_url}"
|
||||
|
||||
elif channel.type == "telegram":
|
||||
bot_token = config.get("bot_token", "")
|
||||
chat_id = config.get("chat_id", "")
|
||||
if bot_token and chat_id:
|
||||
return f"tgram://{bot_token}/{chat_id}"
|
||||
|
||||
elif channel.type == "email":
|
||||
smtp_host = config.get("smtp_host", "")
|
||||
smtp_port = config.get("smtp_port", 587)
|
||||
username = config.get("username", "")
|
||||
password = config.get("password", "")
|
||||
to_email = config.get("to_email", "")
|
||||
|
||||
if username and password and to_email:
|
||||
return f"mailtos://{username}:{password}@{smtp_host}:{smtp_port}?to={to_email}"
|
||||
|
||||
elif channel.type == "slack":
|
||||
webhook_url = config.get("webhook_url", "")
|
||||
if webhook_url:
|
||||
return f"{webhook_url}"
|
||||
|
||||
elif channel.type == "webhook":
|
||||
url = config.get("url", "")
|
||||
if url:
|
||||
return f"json://{url.replace('https://', '').replace('http://', '')}"
|
||||
|
||||
# 支持 apprise 原生 URL 格式
|
||||
elif channel.type == "apprise":
|
||||
return config.get("url", "")
|
||||
|
||||
return None
|
||||
|
||||
async def send_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
channel_id: int,
|
||||
title: Optional[str],
|
||||
body: str,
|
||||
priority: str = "normal"
|
||||
) -> Dict[str, Any]:
|
||||
"""发送单条通知到指定通道"""
|
||||
channel = await crud.get_channel(db, channel_id)
|
||||
if not channel:
|
||||
return {
|
||||
"channel_id": channel_id,
|
||||
"status": "failed",
|
||||
"error_msg": "Channel not found"
|
||||
}
|
||||
|
||||
if not channel.is_active:
|
||||
return {
|
||||
"channel": channel.name,
|
||||
"channel_id": channel_id,
|
||||
"status": "skipped",
|
||||
"error_msg": "Channel is inactive"
|
||||
}
|
||||
|
||||
# 创建通知记录
|
||||
notification = await crud.create_notification(
|
||||
db, channel_id, {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"priority": priority,
|
||||
"status": "pending"
|
||||
}
|
||||
)
|
||||
|
||||
# 构建 apprise URL
|
||||
apprise_url = self._build_apprise_url(channel)
|
||||
if not apprise_url:
|
||||
error_msg = f"Invalid configuration for channel type: {channel.type}"
|
||||
await crud.update_notification_status(
|
||||
db, notification.id, "failed", error_msg
|
||||
)
|
||||
return {
|
||||
"channel": channel.name,
|
||||
"channel_id": channel_id,
|
||||
"status": "failed",
|
||||
"notification_id": notification.id,
|
||||
"error_msg": error_msg
|
||||
}
|
||||
|
||||
try:
|
||||
# 发送通知
|
||||
apobj = apprise.Apprise()
|
||||
apobj.add(apprise_url)
|
||||
|
||||
# 构建消息
|
||||
message = body
|
||||
if title:
|
||||
message = f"**{title}**\n\n{body}"
|
||||
|
||||
# 发送
|
||||
result = apobj.notify(body=message)
|
||||
|
||||
if result:
|
||||
await crud.update_notification_status(
|
||||
db, notification.id, "sent", sent_at=datetime.utcnow()
|
||||
)
|
||||
return {
|
||||
"channel": channel.name,
|
||||
"channel_id": channel_id,
|
||||
"status": "sent",
|
||||
"notification_id": notification.id
|
||||
}
|
||||
else:
|
||||
error_msg = "Failed to send notification"
|
||||
await crud.update_notification_status(
|
||||
db, notification.id, "failed", error_msg
|
||||
)
|
||||
return {
|
||||
"channel": channel.name,
|
||||
"channel_id": channel_id,
|
||||
"status": "failed",
|
||||
"notification_id": notification.id,
|
||||
"error_msg": error_msg
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
await crud.update_notification_status(
|
||||
db, notification.id, "failed", error_msg
|
||||
)
|
||||
return {
|
||||
"channel": channel.name,
|
||||
"channel_id": channel_id,
|
||||
"status": "failed",
|
||||
"notification_id": notification.id,
|
||||
"error_msg": error_msg
|
||||
}
|
||||
|
||||
async def send_to_channels(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
channels: Optional[List[str]],
|
||||
tags: Optional[List[str]],
|
||||
title: Optional[str],
|
||||
body: str,
|
||||
priority: str = "normal"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""批量发送通知到多个通道或按标签发送"""
|
||||
results = []
|
||||
target_channels = []
|
||||
|
||||
# 按名称获取通道
|
||||
if channels:
|
||||
for channel_name in channels:
|
||||
channel = await crud.get_channel_by_name(db, channel_name)
|
||||
if channel:
|
||||
target_channels.append(channel)
|
||||
|
||||
# 按标签获取通道
|
||||
if tags:
|
||||
tagged_channels = await crud.get_channels_by_tags(db, tags)
|
||||
# 合并去重
|
||||
existing_ids = {c.id for c in target_channels}
|
||||
for channel in tagged_channels:
|
||||
if channel.id not in existing_ids:
|
||||
target_channels.append(channel)
|
||||
existing_ids.add(channel.id)
|
||||
|
||||
# 发送通知
|
||||
for channel in target_channels:
|
||||
result = await self.send_notification(
|
||||
db, channel.id, title, body, priority
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
1
backend/routers/__init__.py
Normal file
1
backend/routers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# routers package
|
||||
BIN
backend/routers/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
backend/routers/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/channels.cpython-310.pyc
Normal file
BIN
backend/routers/__pycache__/channels.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/history.cpython-310.pyc
Normal file
BIN
backend/routers/__pycache__/history.cpython-310.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/notify.cpython-310.pyc
Normal file
BIN
backend/routers/__pycache__/notify.cpython-310.pyc
Normal file
Binary file not shown.
82
backend/routers/channels.py
Normal file
82
backend/routers/channels.py
Normal file
@ -0,0 +1,82 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from backend.database import get_db
|
||||
from backend import crud
|
||||
from backend.schemas import Channel, ChannelCreate, ChannelUpdate, ChannelList
|
||||
|
||||
router = APIRouter(prefix="/api/channels", tags=["channels"])
|
||||
|
||||
@router.get("", response_model=ChannelList)
|
||||
async def list_channels(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取所有通道列表"""
|
||||
channels = await crud.get_channels(db, skip=skip, limit=limit)
|
||||
total = len(channels)
|
||||
return {"channels": channels, "total": total}
|
||||
|
||||
@router.get("/{channel_id}", response_model=Channel)
|
||||
async def get_channel(channel_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""获取单个通道详情"""
|
||||
channel = await crud.get_channel(db, channel_id)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
return channel
|
||||
|
||||
@router.post("", response_model=Channel, status_code=201)
|
||||
async def create_channel(
|
||||
channel: ChannelCreate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""创建新通道"""
|
||||
existing = await crud.get_channel_by_name(db, channel.name)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Channel name already exists")
|
||||
|
||||
return await crud.create_channel(db, channel.model_dump())
|
||||
|
||||
@router.put("/{channel_id}", response_model=Channel)
|
||||
async def update_channel(
|
||||
channel_id: int,
|
||||
channel: ChannelUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新通道"""
|
||||
if channel.name:
|
||||
existing = await crud.get_channel_by_name(db, channel.name)
|
||||
if existing and existing.id != channel_id:
|
||||
raise HTTPException(status_code=400, detail="Channel name already exists")
|
||||
|
||||
updated = await crud.update_channel(db, channel_id, channel.model_dump(exclude_unset=True))
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
return updated
|
||||
|
||||
@router.delete("/{channel_id}")
|
||||
async def delete_channel(channel_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""删除通道"""
|
||||
success = await crud.delete_channel(db, channel_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
return {"message": "Channel deleted successfully"}
|
||||
|
||||
@router.post("/{channel_id}/test")
|
||||
async def test_channel(channel_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""测试通道配置"""
|
||||
from backend.notify_service import NotifyService
|
||||
|
||||
service = NotifyService()
|
||||
result = await service.send_notification(
|
||||
db, channel_id, "测试通知", "这是一条测试消息", "normal"
|
||||
)
|
||||
|
||||
if result["status"] == "sent":
|
||||
return {"success": True, "message": "Test notification sent successfully"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Test failed: {result.get('error_msg', 'Unknown error')}"
|
||||
)
|
||||
41
backend/routers/history.py
Normal file
41
backend/routers/history.py
Normal file
@ -0,0 +1,41 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from backend.database import get_db
|
||||
from backend import crud
|
||||
from backend.schemas import Notification
|
||||
|
||||
router = APIRouter(prefix="/api/notifications", tags=["history"])
|
||||
|
||||
@router.get("")
|
||||
async def list_notifications(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
channel_id: Optional[int] = None,
|
||||
status: Optional[str] = Query(None, enum=["pending", "sent", "failed"]),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取通知历史记录"""
|
||||
notifications = await crud.get_notifications(db, skip, limit, channel_id, status)
|
||||
return {
|
||||
"notifications": notifications,
|
||||
"skip": skip,
|
||||
"limit": limit
|
||||
}
|
||||
|
||||
@router.get("/{notification_id}")
|
||||
async def get_notification(notification_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""获取单条通知详情"""
|
||||
from sqlalchemy import select
|
||||
from backend.models import Notification
|
||||
|
||||
result = await db.execute(
|
||||
select(Notification).where(Notification.id == notification_id)
|
||||
)
|
||||
notification = result.scalar_one_or_none()
|
||||
|
||||
if not notification:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
|
||||
return notification
|
||||
82
backend/routers/notify.py
Normal file
82
backend/routers/notify.py
Normal file
@ -0,0 +1,82 @@
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from backend.database import get_db
|
||||
from backend.schemas import NotifyRequest, NotifyResponse
|
||||
from backend.notify_service import NotifyService
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["notify"])
|
||||
|
||||
@router.post("/notify", response_model=NotifyResponse)
|
||||
async def send_notification(
|
||||
request: NotifyRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""发送通知到指定通道或按标签发送"""
|
||||
if not request.channels and not request.tags:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either 'channels' or 'tags' must be provided"
|
||||
)
|
||||
|
||||
service = NotifyService()
|
||||
results = await service.send_to_channels(
|
||||
db,
|
||||
request.channels,
|
||||
request.tags,
|
||||
request.title,
|
||||
request.body,
|
||||
request.priority
|
||||
)
|
||||
|
||||
total = len(results)
|
||||
sent = sum(1 for r in results if r["status"] == "sent")
|
||||
failed = sum(1 for r in results if r["status"] == "failed")
|
||||
|
||||
return {
|
||||
"success": sent == total,
|
||||
"results": results,
|
||||
"total": total,
|
||||
"sent": sent,
|
||||
"failed": failed
|
||||
}
|
||||
|
||||
@router.get("/notify")
|
||||
async def send_notification_get(
|
||||
channels: Optional[str] = Query(None, description="通道名称,多个用逗号分隔"),
|
||||
tags: Optional[str] = Query(None, description="标签,多个用逗号分隔"),
|
||||
title: Optional[str] = Query(None),
|
||||
body: str = Query(..., description="消息内容"),
|
||||
priority: str = Query("normal"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""通过 GET 请求发送通知(方便脚本调用)"""
|
||||
channel_list = channels.split(",") if channels else None
|
||||
tag_list = tags.split(",") if tags else None
|
||||
|
||||
if not channel_list and not tag_list:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either 'channels' or 'tags' must be provided"
|
||||
)
|
||||
|
||||
service = NotifyService()
|
||||
results = await service.send_to_channels(
|
||||
db,
|
||||
channel_list,
|
||||
tag_list,
|
||||
title,
|
||||
body,
|
||||
priority
|
||||
)
|
||||
|
||||
total = len(results)
|
||||
sent = sum(1 for r in results if r["status"] == "sent")
|
||||
|
||||
return {
|
||||
"success": sent == total,
|
||||
"results": results,
|
||||
"total": total,
|
||||
"sent": sent,
|
||||
"failed": total - sent
|
||||
}
|
||||
72
backend/schemas.py
Normal file
72
backend/schemas.py
Normal file
@ -0,0 +1,72 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
# Channel Schemas
|
||||
class ChannelBase(BaseModel):
|
||||
name: str
|
||||
type: str
|
||||
config: Dict[str, Any] = {}
|
||||
tags: List[str] = []
|
||||
is_active: bool = True
|
||||
|
||||
class ChannelCreate(ChannelBase):
|
||||
pass
|
||||
|
||||
class ChannelUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class Channel(ChannelBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class ChannelList(BaseModel):
|
||||
channels: List[Channel]
|
||||
total: int
|
||||
|
||||
# Notification Schemas
|
||||
class NotificationBase(BaseModel):
|
||||
title: Optional[str] = None
|
||||
body: str
|
||||
priority: str = "normal"
|
||||
|
||||
class NotificationCreate(NotificationBase):
|
||||
channel_id: int
|
||||
|
||||
class Notification(NotificationBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
channel_id: int
|
||||
status: str
|
||||
error_msg: Optional[str] = None
|
||||
sent_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
class NotificationResult(BaseModel):
|
||||
channel: str
|
||||
channel_id: int
|
||||
status: str
|
||||
notification_id: int
|
||||
error_msg: Optional[str] = None
|
||||
|
||||
class NotifyRequest(BaseModel):
|
||||
channels: Optional[List[str]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
title: Optional[str] = None
|
||||
body: str
|
||||
priority: str = "normal"
|
||||
|
||||
class NotifyResponse(BaseModel):
|
||||
success: bool
|
||||
results: List[NotificationResult]
|
||||
total: int
|
||||
sent: int
|
||||
failed: int
|
||||
1
backend/tests/__init__.py
Normal file
1
backend/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# tests package
|
||||
BIN
backend/tests/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
backend/tests/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
23
backend/tests/test_database.py
Normal file
23
backend/tests/test_database.py
Normal file
@ -0,0 +1,23 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from backend.database import engine, Base, get_db, init_db
|
||||
from backend import models # 导入模型以注册表
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_connection():
|
||||
"""测试数据库连接是否正常"""
|
||||
async with engine.begin() as conn:
|
||||
result = await conn.execute(text("SELECT 1"))
|
||||
assert result.scalar() == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_created():
|
||||
"""测试表是否正确创建"""
|
||||
await init_db()
|
||||
async with engine.begin() as conn:
|
||||
result = await conn.execute(text("SELECT name FROM sqlite_master WHERE type='table'"))
|
||||
tables = [row[0] for row in result.fetchall()]
|
||||
assert "channels" in tables
|
||||
assert "notifications" in tables
|
||||
106
backend/tests/test_notify_service.py
Normal file
106
backend/tests/test_notify_service.py
Normal file
@ -0,0 +1,106 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import uuid
|
||||
from unittest.mock import Mock, patch
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from backend.database import AsyncSessionLocal, init_db
|
||||
from backend import crud
|
||||
from backend.notify_service import NotifyService
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db():
|
||||
await init_db()
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_to_discord(db):
|
||||
"""测试发送到 Discord"""
|
||||
unique_name = f"测试Discord_{uuid.uuid4().hex[:8]}"
|
||||
# 创建测试通道
|
||||
channel = await crud.create_channel(db, {
|
||||
"name": unique_name,
|
||||
"type": "discord",
|
||||
"config": {"webhook_url": "https://discord.com/api/webhooks/test"}
|
||||
})
|
||||
|
||||
service = NotifyService()
|
||||
|
||||
with patch('apprise.Apprise.notify') as mock_notify:
|
||||
mock_notify.return_value = True
|
||||
result = await service.send_notification(
|
||||
db, channel.id, "测试标题", "测试内容", "normal"
|
||||
)
|
||||
|
||||
assert result['status'] == 'sent'
|
||||
assert result['channel_id'] == channel.id
|
||||
mock_notify.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_to_multiple_channels(db):
|
||||
"""测试批量发送到多个通道"""
|
||||
# 创建多个通道
|
||||
name_a = f"通道A_{uuid.uuid4().hex[:8]}"
|
||||
name_b = f"通道B_{uuid.uuid4().hex[:8]}"
|
||||
await crud.create_channel(db, {
|
||||
"name": name_a,
|
||||
"type": "discord",
|
||||
"config": {"webhook_url": "https://discord.com/webhook"},
|
||||
"tags": ["alerts"]
|
||||
})
|
||||
await crud.create_channel(db, {
|
||||
"name": name_b,
|
||||
"type": "telegram",
|
||||
"config": {"bot_token": "123456:ABC", "chat_id": "12345"},
|
||||
"tags": ["alerts"]
|
||||
})
|
||||
|
||||
service = NotifyService()
|
||||
|
||||
with patch('apprise.Apprise.notify') as mock_notify:
|
||||
mock_notify.return_value = True
|
||||
results = await service.send_to_channels(
|
||||
db, [name_a, name_b], None, "测试", "内容", "normal"
|
||||
)
|
||||
|
||||
assert len(results) == 2
|
||||
assert all(r['status'] == 'sent' for r in results)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_by_tags(db):
|
||||
"""测试按标签发送"""
|
||||
unique_tag = f"alerts_{uuid.uuid4().hex[:8]}"
|
||||
name_prod = f"生产告警_{uuid.uuid4().hex[:8]}"
|
||||
name_dev = f"开发告警_{uuid.uuid4().hex[:8]}"
|
||||
name_info = f"普通通知_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
await crud.create_channel(db, {
|
||||
"name": name_prod,
|
||||
"type": "discord",
|
||||
"config": {"webhook_url": "https://discord.com/webhook"},
|
||||
"tags": [unique_tag, "production"]
|
||||
})
|
||||
await crud.create_channel(db, {
|
||||
"name": name_dev,
|
||||
"type": "telegram",
|
||||
"config": {"bot_token": "123456:ABC", "chat_id": "12345"},
|
||||
"tags": [unique_tag, "dev"]
|
||||
})
|
||||
await crud.create_channel(db, {
|
||||
"name": name_info,
|
||||
"type": "email",
|
||||
"config": {"username": "test@test.com", "password": "pass", "to_email": "to@test.com", "smtp_host": "smtp.test.com"},
|
||||
"tags": ["info"]
|
||||
})
|
||||
|
||||
service = NotifyService()
|
||||
|
||||
with patch('apprise.Apprise.notify') as mock_notify:
|
||||
mock_notify.return_value = True
|
||||
results = await service.send_to_channels(
|
||||
db, None, [unique_tag], "告警", "服务器异常", "high"
|
||||
)
|
||||
|
||||
assert len(results) == 2 # 只有带唯一标签的通道
|
||||
assert mock_notify.call_count == 2
|
||||
4
frontend/css/style.css
Normal file
4
frontend/css/style.css
Normal file
@ -0,0 +1,4 @@
|
||||
/* Custom styles */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
233
frontend/index.html
Normal file
233
frontend/index.html
Normal file
@ -0,0 +1,233 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Apprise Notify Center</title>
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<div id="app">
|
||||
<!-- 导航栏 -->
|
||||
<nav class="bg-blue-600 text-white shadow-lg">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-bell text-2xl mr-3"></i>
|
||||
<span class="font-bold text-xl">Notify Center</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-6">
|
||||
<a href="#" @click.prevent="currentView = 'channels'"
|
||||
:class="{'text-blue-200': currentView !== 'channels'}"
|
||||
class="hover:text-blue-200">
|
||||
<i class="fas fa-broadcast-tower mr-1"></i>通道管理
|
||||
</a>
|
||||
<a href="#" @click.prevent="currentView = 'send'"
|
||||
:class="{'text-blue-200': currentView !== 'send'}"
|
||||
class="hover:text-blue-200">
|
||||
<i class="fas fa-paper-plane mr-1"></i>手动发送
|
||||
</a>
|
||||
<a href="#" @click.prevent="currentView = 'history'"
|
||||
:class="{'text-blue-200': currentView !== 'history'}"
|
||||
class="hover:text-blue-200">
|
||||
<i class="fas fa-history mr-1"></i>历史记录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="max-w-7xl mx-auto px-4 py-8">
|
||||
<!-- 通道管理 -->
|
||||
<div v-if="currentView === 'channels'" class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold text-gray-800">通道管理</h2>
|
||||
<button @click="showChannelModal = true"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
|
||||
<i class="fas fa-plus mr-2"></i>添加通道
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">名称</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">类型</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">标签</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="channel in channels" :key="channel.id">
|
||||
<td class="px-6 py-4">{{ channel.name }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="px-2 py-1 bg-gray-100 rounded text-sm">{{ channel.type }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span v-for="tag in channel.tags" :key="tag"
|
||||
class="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs mr-1">
|
||||
{{ tag }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span :class="channel.is_active ? 'text-green-600' : 'text-gray-400'">
|
||||
{{ channel.is_active ? '活跃' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 space-x-2">
|
||||
<button @click="testChannel(channel.id)"
|
||||
class="text-blue-600 hover:text-blue-800">
|
||||
<i class="fas fa-vial"></i> 测试
|
||||
</button>
|
||||
<button @click="deleteChannel(channel.id)"
|
||||
class="text-red-600 hover:text-red-800">
|
||||
<i class="fas fa-trash"></i> 删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动发送 -->
|
||||
<div v-if="currentView === 'send'" class="max-w-2xl">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-6">手动发送通知</h2>
|
||||
<div class="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">选择通道</label>
|
||||
<select v-model="sendForm.channels" multiple
|
||||
class="w-full border rounded-lg px-3 py-2 h-32">
|
||||
<option v-for="channel in channels" :key="channel.id" :value="channel.name">
|
||||
{{ channel.name }} ({{ channel.type }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">优先级</label>
|
||||
<select v-model="sendForm.priority" class="w-full border rounded-lg px-3 py-2">
|
||||
<option value="low">低</option>
|
||||
<option value="normal">普通</option>
|
||||
<option value="high">高</option>
|
||||
<option value="urgent">紧急</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">标题(可选)</label>
|
||||
<input v-model="sendForm.title" type="text"
|
||||
class="w-full border rounded-lg px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">内容</label>
|
||||
<textarea v-model="sendForm.body" rows="4"
|
||||
class="w-full border rounded-lg px-3 py-2" required></textarea>
|
||||
</div>
|
||||
<button @click="sendNotification"
|
||||
:disabled="sending"
|
||||
class="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 disabled:bg-gray-400">
|
||||
<i v-if="!sending" class="fas fa-paper-plane mr-2"></i>
|
||||
<i v-else class="fas fa-spinner fa-spin mr-2"></i>
|
||||
{{ sending ? '发送中...' : '发送通知' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史记录 -->
|
||||
<div v-if="currentView === 'history'" class="space-y-6">
|
||||
<h2 class="text-2xl font-bold text-gray-800">发送历史</h2>
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">时间</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">通道</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">标题</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="n in notifications" :key="n.id">
|
||||
<td class="px-6 py-4 text-sm">{{ formatDate(n.created_at) }}</td>
|
||||
<td class="px-6 py-4">{{ n.channel?.name || '-' }}</td>
|
||||
<td class="px-6 py-4">{{ n.title || '-' }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span :class="getStatusClass(n.status)">
|
||||
{{ n.status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 通道编辑模态框 -->
|
||||
<div v-if="showChannelModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-screen overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-bold mb-4">添加通道</h3>
|
||||
<form @submit.prevent="saveChannel" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">名称</label>
|
||||
<input v-model="channelForm.name" type="text" required
|
||||
class="w-full border rounded-lg px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">类型</label>
|
||||
<select v-model="channelForm.type" required
|
||||
class="w-full border rounded-lg px-3 py-2">
|
||||
<option value="discord">Discord</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="apprise">Apprise URL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">配置 (JSON)</label>
|
||||
<textarea v-model="channelForm.configJson" rows="4" required
|
||||
class="w-full border rounded-lg px-3 py-2 font-mono text-sm"
|
||||
placeholder='{"webhook_url": "https://..."}'></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">标签(逗号分隔)</label>
|
||||
<input v-model="channelForm.tags" type="text"
|
||||
class="w-full border rounded-lg px-3 py-2"
|
||||
placeholder="alerts, production">
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input v-model="channelForm.is_active" type="checkbox" id="is_active"
|
||||
class="mr-2">
|
||||
<label for="is_active">启用</label>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button type="button" @click="showChannelModal = false"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<div v-if="message" :class="`fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg ${message.type === 'success' ? 'bg-green-500' : 'bg-red-500'} text-white`">
|
||||
{{ message.text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
185
frontend/js/app.js
Normal file
185
frontend/js/app.js
Normal file
@ -0,0 +1,185 @@
|
||||
const { createApp, ref, onMounted } = Vue;
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const currentView = ref('channels');
|
||||
const channels = ref([]);
|
||||
const notifications = ref([]);
|
||||
const showChannelModal = ref(false);
|
||||
const sending = ref(false);
|
||||
const message = ref(null);
|
||||
|
||||
const channelForm = ref({
|
||||
name: '',
|
||||
type: 'discord',
|
||||
configJson: '{}',
|
||||
tags: '',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
const sendForm = ref({
|
||||
channels: [],
|
||||
title: '',
|
||||
body: '',
|
||||
priority: 'normal'
|
||||
});
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
const showMessage = (text, type = 'success') => {
|
||||
message.value = { text, type };
|
||||
setTimeout(() => message.value = null, 3000);
|
||||
};
|
||||
|
||||
const fetchChannels = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/channels`);
|
||||
const data = await res.json();
|
||||
channels.value = data.channels;
|
||||
} catch (e) {
|
||||
showMessage('获取通道失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/notifications`);
|
||||
const data = await res.json();
|
||||
notifications.value = data.notifications;
|
||||
} catch (e) {
|
||||
showMessage('获取历史失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const saveChannel = async () => {
|
||||
try {
|
||||
const config = JSON.parse(channelForm.value.configJson);
|
||||
const tags = channelForm.value.tags.split(',').map(t => t.trim()).filter(t => t);
|
||||
|
||||
const res = await fetch(`${API_BASE}/channels`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: channelForm.value.name,
|
||||
type: channelForm.value.type,
|
||||
config,
|
||||
tags,
|
||||
is_active: channelForm.value.is_active
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showMessage('通道创建成功');
|
||||
showChannelModal.value = false;
|
||||
fetchChannels();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
showMessage(err.detail || '创建失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('配置 JSON 格式错误', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteChannel = async (id) => {
|
||||
if (!confirm('确定删除此通道?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/channels/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
showMessage('删除成功');
|
||||
fetchChannels();
|
||||
} else {
|
||||
showMessage('删除失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('删除失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const testChannel = async (id) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/channels/${id}/test`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
showMessage('测试消息已发送');
|
||||
} else {
|
||||
showMessage('测试失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('测试失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const sendNotification = async () => {
|
||||
if (!sendForm.value.body) {
|
||||
showMessage('请输入消息内容', 'error');
|
||||
return;
|
||||
}
|
||||
if (sendForm.value.channels.length === 0) {
|
||||
showMessage('请至少选择一个通道', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
sending.value = true;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channels: sendForm.value.channels,
|
||||
title: sendForm.value.title,
|
||||
body: sendForm.value.body,
|
||||
priority: sendForm.value.priority
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showMessage('通知发送成功');
|
||||
sendForm.value = { channels: [], title: '', body: '', priority: 'normal' };
|
||||
} else {
|
||||
showMessage('发送失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('发送失败', 'error');
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
return {
|
||||
'px-2 py-1 rounded text-xs': true,
|
||||
'bg-green-100 text-green-800': status === 'sent',
|
||||
'bg-red-100 text-red-800': status === 'failed',
|
||||
'bg-yellow-100 text-yellow-800': status === 'pending'
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchChannels();
|
||||
fetchNotifications();
|
||||
});
|
||||
|
||||
return {
|
||||
currentView,
|
||||
channels,
|
||||
notifications,
|
||||
showChannelModal,
|
||||
channelForm,
|
||||
sendForm,
|
||||
sending,
|
||||
message,
|
||||
saveChannel,
|
||||
deleteChannel,
|
||||
testChannel,
|
||||
sendNotification,
|
||||
formatDate,
|
||||
getStatusClass
|
||||
};
|
||||
}
|
||||
}).mount('#app');
|
||||
Loading…
x
Reference in New Issue
Block a user