Compare commits

..

No commits in common. "e715ca73506267ea35356900572e6b4069d8228e" and "d5344e244ec59970418b62be8a961a79aab22362" have entirely different histories.

33 changed files with 0 additions and 1258 deletions

View File

@ -1 +0,0 @@
# backend package

View File

@ -1,3 +0,0 @@
import os
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./notify_center.db")

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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")

View File

@ -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

View File

@ -1 +0,0 @@
# routers package

View File

@ -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')}"
)

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -1 +0,0 @@
# tests package

View File

@ -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

View File

@ -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

View File

@ -1,4 +0,0 @@
/* Custom styles */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

View File

@ -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>

View File

@ -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');