Compare commits

...

4 Commits

Author SHA1 Message Date
OpenClaw Agent
e715ca7350 feat: add Vue3 frontend with TailwindCSS 2026-02-07 18:15:24 +00:00
OpenClaw Agent
7f581e3fbb feat: add FastAPI routers for channels, notify, and history 2026-02-07 18:13:24 +00:00
OpenClaw Agent
53bc48fee6 feat: add notification service with apprise integration and tests 2026-02-07 17:30:04 +00:00
OpenClaw Agent
1a5f32dc4e feat: add database models and CRUD operations 2026-02-07 17:25:47 +00:00
33 changed files with 1258 additions and 0 deletions

1
backend/__init__.py Normal file
View File

@ -0,0 +1 @@
# 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.

3
backend/config.py Normal file
View File

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

130
backend/crud.py Normal file
View 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
View 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
View 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
View 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
View 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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

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

View File

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

Binary file not shown.

View 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

View 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
View File

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

233
frontend/index.html Normal file
View 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
View 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');