Compare commits
No commits in common. "e715ca73506267ea35356900572e6b4069d8228e" and "d5344e244ec59970418b62be8a961a79aab22362" have entirely different histories.
e715ca7350
...
d5344e244e
@ -1 +0,0 @@
|
||||
# backend package
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,3 +0,0 @@
|
||||
import os
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./notify_center.db")
|
||||
130
backend/crud.py
130
backend/crud.py
@ -1,130 +0,0 @@
|
||||
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
|
||||
@ -1,22 +0,0 @@
|
||||
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)
|
||||
@ -1,49 +0,0 @@
|
||||
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)
|
||||
@ -1,34 +0,0 @@
|
||||
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")
|
||||
@ -1,189 +0,0 @@
|
||||
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 +0,0 @@
|
||||
# routers package
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,82 +0,0 @@
|
||||
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')}"
|
||||
)
|
||||
@ -1,41 +0,0 @@
|
||||
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
|
||||
@ -1,82 +0,0 @@
|
||||
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
|
||||
}
|
||||
@ -1,72 +0,0 @@
|
||||
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 +0,0 @@
|
||||
# tests package
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,23 +0,0 @@
|
||||
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
|
||||
@ -1,106 +0,0 @@
|
||||
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
|
||||
@ -1,4 +0,0 @@
|
||||
/* Custom styles */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
@ -1,233 +0,0 @@
|
||||
<!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>
|
||||
@ -1,185 +0,0 @@
|
||||
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