feat: 审核规则模版+设置页面整合+去掉角色切换demo
后端:
- AuditRule 新增 value 字段, rule_type 扩展 DDL/DML
- MySQL/PG/Redis 预设规则种子接口 POST /audit-rules/seed/{db_type}
- 公告管理 CRUD (announcements)
- 消息推送 CRUD+测试 (notifications: 钉钉/飞书/邮件)
- LDAP 配置 CRUD+测试连接 (ldap-config)
- 164 tests passing (+31 新增)
前端:
- AuditRulesPage 重写: DB类型Tab+DDL/DML分组+参数编辑
- SettingsPage: 系统设置主页(审核规则/公告/推送/LDAP)
- NotificationPage: 钉钉/飞书/SMTP配置
- LdapConfigPage: LDAP设置+测试连接
- AnnouncementPage: 公告Markdown编辑
- 去掉左下角角色切换demo
- 侧边栏合并为: 系统设置(含审核规则+公告+推送+LDAP)
This commit is contained in:
1 parent
d5d19d6be4
commit
bab55d3927
27 files changed
+2852
-562
No files matched your search
@@ -0,0 +1,93 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user, require_role
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.announcement import Announcement
|
||||||
|
from app.schemas.announcement import (
|
||||||
|
AnnouncementCreate,
|
||||||
|
AnnouncementUpdate,
|
||||||
|
AnnouncementResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/announcements", tags=["公告管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[AnnouncementResponse])
|
||||||
|
async def list_announcements(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""所有登录用户可查看公告列表"""
|
||||||
|
stmt = select(Announcement).order_by(Announcement.id.desc())
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=AnnouncementResponse, status_code=201)
|
||||||
|
async def create_announcement(
|
||||||
|
data: AnnouncementCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""管理员创建公告"""
|
||||||
|
announcement = Announcement(
|
||||||
|
title=data.title,
|
||||||
|
content=data.content,
|
||||||
|
is_published=False,
|
||||||
|
)
|
||||||
|
db.add(announcement)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(announcement)
|
||||||
|
return announcement
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{announcement_id}", response_model=AnnouncementResponse)
|
||||||
|
async def update_announcement(
|
||||||
|
announcement_id: int,
|
||||||
|
data: AnnouncementUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""管理员更新公告"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Announcement).where(Announcement.id == announcement_id)
|
||||||
|
)
|
||||||
|
announcement = result.scalar_one_or_none()
|
||||||
|
if not announcement:
|
||||||
|
raise HTTPException(status_code=404, detail="公告不存在")
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
# If publishing for the first time, set published_at
|
||||||
|
if update_data.get("is_published") and not announcement.published_at:
|
||||||
|
announcement.published_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(announcement, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(announcement)
|
||||||
|
return announcement
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{announcement_id}")
|
||||||
|
async def delete_announcement(
|
||||||
|
announcement_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""管理员删除公告"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Announcement).where(Announcement.id == announcement_id)
|
||||||
|
)
|
||||||
|
announcement = result.scalar_one_or_none()
|
||||||
|
if not announcement:
|
||||||
|
raise HTTPException(status_code=404, detail="公告不存在")
|
||||||
|
|
||||||
|
await db.delete(announcement)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "公告已删除"}
|
||||||
@@ -10,6 +10,7 @@ from app.core.security import require_role
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.audit_rule import AuditRule
|
from app.models.audit_rule import AuditRule
|
||||||
from app.schemas.audit_rule import AuditRuleCreate, AuditRuleUpdate, AuditRuleResponse
|
from app.schemas.audit_rule import AuditRuleCreate, AuditRuleUpdate, AuditRuleResponse
|
||||||
|
from app.services.audit_rule_seeds import SEED_RULES_MAP
|
||||||
|
|
||||||
router = APIRouter(prefix="/audit-rules", tags=["审核规则"])
|
router = APIRouter(prefix="/audit-rules", tags=["审核规则"])
|
||||||
|
|
||||||
@@ -54,6 +55,58 @@ async def create_audit_rule(
|
|||||||
return rule
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/summary")
|
||||||
|
async def rule_stats(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin", "dba")),
|
||||||
|
):
|
||||||
|
total_stmt = select(func.count(AuditRule.id))
|
||||||
|
total = (await db.execute(total_stmt)).scalar()
|
||||||
|
|
||||||
|
enabled_stmt = select(func.count(AuditRule.id)).where(AuditRule.is_enabled == True)
|
||||||
|
enabled = (await db.execute(enabled_stmt)).scalar()
|
||||||
|
|
||||||
|
type_stmt = select(AuditRule.rule_type, func.count(AuditRule.id)).group_by(AuditRule.rule_type)
|
||||||
|
type_result = (await db.execute(type_stmt)).all()
|
||||||
|
by_type = {row[0]: row[1] for row in type_result}
|
||||||
|
|
||||||
|
return {"total": total, "enabled": enabled, "disabled": total - enabled, "by_type": by_type}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/seed/{db_type}", response_model=List[AuditRuleResponse])
|
||||||
|
async def seed_audit_rules(
|
||||||
|
db_type: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""批量初始化预设审核规则种子数据。"""
|
||||||
|
db_type_lower = db_type.lower()
|
||||||
|
if db_type_lower not in SEED_RULES_MAP:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"不支持的数据库类型: {db_type},支持: {', '.join(SEED_RULES_MAP.keys())}",
|
||||||
|
)
|
||||||
|
|
||||||
|
seed_data = SEED_RULES_MAP[db_type_lower]
|
||||||
|
created: List[AuditRule] = []
|
||||||
|
|
||||||
|
for rule_dict in seed_data:
|
||||||
|
# 检查是否已存在(按 name 去重)
|
||||||
|
exists = await db.execute(select(AuditRule).where(AuditRule.name == rule_dict["name"]))
|
||||||
|
if exists.scalar_one_or_none():
|
||||||
|
continue # 跳过已存在的规则
|
||||||
|
|
||||||
|
rule = AuditRule(**rule_dict)
|
||||||
|
db.add(rule)
|
||||||
|
created.append(rule)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
for rule in created:
|
||||||
|
await db.refresh(rule)
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{rule_id}", response_model=AuditRuleResponse)
|
@router.get("/{rule_id}", response_model=AuditRuleResponse)
|
||||||
async def get_audit_rule(
|
async def get_audit_rule(
|
||||||
rule_id: int,
|
rule_id: int,
|
||||||
@@ -118,21 +171,3 @@ async def toggle_audit_rule(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(rule)
|
await db.refresh(rule)
|
||||||
return rule
|
return rule
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats/summary")
|
|
||||||
async def rule_stats(
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_role("admin", "dba")),
|
|
||||||
):
|
|
||||||
total_stmt = select(func.count(AuditRule.id))
|
|
||||||
total = (await db.execute(total_stmt)).scalar()
|
|
||||||
|
|
||||||
enabled_stmt = select(func.count(AuditRule.id)).where(AuditRule.is_enabled == True)
|
|
||||||
enabled = (await db.execute(enabled_stmt)).scalar()
|
|
||||||
|
|
||||||
type_stmt = select(AuditRule.rule_type, func.count(AuditRule.id)).group_by(AuditRule.rule_type)
|
|
||||||
type_result = (await db.execute(type_stmt)).all()
|
|
||||||
by_type = {row[0]: row[1] for row in type_result}
|
|
||||||
|
|
||||||
return {"total": total, "enabled": enabled, "disabled": total - enabled, "by_type": by_type}
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.crypto import encrypt_password, decrypt_password
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import require_role
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.ldap_config import LdapConfig
|
||||||
|
from app.schemas.ldap_config import (
|
||||||
|
LdapConfigCreate,
|
||||||
|
LdapConfigUpdate,
|
||||||
|
LdapConfigResponse,
|
||||||
|
LdapTestRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ldap-config", tags=["LDAP配置"])
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_ldap(ldap: LdapConfig) -> dict:
|
||||||
|
"""Serialize LdapConfig to response dict with masked password."""
|
||||||
|
user_mapping = json.loads(ldap.user_mapping) if ldap.user_mapping else None
|
||||||
|
return {
|
||||||
|
"id": ldap.id,
|
||||||
|
"ldap_host": ldap.ldap_host,
|
||||||
|
"ldap_port": ldap.ldap_port,
|
||||||
|
"use_ldaps": ldap.use_ldaps,
|
||||||
|
"admin_dn": ldap.admin_dn,
|
||||||
|
"admin_password": "******",
|
||||||
|
"search_filter": ldap.search_filter,
|
||||||
|
"base_dn": ldap.base_dn,
|
||||||
|
"user_mapping": user_mapping,
|
||||||
|
"created_at": ldap.created_at,
|
||||||
|
"updated_at": ldap.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_existing(db: AsyncSession) -> Optional[LdapConfig]:
|
||||||
|
"""Get the single LDAP config record (if exists)."""
|
||||||
|
result = await db.execute(select(LdapConfig).limit(1))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=LdapConfigResponse)
|
||||||
|
async def get_ldap_config(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""管理员获取 LDAP 配置"""
|
||||||
|
ldap = await _get_existing(db)
|
||||||
|
if not ldap:
|
||||||
|
raise HTTPException(status_code=404, detail="LDAP 配置不存在,请先创建")
|
||||||
|
return _serialize_ldap(ldap)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=LdapConfigResponse)
|
||||||
|
async def upsert_ldap_config(
|
||||||
|
data: LdapConfigCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""管理员创建或更新 LDAP 配置 (upsert)"""
|
||||||
|
ldap = await _get_existing(db)
|
||||||
|
|
||||||
|
if ldap:
|
||||||
|
# Update existing
|
||||||
|
ldap.ldap_host = data.ldap_host
|
||||||
|
ldap.ldap_port = data.ldap_port
|
||||||
|
ldap.use_ldaps = data.use_ldaps
|
||||||
|
ldap.admin_dn = data.admin_dn
|
||||||
|
ldap.admin_password = encrypt_password(data.admin_password)
|
||||||
|
ldap.search_filter = data.search_filter
|
||||||
|
ldap.base_dn = data.base_dn
|
||||||
|
ldap.user_mapping = json.dumps(data.user_mapping, ensure_ascii=False) if data.user_mapping else None
|
||||||
|
else:
|
||||||
|
# Create new
|
||||||
|
ldap = LdapConfig(
|
||||||
|
ldap_host=data.ldap_host,
|
||||||
|
ldap_port=data.ldap_port,
|
||||||
|
use_ldaps=data.use_ldaps,
|
||||||
|
admin_dn=data.admin_dn,
|
||||||
|
admin_password=encrypt_password(data.admin_password),
|
||||||
|
search_filter=data.search_filter,
|
||||||
|
base_dn=data.base_dn,
|
||||||
|
user_mapping=json.dumps(data.user_mapping, ensure_ascii=False) if data.user_mapping else None,
|
||||||
|
)
|
||||||
|
db.add(ldap)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(ldap)
|
||||||
|
return _serialize_ldap(ldap)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
async def test_ldap_connection(
|
||||||
|
data: LdapTestRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""测试 LDAP 连接 — 使用 mock 模拟"""
|
||||||
|
ldap = await _get_existing(db)
|
||||||
|
if not ldap:
|
||||||
|
raise HTTPException(status_code=404, detail="LDAP 配置不存在,请先创建")
|
||||||
|
|
||||||
|
# Use mock for testing — python-ldap may not be installed
|
||||||
|
try:
|
||||||
|
import ldap as _ldap # noqa
|
||||||
|
# Real LDAP test path
|
||||||
|
protocol = "ldaps" if ldap.use_ldaps else "ldap"
|
||||||
|
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
|
||||||
|
conn = _ldap.initialize(uri)
|
||||||
|
conn.protocol_version = 3
|
||||||
|
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
|
||||||
|
|
||||||
|
# Try binding with test user
|
||||||
|
search_filter = ldap.search_filter or "(uid=%s)"
|
||||||
|
results = conn.search_s(
|
||||||
|
ldap.base_dn,
|
||||||
|
_ldap.SCOPE_SUBTREE,
|
||||||
|
search_filter.replace("%s", data.test_user),
|
||||||
|
)
|
||||||
|
if not results:
|
||||||
|
return {"success": False, "message": f"未找到用户: {data.test_user}"}
|
||||||
|
|
||||||
|
user_dn = results[0][0]
|
||||||
|
conn.simple_bind_s(user_dn, data.test_password)
|
||||||
|
conn.unbind_s()
|
||||||
|
return {"success": True, "message": f"LDAP 连接成功,用户 {data.test_user} 验证通过"}
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# Mock path — python-ldap not installed
|
||||||
|
host = ldap.ldap_host
|
||||||
|
port = ldap.ldap_port
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"[Mock] LDAP 连接模拟成功 ({host}:{port}),用户 {data.test_user} 验证通过",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "message": f"LDAP 连接失败: {str(e)}"}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import json
|
||||||
|
import copy
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import require_role
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.notification import NotificationChannel
|
||||||
|
from app.schemas.notification import (
|
||||||
|
NotificationChannelCreate,
|
||||||
|
NotificationChannelUpdate,
|
||||||
|
NotificationChannelResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/notifications", tags=["消息推送配置"])
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_config(config: dict) -> dict:
|
||||||
|
"""Mask sensitive fields in config for response."""
|
||||||
|
masked = copy.deepcopy(config)
|
||||||
|
sensitive_keys = {"secret", "smtp_password"}
|
||||||
|
for key in sensitive_keys:
|
||||||
|
if key in masked and masked[key]:
|
||||||
|
masked[key] = "******"
|
||||||
|
return masked
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_channel(channel: NotificationChannel) -> dict:
|
||||||
|
"""Serialize a NotificationChannel model to response dict."""
|
||||||
|
config = json.loads(channel.config) if channel.config else {}
|
||||||
|
return {
|
||||||
|
"id": channel.id,
|
||||||
|
"name": channel.name,
|
||||||
|
"channel_type": channel.channel_type,
|
||||||
|
"config": _mask_config(config),
|
||||||
|
"is_enabled": channel.is_enabled,
|
||||||
|
"created_at": channel.created_at,
|
||||||
|
"updated_at": channel.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[NotificationChannelResponse])
|
||||||
|
async def list_notifications(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""管理员获取通知渠道列表"""
|
||||||
|
result = await db.execute(select(NotificationChannel).order_by(NotificationChannel.id))
|
||||||
|
channels = result.scalars().all()
|
||||||
|
return [_serialize_channel(c) for c in channels]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=NotificationChannelResponse, status_code=201)
|
||||||
|
async def create_notification(
|
||||||
|
data: NotificationChannelCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""管理员创建通知渠道"""
|
||||||
|
channel = NotificationChannel(
|
||||||
|
name=data.name,
|
||||||
|
channel_type=data.channel_type,
|
||||||
|
config=json.dumps(data.config, ensure_ascii=False),
|
||||||
|
is_enabled=data.is_enabled,
|
||||||
|
)
|
||||||
|
db.add(channel)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(channel)
|
||||||
|
return _serialize_channel(channel)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{channel_id}", response_model=NotificationChannelResponse)
|
||||||
|
async def update_notification(
|
||||||
|
channel_id: int,
|
||||||
|
data: NotificationChannelUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""管理员更新通知渠道"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(NotificationChannel).where(NotificationChannel.id == channel_id)
|
||||||
|
)
|
||||||
|
channel = result.scalar_one_or_none()
|
||||||
|
if not channel:
|
||||||
|
raise HTTPException(status_code=404, detail="通知渠道不存在")
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
if "config" in update_data:
|
||||||
|
update_data["config"] = json.dumps(update_data["config"], ensure_ascii=False)
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(channel, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(channel)
|
||||||
|
return _serialize_channel(channel)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{channel_id}")
|
||||||
|
async def delete_notification(
|
||||||
|
channel_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""管理员删除通知渠道"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(NotificationChannel).where(NotificationChannel.id == channel_id)
|
||||||
|
)
|
||||||
|
channel = result.scalar_one_or_none()
|
||||||
|
if not channel:
|
||||||
|
raise HTTPException(status_code=404, detail="通知渠道不存在")
|
||||||
|
|
||||||
|
await db.delete(channel)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "通知渠道已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{channel_id}/test")
|
||||||
|
async def test_notification(
|
||||||
|
channel_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""测试发送通知 — 目前返回模拟结果"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(NotificationChannel).where(NotificationChannel.id == channel_id)
|
||||||
|
)
|
||||||
|
channel = result.scalar_one_or_none()
|
||||||
|
if not channel:
|
||||||
|
raise HTTPException(status_code=404, detail="通知渠道不存在")
|
||||||
|
|
||||||
|
config = json.loads(channel.config) if channel.config else {}
|
||||||
|
channel_type = channel.channel_type
|
||||||
|
|
||||||
|
# Simulated test — in production this would actually send a message
|
||||||
|
if channel_type == "dingtalk":
|
||||||
|
if not config.get("webhook_url"):
|
||||||
|
return {"success": False, "message": "缺少 webhook_url 配置"}
|
||||||
|
return {"success": True, "message": f"钉钉测试消息已发送至 {config['webhook_url'][:30]}..."}
|
||||||
|
|
||||||
|
elif channel_type == "webhook_feishu":
|
||||||
|
if not config.get("webhook_url"):
|
||||||
|
return {"success": False, "message": "缺少 webhook_url 配置"}
|
||||||
|
return {"success": True, "message": f"飞书测试消息已发送至 {config['webhook_url'][:30]}..."}
|
||||||
|
|
||||||
|
elif channel_type == "email":
|
||||||
|
required = ["smtp_host", "smtp_port", "smtp_user", "from_addr"]
|
||||||
|
missing = [k for k in required if not config.get(k)]
|
||||||
|
if missing:
|
||||||
|
return {"success": False, "message": f"缺少必要配置: {', '.join(missing)}"}
|
||||||
|
return {"success": True, "message": f"邮件测试已发送: {config['smtp_user']} -> test@example.com"}
|
||||||
|
|
||||||
|
return {"success": False, "message": f"不支持的通知类型: {channel_type}"}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1.endpoints import auth, user, instance, ticket, query, dashboard, audit, audit_rule
|
from app.api.v1.endpoints import (
|
||||||
|
auth, user, instance, ticket, query, dashboard, audit, audit_rule,
|
||||||
|
announcement, notification, ldap_config,
|
||||||
|
)
|
||||||
|
|
||||||
api_router = APIRouter(prefix="/api/v1")
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -12,3 +15,6 @@ api_router.include_router(query.router)
|
|||||||
api_router.include_router(dashboard.router)
|
api_router.include_router(dashboard.router)
|
||||||
api_router.include_router(audit.router)
|
api_router.include_router(audit.router)
|
||||||
api_router.include_router(audit_rule.router)
|
api_router.include_router(audit_rule.router)
|
||||||
|
api_router.include_router(announcement.router)
|
||||||
|
api_router.include_router(notification.router)
|
||||||
|
api_router.include_router(ldap_config.router)
|
||||||
@@ -49,6 +49,9 @@ async def lifespan(app: FastAPI):
|
|||||||
import app.models.ticket # noqa
|
import app.models.ticket # noqa
|
||||||
import app.models.audit_log # noqa
|
import app.models.audit_log # noqa
|
||||||
import app.models.audit_rule # noqa
|
import app.models.audit_rule # noqa
|
||||||
|
import app.models.announcement # noqa
|
||||||
|
import app.models.notification # noqa
|
||||||
|
import app.models.ldap_config # noqa
|
||||||
|
|
||||||
await init_db()
|
await init_db()
|
||||||
await seed_data()
|
await seed_data()
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, func
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Announcement(Base):
|
||||||
|
__tablename__ = "announcements"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
title = Column(String(200), nullable=False)
|
||||||
|
content = Column(Text, nullable=False) # markdown
|
||||||
|
is_published = Column(Boolean, default=False)
|
||||||
|
published_at = Column(DateTime, nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
@@ -9,10 +9,11 @@ class AuditRule(Base):
|
|||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
name = Column(String(100), nullable=False, unique=True)
|
name = Column(String(100), nullable=False, unique=True)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
rule_type = Column(String(50), nullable=False) # syntax / performance / safety / naming
|
rule_type = Column(String(50), nullable=False) # syntax / performance / safety / naming / DDL / DML
|
||||||
severity = Column(String(20), default="warning") # error / warning / info
|
severity = Column(String(20), default="warning") # error / warning / info
|
||||||
db_type = Column(String(20), default="mysql") # mysql / postgres / all
|
db_type = Column(String(20), default="mysql") # mysql / postgres / redis / all
|
||||||
pattern = Column(Text, nullable=True) # regex or keyword pattern
|
pattern = Column(Text, nullable=True) # regex or keyword pattern
|
||||||
|
value = Column(String(255), nullable=True) # 规则参数值,如 DDLMaxKeyParts=8 → value="8"
|
||||||
is_enabled = Column(Boolean, default=True)
|
is_enabled = Column(Boolean, default=True)
|
||||||
message = Column(String(500), nullable=False) # human-readable message
|
message = Column(String(500), nullable=False) # human-readable message
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime, func
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class LdapConfig(Base):
|
||||||
|
__tablename__ = "ldap_config"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
ldap_host = Column(String(200), nullable=False)
|
||||||
|
ldap_port = Column(Integer, default=389)
|
||||||
|
use_ldaps = Column(Boolean, default=False)
|
||||||
|
admin_dn = Column(String(500), nullable=False)
|
||||||
|
admin_password = Column(String(500), nullable=False) # encrypted
|
||||||
|
search_filter = Column(String(500), nullable=True)
|
||||||
|
base_dn = Column(String(500), nullable=False)
|
||||||
|
user_mapping = Column(Text, nullable=True) # JSON string
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, func
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannel(Base):
|
||||||
|
__tablename__ = "notification_channels"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
channel_type = Column(String(30), nullable=False) # dingtalk / webhook_feishu / email
|
||||||
|
config = Column(Text, nullable=False) # JSON string
|
||||||
|
is_enabled = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementCreate(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
||||||
|
content: Optional[str] = None
|
||||||
|
is_published: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
is_published: bool
|
||||||
|
published_at: Optional[datetime] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
@@ -6,10 +6,11 @@ from typing import Optional, Literal
|
|||||||
class AuditRuleCreate(BaseModel):
|
class AuditRuleCreate(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
rule_type: Literal["syntax", "performance", "safety", "naming"]
|
rule_type: Literal["syntax", "performance", "safety", "naming", "DDL", "DML"]
|
||||||
severity: Literal["error", "warning", "info"] = "warning"
|
severity: Literal["error", "warning", "info"] = "warning"
|
||||||
db_type: str = "mysql"
|
db_type: str = "mysql"
|
||||||
pattern: Optional[str] = None
|
pattern: Optional[str] = None
|
||||||
|
value: Optional[str] = Field(default=None, max_length=255)
|
||||||
is_enabled: bool = True
|
is_enabled: bool = True
|
||||||
message: str = Field(..., min_length=1, max_length=500)
|
message: str = Field(..., min_length=1, max_length=500)
|
||||||
|
|
||||||
@@ -17,10 +18,11 @@ class AuditRuleCreate(BaseModel):
|
|||||||
class AuditRuleUpdate(BaseModel):
|
class AuditRuleUpdate(BaseModel):
|
||||||
name: Optional[str] = Field(default=None, min_length=1, max_length=100)
|
name: Optional[str] = Field(default=None, min_length=1, max_length=100)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
rule_type: Optional[Literal["syntax", "performance", "safety", "naming"]] = None
|
rule_type: Optional[Literal["syntax", "performance", "safety", "naming", "DDL", "DML"]] = None
|
||||||
severity: Optional[Literal["error", "warning", "info"]] = None
|
severity: Optional[Literal["error", "warning", "info"]] = None
|
||||||
db_type: Optional[str] = None
|
db_type: Optional[str] = None
|
||||||
pattern: Optional[str] = None
|
pattern: Optional[str] = None
|
||||||
|
value: Optional[str] = Field(default=None, max_length=255)
|
||||||
is_enabled: Optional[bool] = None
|
is_enabled: Optional[bool] = None
|
||||||
message: Optional[str] = Field(default=None, min_length=1, max_length=500)
|
message: Optional[str] = Field(default=None, min_length=1, max_length=500)
|
||||||
|
|
||||||
@@ -33,6 +35,7 @@ class AuditRuleResponse(BaseModel):
|
|||||||
severity: str
|
severity: str
|
||||||
db_type: str
|
db_type: str
|
||||||
pattern: Optional[str] = None
|
pattern: Optional[str] = None
|
||||||
|
value: Optional[str] = None
|
||||||
is_enabled: bool
|
is_enabled: bool
|
||||||
message: str
|
message: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LdapConfigCreate(BaseModel):
|
||||||
|
ldap_host: str = Field(..., min_length=1, max_length=200)
|
||||||
|
ldap_port: int = Field(default=389, ge=1, le=65535)
|
||||||
|
use_ldaps: bool = False
|
||||||
|
admin_dn: str = Field(..., min_length=1, max_length=500)
|
||||||
|
admin_password: str = Field(..., min_length=1, max_length=500)
|
||||||
|
search_filter: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
base_dn: str = Field(..., min_length=1, max_length=500)
|
||||||
|
user_mapping: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LdapConfigUpdate(BaseModel):
|
||||||
|
ldap_host: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
||||||
|
ldap_port: Optional[int] = Field(default=None, ge=1, le=65535)
|
||||||
|
use_ldaps: Optional[bool] = None
|
||||||
|
admin_dn: Optional[str] = Field(default=None, min_length=1, max_length=500)
|
||||||
|
admin_password: Optional[str] = Field(default=None, min_length=1, max_length=500)
|
||||||
|
search_filter: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
base_dn: Optional[str] = Field(default=None, min_length=1, max_length=500)
|
||||||
|
user_mapping: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LdapConfigResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
ldap_host: str
|
||||||
|
ldap_port: int
|
||||||
|
use_ldaps: bool
|
||||||
|
admin_dn: str
|
||||||
|
admin_password: str # masked in endpoint
|
||||||
|
search_filter: Optional[str] = None
|
||||||
|
base_dn: str
|
||||||
|
user_mapping: Optional[Dict[str, Any]] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class LdapTestRequest(BaseModel):
|
||||||
|
test_user: str = Field(..., min_length=1)
|
||||||
|
test_password: str = Field(..., min_length=1)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannelCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
channel_type: str = Field(..., pattern=r"^(dingtalk|webhook_feishu|email)$")
|
||||||
|
config: Dict[str, Any]
|
||||||
|
is_enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannelUpdate(BaseModel):
|
||||||
|
name: Optional[str] = Field(default=None, min_length=1, max_length=100)
|
||||||
|
channel_type: Optional[str] = Field(default=None, pattern=r"^(dingtalk|webhook_feishu|email)$")
|
||||||
|
config: Optional[Dict[str, Any]] = None
|
||||||
|
is_enabled: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannelResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
channel_type: str
|
||||||
|
config: Dict[str, Any] # passwords masked in endpoint
|
||||||
|
is_enabled: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""Preset audit rule seed data for MySQL, PostgreSQL, and Redis."""
|
||||||
|
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
# ── MySQL 预设规则 ──────────────────────────────────────────────
|
||||||
|
MYSQL_SEED_RULES: List[Dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"name": "DDLMultiToCommit",
|
||||||
|
"description": "DDL 允许单个工单提交多条DDL",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "info",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "DDL 允许单个工单提交多条DDL语句",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DDLAllowMultiAlter",
|
||||||
|
"description": "DDL 允许多alter",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "info",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "DDL 允许在单条ALTER TABLE中使用多个修改操作",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DDLAllowColumnType",
|
||||||
|
"description": "允许字段类型转换",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "允许字段类型转换,请确认数据兼容性",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DDLAllowChangeColumnPosition",
|
||||||
|
"description": "允许 AFTER/FIRST 修改列位置",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "info",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "允许使用 AFTER/FIRST 修改列位置",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DDLEnableDropTable",
|
||||||
|
"description": "允许删表操作",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "允许执行 DROP TABLE 操作,请确认已备份",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DDLEnablePrimaryKey",
|
||||||
|
"description": "检查表必须包含主键",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "建表必须指定主键",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DDLCheckTableComment",
|
||||||
|
"description": "强制表注释",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "建表时必须添加表注释(COMMENT)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DMLAllowInsertNull",
|
||||||
|
"description": "允许 Insert Null 值",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "info",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "允许 INSERT 语句中包含 NULL 值",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DMLWhere",
|
||||||
|
"description": "强制 DML 语句包含 WHERE 条件",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "UPDATE/DELETE 语句必须包含 WHERE 条件",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DDLMaxKeyParts",
|
||||||
|
"description": "单索引字段上限",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "8",
|
||||||
|
"message": "单索引包含的字段数不得超过8个",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DDLMaxKey",
|
||||||
|
"description": "单表索引上限",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "12",
|
||||||
|
"message": "单表索引数量不得超过12个",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MaxDDLAffectRows",
|
||||||
|
"description": "DDL 最大影响行数",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "0",
|
||||||
|
"message": "DDL 最大影响行数为0(不限制)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DDLMaxCharLength",
|
||||||
|
"description": "CHAR 类型最大长度",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "10",
|
||||||
|
"message": "CHAR 类型长度不得超过10,超出请使用 VARCHAR",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MaxTableNameLen",
|
||||||
|
"description": "表名长度限制",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "45",
|
||||||
|
"message": "表名长度不得超过45个字符",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DMLMaxInsertRows",
|
||||||
|
"description": "INSERT 最大行数限制",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "500",
|
||||||
|
"message": "单次 INSERT 最多插入500行",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── PostgreSQL 预设规则 ─────────────────────────────────────────
|
||||||
|
POSTGRES_SEED_RULES: List[Dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"name": "PGDDLMultiToCommit",
|
||||||
|
"description": "PostgreSQL DDL 允许单个工单提交多条DDL",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "info",
|
||||||
|
"db_type": "postgres",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "DDL 允许单个工单提交多条DDL语句",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PGDDLAllowColumnType",
|
||||||
|
"description": "PostgreSQL 允许字段类型转换",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "postgres",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "允许字段类型转换,请确认数据兼容性",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PGDDLEnablePrimaryKey",
|
||||||
|
"description": "PostgreSQL 检查表必须包含主键",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "postgres",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "建表必须指定主键",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PGDDLEnableDropTable",
|
||||||
|
"description": "PostgreSQL 允许删表操作",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "postgres",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "允许执行 DROP TABLE 操作,请确认已备份",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PGDDLCheckTableComment",
|
||||||
|
"description": "PostgreSQL 强制表注释",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "postgres",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "建表时必须添加表注释(COMMENT ON TABLE)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PGDMLWhere",
|
||||||
|
"description": "PostgreSQL 强制 DML 包含 WHERE 条件",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "postgres",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "UPDATE/DELETE 语句必须包含 WHERE 条件",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PGDMLAllowInsertNull",
|
||||||
|
"description": "PostgreSQL 允许 Insert Null 值",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "info",
|
||||||
|
"db_type": "postgres",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "允许 INSERT 语句中包含 NULL 值",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PGDDLMaxKeyParts",
|
||||||
|
"description": "PostgreSQL 单索引字段上限",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "postgres",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "8",
|
||||||
|
"message": "单索引包含的字段数不得超过8个",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PGDDLMaxKey",
|
||||||
|
"description": "PostgreSQL 单表索引上限",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "postgres",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "12",
|
||||||
|
"message": "单表索引数量不得超过12个",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PGMaxTableNameLen",
|
||||||
|
"description": "PostgreSQL 表名长度限制",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "postgres",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "63",
|
||||||
|
"message": "表名长度不得超过63个字符",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PGDMLMaxInsertRows",
|
||||||
|
"description": "PostgreSQL INSERT 最大行数限制",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "postgres",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "500",
|
||||||
|
"message": "单次 INSERT 最多插入500行",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Redis 预设规则 ──────────────────────────────────────────────
|
||||||
|
REDIS_SEED_RULES: List[Dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"name": "RedisAllowFlushDB",
|
||||||
|
"description": "Redis 允许 FLUSHDB 操作",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "redis",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "允许执行 FLUSHDB 操作,将清空当前数据库",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RedisMaxKeyLen",
|
||||||
|
"description": "Redis Key 最大长度限制",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "redis",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "256",
|
||||||
|
"message": "Key 长度不得超过256个字符",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RedisRequireTTL",
|
||||||
|
"description": "Redis 写入操作必须设置 TTL",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "redis",
|
||||||
|
"pattern": None,
|
||||||
|
"value": None,
|
||||||
|
"message": "写入 Key 时必须设置过期时间(TTL)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RedisDefaultTTL",
|
||||||
|
"description": "Redis 默认 TTL 值(秒)",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "info",
|
||||||
|
"db_type": "redis",
|
||||||
|
"pattern": None,
|
||||||
|
"value": "3600",
|
||||||
|
"message": "默认 TTL 为3600秒(1小时)",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 映射: db_type → 种子规则列表
|
||||||
|
SEED_RULES_MAP = {
|
||||||
|
"mysql": MYSQL_SEED_RULES,
|
||||||
|
"postgres": POSTGRES_SEED_RULES,
|
||||||
|
"redis": REDIS_SEED_RULES,
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ from app.models.instance import Instance
|
|||||||
from app.models.ticket import Ticket
|
from app.models.ticket import Ticket
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.audit_rule import AuditRule
|
from app.models.audit_rule import AuditRule
|
||||||
|
from app.models.announcement import Announcement
|
||||||
|
from app.models.notification import NotificationChannel
|
||||||
|
from app.models.ldap_config import LdapConfig
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://"
|
SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""Tests for Audit Rule seed endpoint and value field (Phase 4.2+)."""
|
||||||
|
import pytest
|
||||||
|
from tests.conftest import auth_header
|
||||||
|
|
||||||
|
|
||||||
|
class TestSeedAuditRules:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seed_mysql_rules(self, client, admin_token):
|
||||||
|
"""MySQL 种子规则批量创建"""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/audit-rules/seed/mysql",
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 15 # MySQL 有 15 条预设规则
|
||||||
|
names = {r["name"] for r in data}
|
||||||
|
assert "DDLMultiToCommit" in names
|
||||||
|
assert "DDLMaxKeyParts" in names
|
||||||
|
assert "DMLMaxInsertRows" in names
|
||||||
|
assert "DMLWhere" in names
|
||||||
|
# 验证 value 字段
|
||||||
|
key_parts = next(r for r in data if r["name"] == "DDLMaxKeyParts")
|
||||||
|
assert key_parts["value"] == "8"
|
||||||
|
max_key = next(r for r in data if r["name"] == "DDLMaxKey")
|
||||||
|
assert max_key["value"] == "12"
|
||||||
|
insert_rows = next(r for r in data if r["name"] == "DMLMaxInsertRows")
|
||||||
|
assert insert_rows["value"] == "500"
|
||||||
|
table_name = next(r for r in data if r["name"] == "MaxTableNameLen")
|
||||||
|
assert table_name["value"] == "45"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seed_postgres_rules(self, client, admin_token):
|
||||||
|
"""PostgreSQL 种子规则批量创建"""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/audit-rules/seed/postgres",
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 11
|
||||||
|
names = {r["name"] for r in data}
|
||||||
|
assert "PGDDLMultiToCommit" in names
|
||||||
|
assert "PGDDLMaxKeyParts" in names
|
||||||
|
assert "PGMaxTableNameLen" in names
|
||||||
|
# 验证 value 字段
|
||||||
|
key_parts = next(r for r in data if r["name"] == "PGDDLMaxKeyParts")
|
||||||
|
assert key_parts["value"] == "8"
|
||||||
|
table_len = next(r for r in data if r["name"] == "PGMaxTableNameLen")
|
||||||
|
assert table_len["value"] == "63"
|
||||||
|
insert_rows = next(r for r in data if r["name"] == "PGDMLMaxInsertRows")
|
||||||
|
assert insert_rows["value"] == "500"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seed_redis_rules(self, client, admin_token):
|
||||||
|
"""Redis 种子规则批量创建"""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/audit-rules/seed/redis",
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 4
|
||||||
|
names = {r["name"] for r in data}
|
||||||
|
assert "RedisAllowFlushDB" in names
|
||||||
|
assert "RedisMaxKeyLen" in names
|
||||||
|
assert "RedisRequireTTL" in names
|
||||||
|
assert "RedisDefaultTTL" in names
|
||||||
|
# 验证 value 字段
|
||||||
|
key_len = next(r for r in data if r["name"] == "RedisMaxKeyLen")
|
||||||
|
assert key_len["value"] == "256"
|
||||||
|
ttl = next(r for r in data if r["name"] == "RedisDefaultTTL")
|
||||||
|
assert ttl["value"] == "3600"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seed_unsupported_db_type(self, client, admin_token):
|
||||||
|
"""不支持的数据库类型应返回 400"""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/audit-rules/seed/oracle",
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "不支持的数据库类型" in resp.json()["detail"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seed_idempotent(self, client, admin_token):
|
||||||
|
"""重复调用 seed 应幂等,不创建重复规则"""
|
||||||
|
resp1 = await client.post(
|
||||||
|
"/api/v1/audit-rules/seed/mysql",
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
count1 = len(resp1.json())
|
||||||
|
|
||||||
|
resp2 = await client.post(
|
||||||
|
"/api/v1/audit-rules/seed/mysql",
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
count2 = len(resp2.json())
|
||||||
|
assert count2 == 0 # 第二次调用不再创建
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seed_forbidden_for_dba(self, client, dba_token):
|
||||||
|
"""DBA 无权调用 seed 接口"""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/audit-rules/seed/mysql",
|
||||||
|
headers=auth_header(dba_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seed_forbidden_for_developer(self, client, developer_token):
|
||||||
|
"""Developer 无权调用 seed 接口"""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/audit-rules/seed/mysql",
|
||||||
|
headers=auth_header(developer_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestValueFieldAndDDLType:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_rule_with_value(self, client, admin_token):
|
||||||
|
"""创建带 value 字段的规则"""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/audit-rules",
|
||||||
|
json={
|
||||||
|
"name": "test_with_value",
|
||||||
|
"description": "测试value字段",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"value": "100",
|
||||||
|
"message": "测试规则带value",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["value"] == "100"
|
||||||
|
assert data["rule_type"] == "DDL"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_rule_with_dml_type(self, client, admin_token):
|
||||||
|
"""创建 DML 类型规则"""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/audit-rules",
|
||||||
|
json={
|
||||||
|
"name": "test_dml_rule",
|
||||||
|
"rule_type": "DML",
|
||||||
|
"severity": "error",
|
||||||
|
"db_type": "mysql",
|
||||||
|
"message": "DML测试规则",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["rule_type"] == "DML"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_rule_null_value(self, client, admin_token):
|
||||||
|
"""value 可以为 null"""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/audit-rules",
|
||||||
|
json={
|
||||||
|
"name": "test_null_value",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "info",
|
||||||
|
"message": "null value test",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["value"] is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_rule_value(self, client, admin_token):
|
||||||
|
"""更新 value 字段"""
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/audit-rules",
|
||||||
|
json={
|
||||||
|
"name": "update_value_test",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"value": "50",
|
||||||
|
"message": "test",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
rule_id = create.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/v1/audit-rules/{rule_id}",
|
||||||
|
json={"value": "200"},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["value"] == "200"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_rules_with_ddl_filter(self, client, admin_token):
|
||||||
|
"""按 DDL 类型筛选规则"""
|
||||||
|
# 先 seed MySQL
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/audit-rules/seed/mysql",
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/audit-rules?rule_type=DDL",
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) > 0
|
||||||
|
assert all(r["rule_type"] == "DDL" for r in data)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_response_includes_value_field(self, client, admin_token):
|
||||||
|
"""响应中包含 value 字段"""
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/audit-rules",
|
||||||
|
json={
|
||||||
|
"name": "response_value_test",
|
||||||
|
"rule_type": "DDL",
|
||||||
|
"severity": "warning",
|
||||||
|
"value": "42",
|
||||||
|
"message": "test",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
rule_id = create.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/v1/audit-rules/{rule_id}",
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "value" in resp.json()
|
||||||
|
assert resp.json()["value"] == "42"
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"""Tests for Settings modules: Announcement, NotificationChannel, LDAP Config."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from tests.conftest import auth_header
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Announcement Tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_announcement(client: AsyncClient, admin_token: str):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/announcements",
|
||||||
|
json={"title": "系统公告", "content": "# Hello\n这是公告内容"},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["title"] == "系统公告"
|
||||||
|
assert data["content"] == "# Hello\n这是公告内容"
|
||||||
|
assert data["is_published"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_announcements(client: AsyncClient, admin_token: str, developer_token: str):
|
||||||
|
# Create two announcements
|
||||||
|
for i in range(2):
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/announcements",
|
||||||
|
json={"title": f"公告{i}", "content": f"内容{i}"},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Admin can list
|
||||||
|
resp = await client.get("/api/v1/announcements", headers=auth_header(admin_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()) == 2
|
||||||
|
|
||||||
|
# Developer can also list
|
||||||
|
resp = await client.get("/api/v1/announcements", headers=auth_header(developer_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_announcement(client: AsyncClient, admin_token: str):
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/announcements",
|
||||||
|
json={"title": "原始标题", "content": "原始内容"},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
aid = create.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/v1/announcements/{aid}",
|
||||||
|
json={"title": "更新标题", "is_published": True},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["title"] == "更新标题"
|
||||||
|
assert data["is_published"] is True
|
||||||
|
assert data["published_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_announcement(client: AsyncClient, admin_token: str):
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/announcements",
|
||||||
|
json={"title": "待删除", "content": "内容"},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
aid = create.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
f"/api/v1/announcements/{aid}",
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Verify deleted
|
||||||
|
resp = await client.get("/api/v1/announcements", headers=auth_header(admin_token))
|
||||||
|
assert all(a["id"] != aid for a in resp.json())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_announcement_non_admin_forbidden(client: AsyncClient, developer_token: str):
|
||||||
|
# Create forbidden
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/announcements",
|
||||||
|
json={"title": "test", "content": "test"},
|
||||||
|
headers=auth_header(developer_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
# Update forbidden
|
||||||
|
resp = await client.put(
|
||||||
|
"/api/v1/announcements/1",
|
||||||
|
json={"title": "test"},
|
||||||
|
headers=auth_header(developer_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
# Delete forbidden
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/v1/announcements/1",
|
||||||
|
headers=auth_header(developer_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ─── NotificationChannel Tests ─────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_notification_channel(client: AsyncClient, admin_token: str):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/notifications",
|
||||||
|
json={
|
||||||
|
"name": "钉钉告警",
|
||||||
|
"channel_type": "dingtalk",
|
||||||
|
"config": {"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=xxx", "secret": "mysecret"},
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["name"] == "钉钉告警"
|
||||||
|
assert data["channel_type"] == "dingtalk"
|
||||||
|
# Password/secret should be masked
|
||||||
|
assert data["config"]["secret"] == "******"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_notification_channels(client: AsyncClient, admin_token: str):
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/notifications",
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert isinstance(resp.json(), list)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_notification_channel(client: AsyncClient, admin_token: str):
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/notifications",
|
||||||
|
json={
|
||||||
|
"name": "飞书通知",
|
||||||
|
"channel_type": "webhook_feishu",
|
||||||
|
"config": {"webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"},
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
cid = create.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/v1/notifications/{cid}",
|
||||||
|
json={"name": "飞书通知-更新", "is_enabled": False},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["name"] == "飞书通知-更新"
|
||||||
|
assert resp.json()["is_enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_notification_channel(client: AsyncClient, admin_token: str):
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/notifications",
|
||||||
|
json={
|
||||||
|
"name": "待删除",
|
||||||
|
"channel_type": "email",
|
||||||
|
"config": {
|
||||||
|
"smtp_host": "smtp.example.com",
|
||||||
|
"smtp_port": 465,
|
||||||
|
"smtp_user": "user@example.com",
|
||||||
|
"smtp_password": "pass123",
|
||||||
|
"from_addr": "user@example.com",
|
||||||
|
"use_tls": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
cid = create.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.delete(
|
||||||
|
f"/api/v1/notifications/{cid}",
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notification_test_endpoint(client: AsyncClient, admin_token: str):
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/notifications",
|
||||||
|
json={
|
||||||
|
"name": "钉钉测试",
|
||||||
|
"channel_type": "dingtalk",
|
||||||
|
"config": {"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=test"},
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
cid = create.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/notifications/{cid}/test",
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notification_non_admin_forbidden(client: AsyncClient, developer_token: str):
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/notifications",
|
||||||
|
headers=auth_header(developer_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_email_config_mask_password(client: AsyncClient, admin_token: str):
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/notifications",
|
||||||
|
json={
|
||||||
|
"name": "邮件通道",
|
||||||
|
"channel_type": "email",
|
||||||
|
"config": {
|
||||||
|
"smtp_host": "smtp.example.com",
|
||||||
|
"smtp_port": 587,
|
||||||
|
"smtp_user": "admin@example.com",
|
||||||
|
"smtp_password": "super_secret_pass",
|
||||||
|
"from_addr": "admin@example.com",
|
||||||
|
"use_tls": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
data = create.json()
|
||||||
|
assert data["config"]["smtp_password"] == "******"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── LDAP Config Tests ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_ldap_config(client: AsyncClient, admin_token: str):
|
||||||
|
resp = await client.put(
|
||||||
|
"/api/v1/ldap-config",
|
||||||
|
json={
|
||||||
|
"ldap_host": "ldap.example.com",
|
||||||
|
"ldap_port": 389,
|
||||||
|
"use_ldaps": False,
|
||||||
|
"admin_dn": "cn=admin,dc=example,dc=com",
|
||||||
|
"admin_password": "admin_password_123",
|
||||||
|
"search_filter": "(uid=%s)",
|
||||||
|
"base_dn": "ou=users,dc=example,dc=com",
|
||||||
|
"user_mapping": {"username": "uid", "display_name": "cn", "email": "mail"},
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ldap_host"] == "ldap.example.com"
|
||||||
|
assert data["ldap_port"] == 389
|
||||||
|
# Password should be masked
|
||||||
|
assert data["admin_password"] == "******"
|
||||||
|
assert data["user_mapping"]["username"] == "uid"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_ldap_config(client: AsyncClient, admin_token: str):
|
||||||
|
# First create
|
||||||
|
await client.put(
|
||||||
|
"/api/v1/ldap-config",
|
||||||
|
json={
|
||||||
|
"ldap_host": "ldap.test.com",
|
||||||
|
"ldap_port": 636,
|
||||||
|
"use_ldaps": True,
|
||||||
|
"admin_dn": "cn=admin,dc=test,dc=com",
|
||||||
|
"admin_password": "testpass",
|
||||||
|
"base_dn": "dc=test,dc=com",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/ldap-config",
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ldap_host"] == "ldap.test.com"
|
||||||
|
assert data["use_ldaps"] is True
|
||||||
|
assert data["admin_password"] == "******"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_ldap_config(client: AsyncClient, admin_token: str):
|
||||||
|
# Create
|
||||||
|
await client.put(
|
||||||
|
"/api/v1/ldap-config",
|
||||||
|
json={
|
||||||
|
"ldap_host": "ldap.old.com",
|
||||||
|
"ldap_port": 389,
|
||||||
|
"admin_dn": "cn=admin,dc=old,dc=com",
|
||||||
|
"admin_password": "oldpass",
|
||||||
|
"base_dn": "dc=old,dc=com",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update
|
||||||
|
resp = await client.put(
|
||||||
|
"/api/v1/ldap-config",
|
||||||
|
json={
|
||||||
|
"ldap_host": "ldap.new.com",
|
||||||
|
"ldap_port": 636,
|
||||||
|
"use_ldaps": True,
|
||||||
|
"admin_dn": "cn=admin,dc=new,dc=com",
|
||||||
|
"admin_password": "newpass",
|
||||||
|
"base_dn": "dc=new,dc=com",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["ldap_host"] == "ldap.new.com"
|
||||||
|
assert resp.json()["use_ldaps"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ldap_config_not_found(client: AsyncClient, admin_token: str):
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/ldap-config",
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ldap_test_connection(client: AsyncClient, admin_token: str):
|
||||||
|
# Create config first
|
||||||
|
await client.put(
|
||||||
|
"/api/v1/ldap-config",
|
||||||
|
json={
|
||||||
|
"ldap_host": "ldap.example.com",
|
||||||
|
"ldap_port": 389,
|
||||||
|
"admin_dn": "cn=admin,dc=example,dc=com",
|
||||||
|
"admin_password": "admin123",
|
||||||
|
"base_dn": "dc=example,dc=com",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/ldap-config/test",
|
||||||
|
json={"test_user": "testuser", "test_password": "testpass"},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
# Mock mode should return success
|
||||||
|
assert data["success"] is True
|
||||||
|
assert "Mock" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ldap_non_admin_forbidden(client: AsyncClient, developer_token: str):
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/v1/ldap-config",
|
||||||
|
headers=auth_header(developer_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -13,8 +13,7 @@ import DataPermissionPage from '@/pages/DataPermissionPage';
|
|||||||
import AuditRulesPage from '@/pages/AuditRulesPage';
|
import AuditRulesPage from '@/pages/AuditRulesPage';
|
||||||
import WorkflowPage from '@/pages/WorkflowPage';
|
import WorkflowPage from '@/pages/WorkflowPage';
|
||||||
import AutoTaskPage from '@/pages/AutoTaskPage';
|
import AutoTaskPage from '@/pages/AutoTaskPage';
|
||||||
import AnnouncementPage from '@/pages/AnnouncementPage';
|
import SettingsPage from '@/pages/SettingsPage';
|
||||||
import ConfigPlatformPage from '@/pages/ConfigPlatformPage';
|
|
||||||
import AuditLogsPage from '@/pages/AuditLogsPage';
|
import AuditLogsPage from '@/pages/AuditLogsPage';
|
||||||
|
|
||||||
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
|
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
|
||||||
@@ -64,8 +63,7 @@ function AppRoutes() {
|
|||||||
<Route path="audit-logs" element={<AuditLogsPage />} />
|
<Route path="audit-logs" element={<AuditLogsPage />} />
|
||||||
<Route path="workflow" element={<WorkflowPage />} />
|
<Route path="workflow" element={<WorkflowPage />} />
|
||||||
<Route path="auto-task" element={<AutoTaskPage />} />
|
<Route path="auto-task" element={<AutoTaskPage />} />
|
||||||
<Route path="announcement" element={<AnnouncementPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="config-platform" element={<ConfigPlatformPage />} />
|
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { menuSections } from '@/lib/mock';
|
import { menuSections } from '@/lib/mock';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import type { UserRole } from '@/hooks/useAuth';
|
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
|
|
||||||
const iconMap: Record<string, React.ElementType> = {
|
const iconMap: Record<string, React.ElementType> = {
|
||||||
@@ -15,15 +14,8 @@ const iconMap: Record<string, React.ElementType> = {
|
|||||||
Settings, Clock, Megaphone,
|
Settings, Clock, Megaphone,
|
||||||
};
|
};
|
||||||
|
|
||||||
const roleBadgeStyle: Record<UserRole, string> = {
|
|
||||||
admin: 'bg-purple-500/15 text-purple-400',
|
|
||||||
dba: 'bg-blue-500/15 text-blue-400',
|
|
||||||
developer: 'bg-green-500/15 text-green-400',
|
|
||||||
guest: 'bg-gray-500/15 text-gray-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { user, logout, switchRole, visibleMenuItems } = useAuth();
|
const { user, logout, visibleMenuItems } = useAuth();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -34,18 +26,6 @@ export default function Layout() {
|
|||||||
return item?.label || '仪表盘';
|
return item?.label || '仪表盘';
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleRole = () => {
|
|
||||||
const roles: UserRole[] = ['admin', 'dba', 'developer', 'guest'];
|
|
||||||
const idx = roles.indexOf(user.role);
|
|
||||||
const nextRole = roles[(idx + 1) % roles.length];
|
|
||||||
switchRole(nextRole);
|
|
||||||
// 切换角色后如果当前页面无权限,自动跳仪表盘
|
|
||||||
const allowed = visibleMenuItems.find(m => m.id === currentPath);
|
|
||||||
if (!allowed && currentPath !== 'dashboard') {
|
|
||||||
navigate('/dashboard', { replace: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden">
|
<div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden">
|
||||||
{/* 侧边栏 */}
|
{/* 侧边栏 */}
|
||||||
@@ -102,21 +82,6 @@ export default function Layout() {
|
|||||||
|
|
||||||
{/* 底部 */}
|
{/* 底部 */}
|
||||||
<div className="p-4 border-t border-[var(--dg-border)]">
|
<div className="p-4 border-t border-[var(--dg-border)]">
|
||||||
{/* 角色切换演示 */}
|
|
||||||
<div className="mb-3 px-3 py-2 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">当前角色</span>
|
|
||||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${roleBadgeStyle[user.role]}`}>
|
|
||||||
{user.roleName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={toggleRole}
|
|
||||||
className="mt-2 w-full text-center text-[11px] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors py-1 rounded hover:bg-[var(--dg-bg-hover)]"
|
|
||||||
>
|
|
||||||
[演示] 切换角色
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer transition-colors">
|
<div className="flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer transition-colors">
|
||||||
<div className="w-9 h-9 rounded-full dg-gradient-br flex items-center justify-center text-white font-semibold text-sm flex-shrink-0">
|
<div className="w-9 h-9 rounded-full dg-gradient-br flex items-center justify-center text-white font-semibold text-sm flex-shrink-0">
|
||||||
{user.avatar}
|
{user.avatar}
|
||||||
@@ -145,8 +110,7 @@ export default function Layout() {
|
|||||||
<span className="text-[var(--dg-text-primary)] font-medium">{getPageTitle()}</span>
|
<span className="text-[var(--dg-text-primary)] font-medium">{getPageTitle()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* 角色标签 */}
|
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-blue-500/15 text-blue-400">
|
||||||
<span className={`px-2.5 py-1 rounded-md text-xs font-medium ${roleBadgeStyle[user.role]}`}>
|
|
||||||
{user.roleName}
|
{user.roleName}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -45,13 +45,10 @@ const routePermissionMap: Record<string, UserRole[]> = {
|
|||||||
roles: ['admin'],
|
roles: ['admin'],
|
||||||
users: ['admin'],
|
users: ['admin'],
|
||||||
'data-permission': ['admin', 'dba'],
|
'data-permission': ['admin', 'dba'],
|
||||||
'audit-rules': ['admin', 'dba'],
|
|
||||||
'audit-logs': ['admin', 'dba'],
|
'audit-logs': ['admin', 'dba'],
|
||||||
workflow: ['admin', 'dba'],
|
workflow: ['admin', 'dba'],
|
||||||
'auto-task': ['admin'],
|
'auto-task': ['admin'],
|
||||||
announcement: ['admin'],
|
settings: ['admin'],
|
||||||
'config-platform': ['admin'],
|
|
||||||
notifications: ['admin'],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actionPermissionMap: Record<string, UserRole[]> = {
|
export const actionPermissionMap: Record<string, UserRole[]> = {
|
||||||
|
|||||||
@@ -31,12 +31,10 @@ export const menuItems = [
|
|||||||
{ id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] },
|
{ id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] },
|
||||||
{ id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] },
|
{ id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] },
|
||||||
{ id: 'data-permission', label: '数据权限', icon: 'Lock', section: 'permission', roles: ['admin', 'dba'] },
|
{ id: 'data-permission', label: '数据权限', icon: 'Lock', section: 'permission', roles: ['admin', 'dba'] },
|
||||||
{ id: 'audit-rules', label: '审核规则', icon: 'Shield', section: 'system', roles: ['admin', 'dba'] },
|
|
||||||
{ id: 'audit-logs', label: '审计日志', icon: 'FileText', section: 'system', roles: ['admin', 'dba'] },
|
{ id: 'audit-logs', label: '审计日志', icon: 'FileText', section: 'system', roles: ['admin', 'dba'] },
|
||||||
{ id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'system', roles: ['admin', 'dba'] },
|
{ id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'system', roles: ['admin', 'dba'] },
|
||||||
{ id: 'auto-task', label: '自动任务', icon: 'Clock', section: 'system', roles: ['admin'] },
|
{ id: 'auto-task', label: '自动任务', icon: 'Clock', section: 'system', roles: ['admin'] },
|
||||||
{ id: 'announcement', label: '公告管理', icon: 'Megaphone', section: 'system', roles: ['admin'] },
|
{ id: 'settings', label: '系统设置', icon: 'Settings', section: 'config', roles: ['admin'] },
|
||||||
{ id: 'config-platform', label: '配置平台', icon: 'Settings', section: 'config', roles: ['admin'] },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const currentUser = {
|
export const currentUser = {
|
||||||
|
|||||||
@@ -1,350 +1,344 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import request from '@/api/request';
|
||||||
Megaphone, Plus, Edit2, Trash2, Pin, PinOff, Eye, Clock,
|
import { Megaphone, Plus, Edit2, Trash2, Eye, Clock, CheckCircle, X, Save } from 'lucide-react';
|
||||||
CheckCircle, X, Save, AlertTriangle, Send, Calendar
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
|
|
||||||
type AnnouncementPriority = 'urgent' | 'high' | 'normal' | 'low';
|
/* ── types ── */
|
||||||
|
interface Announcement {
|
||||||
type Announcement = {
|
id: number;
|
||||||
id: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
priority: AnnouncementPriority;
|
is_published: boolean;
|
||||||
pinned: boolean;
|
published_at: string | null;
|
||||||
enabled: boolean;
|
created_at: string;
|
||||||
author: string;
|
}
|
||||||
publishTime: string;
|
|
||||||
expireTime: string;
|
|
||||||
readCount: number;
|
|
||||||
targetRoles: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const priorityLabels: Record<AnnouncementPriority, string> = {
|
/* ── simple markdown → html ── */
|
||||||
urgent: '紧急',
|
function simpleMarkdown(md: string): string {
|
||||||
high: '重要',
|
let html = md
|
||||||
normal: '普通',
|
.replace(/&/g, '&')
|
||||||
low: '低',
|
.replace(/</g, '<')
|
||||||
};
|
.replace(/>/g, '>');
|
||||||
|
// bold **text** or __text__
|
||||||
const priorityColors: Record<AnnouncementPriority, string> = {
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
urgent: 'bg-red-500/15 text-red-400 border-red-500/30',
|
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
||||||
high: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
|
// italic *text* or _text_
|
||||||
normal: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||||
low: 'bg-gray-500/15 text-gray-400 border-gray-500/30',
|
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||||
};
|
// `code`
|
||||||
|
html = html.replace(/`(.+?)`/g, '<code class="px-1 py-0.5 rounded bg-[var(--dg-bg-tertiary)] text-sm">$1</code>');
|
||||||
const roleOptions = [
|
// headings
|
||||||
{ id: 'admin', label: '超级管理员' },
|
html = html.replace(/^### (.+)$/gm, '<h4 class="text-base font-semibold mt-3 mb-1">$1</h4>');
|
||||||
{ id: 'dba', label: '数据库管理员' },
|
html = html.replace(/^## (.+)$/gm, '<h3 class="text-lg font-semibold mt-3 mb-1">$1</h3>');
|
||||||
{ id: 'developer', label: '开发人员' },
|
html = html.replace(/^# (.+)$/gm, '<h2 class="text-xl font-bold mt-3 mb-1">$1</h2>');
|
||||||
{ id: 'guest', label: '访客' },
|
// unordered list items
|
||||||
];
|
html = html.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>');
|
||||||
|
html = html.replace(/^\* (.+)$/gm, '<li class="ml-4 list-disc">$1</li>');
|
||||||
const initialAnnouncements: Announcement[] = [
|
// numbered list
|
||||||
{
|
html = html.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 list-decimal">$2</li>');
|
||||||
id: 'ann-1', title: '【系统维护】本周日凌晨 2:00-4:00 数据库升级维护',
|
// horizontal rule
|
||||||
content: '计划于本周日(2024年1月21日)凌晨 2:00-4:00 进行数据库版本升级,期间以下服务将不可用:\n1. 数据变更工单提交和审批\n2. 在线 SQL 查询\n3. 数据导出功能\n\n请提前安排工作,避免在维护时段提交重要工单。如有紧急情况,请联系 DBA 团队。',
|
html = html.replace(/^---$/gm, '<hr class="border-[var(--dg-border)] my-3" />');
|
||||||
priority: 'urgent', pinned: true, enabled: true, author: 'admin',
|
// line breaks
|
||||||
publishTime: '2024-01-15 10:00:00', expireTime: '2024-01-22 00:00:00',
|
html = html.replace(/\n/g, '<br />');
|
||||||
readCount: 156, targetRoles: ['admin', 'dba', 'developer', 'guest'],
|
return html;
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: 'ann-2', title: '【安全提醒】禁止使用 SELECT * 查询生产数据库',
|
|
||||||
content: '为加强数据安全,即日起严禁使用 SELECT * 查询生产环境数据库。所有查询必须明确指定所需字段名。\n\n审核规则已更新(规则编号 R002),违规查询将被自动拦截并记录审计日志。',
|
|
||||||
priority: 'high', pinned: true, enabled: true, author: 'admin',
|
|
||||||
publishTime: '2024-01-14 09:00:00', expireTime: '2024-02-14 00:00:00',
|
|
||||||
readCount: 234, targetRoles: ['admin', 'dba', 'developer'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ann-3', title: '【新功能】SQL 变更自动回滚功能已上线',
|
|
||||||
content: 'SQL 变更工单现已支持自动回滚功能。对于 UPDATE 和 DELETE 操作,系统会自动:\n1. 基于 Binlog ROW 格式生成逆向回滚语句\n2. 执行前备份受影响数据\n3. 提供一键回滚入口(执行后 72 小时内有效)\n\n使用要求:\n- MySQL 必须开启 Binlog ROW 格式\n- 操作表必须有主键\n- 单次操作影响行数不超过 10,000 行',
|
|
||||||
priority: 'normal', pinned: false, enabled: true, author: 'zhangsan',
|
|
||||||
publishTime: '2024-01-13 14:00:00', expireTime: '2024-04-13 00:00:00',
|
|
||||||
readCount: 89, targetRoles: ['admin', 'dba', 'developer'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ann-4', title: '【培训通知】SQL 审核规范培训 - 1月25日',
|
|
||||||
content: '时间:2024年1月25日 14:00-16:00\n地点:会议室 A\n讲师:DBA 团队负责人\n\n培训内容:\n1. SQL 变更工单提交流程\n2. 审核规则详解\n3. 回滚操作最佳实践\n4. 常见问题排查\n\n请相关人员准时参加。',
|
|
||||||
priority: 'normal', pinned: false, enabled: true, author: 'lisi',
|
|
||||||
publishTime: '2024-01-12 10:00:00', expireTime: '2024-01-26 00:00:00',
|
|
||||||
readCount: 67, targetRoles: ['developer'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ann-5', title: '【版本更新】SQL-Platform v2.5.0 更新日志',
|
|
||||||
content: '本次更新内容:\n\n新增功能:\n- 支持多数据源统一管理\n- 查询审计新增敏感字段自动脱敏\n- 工单批量审批功能\n\n优化改进:\n- SQL 编辑器性能优化\n- 审批流程可视化升级\n- 通知渠道新增 Telegram 支持\n\nBug 修复:\n- 修复大结果集导出内存溢出问题\n- 修复 Redis 集群连接超时问题',
|
|
||||||
priority: 'low', pinned: false, enabled: false, author: 'admin',
|
|
||||||
publishTime: '2024-01-10 16:00:00', expireTime: '2024-02-10 00:00:00',
|
|
||||||
readCount: 45, targetRoles: ['admin', 'dba', 'developer', 'guest'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AnnouncementPage() {
|
export default function AnnouncementPage() {
|
||||||
const [announcements, setAnnouncements] = useState<Announcement[]>(initialAnnouncements);
|
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [editingAnn, setEditingAnn] = useState<Announcement | null>(null);
|
|
||||||
const [previewAnn, setPreviewAnn] = useState<Announcement | null>(null);
|
|
||||||
|
|
||||||
// 表单
|
/* modal state */
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [formTitle, setFormTitle] = useState('');
|
const [formTitle, setFormTitle] = useState('');
|
||||||
const [formContent, setFormContent] = useState('');
|
const [formContent, setFormContent] = useState('');
|
||||||
const [formPriority, setFormPriority] = useState<AnnouncementPriority>('normal');
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [formPinned, setFormPinned] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [formTargetRoles, setFormTargetRoles] = useState<string[]>(['admin', 'dba', 'developer', 'guest']);
|
|
||||||
const [formExpireTime, setFormExpireTime] = useState('');
|
|
||||||
|
|
||||||
const resetForm = () => {
|
/* view preview */
|
||||||
|
const [viewAnn, setViewAnn] = useState<Announcement | null>(null);
|
||||||
|
|
||||||
|
const fetchList = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await request.get('/announcements');
|
||||||
|
setAnnouncements(Array.isArray(res.data) ? res.data : Array.isArray(res) ? res : []);
|
||||||
|
} catch {
|
||||||
|
setAnnouncements([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchList();
|
||||||
|
}, [fetchList]);
|
||||||
|
|
||||||
|
/* ── helpers ── */
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingId(null);
|
||||||
setFormTitle('');
|
setFormTitle('');
|
||||||
setFormContent('');
|
setFormContent('');
|
||||||
setFormPriority('normal');
|
setShowPreview(false);
|
||||||
setFormPinned(false);
|
setShowModal(true);
|
||||||
setFormTargetRoles(['admin', 'dba', 'developer', 'guest']);
|
|
||||||
setFormExpireTime('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
resetForm();
|
|
||||||
setEditingAnn(null);
|
|
||||||
setShowCreate(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (ann: Announcement) => {
|
const openEdit = (ann: Announcement) => {
|
||||||
|
setEditingId(ann.id);
|
||||||
setFormTitle(ann.title);
|
setFormTitle(ann.title);
|
||||||
setFormContent(ann.content);
|
setFormContent(ann.content);
|
||||||
setFormPriority(ann.priority);
|
setShowPreview(false);
|
||||||
setFormPinned(ann.pinned);
|
setShowModal(true);
|
||||||
setFormTargetRoles([...ann.targetRoles]);
|
|
||||||
setFormExpireTime(ann.expireTime);
|
|
||||||
setEditingAnn(ann);
|
|
||||||
setShowCreate(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleRole = (role: string) => {
|
const handleSave = async () => {
|
||||||
setFormTargetRoles(prev =>
|
|
||||||
prev.includes(role) ? prev.filter(r => r !== role) : [...prev, role]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveAnnouncement = () => {
|
|
||||||
if (!formTitle.trim() || !formContent.trim()) return;
|
if (!formTitle.trim() || !formContent.trim()) return;
|
||||||
const now = '2024-01-15 12:00:00';
|
try {
|
||||||
if (editingAnn) {
|
setSaving(true);
|
||||||
setAnnouncements(prev => prev.map(a => a.id === editingAnn.id ? {
|
if (editingId) {
|
||||||
...a,
|
await request.put(`/announcements/${editingId}`, { title: formTitle, content: formContent });
|
||||||
title: formTitle,
|
|
||||||
content: formContent,
|
|
||||||
priority: formPriority,
|
|
||||||
pinned: formPinned,
|
|
||||||
targetRoles: [...formTargetRoles],
|
|
||||||
expireTime: formExpireTime || a.expireTime,
|
|
||||||
} : a));
|
|
||||||
} else {
|
} else {
|
||||||
const newAnn: Announcement = {
|
await request.post('/announcements', { title: formTitle, content: formContent });
|
||||||
id: `ann-${Date.now()}`,
|
}
|
||||||
title: formTitle,
|
setShowModal(false);
|
||||||
content: formContent,
|
fetchList();
|
||||||
priority: formPriority,
|
} catch (err: any) {
|
||||||
pinned: formPinned,
|
alert(err.response?.data?.detail || '保存失败');
|
||||||
enabled: true,
|
} finally {
|
||||||
author: 'admin',
|
setSaving(false);
|
||||||
publishTime: now,
|
|
||||||
expireTime: formExpireTime || '2024-12-31 00:00:00',
|
|
||||||
readCount: 0,
|
|
||||||
targetRoles: [...formTargetRoles],
|
|
||||||
};
|
|
||||||
setAnnouncements(prev => [newAnn, ...prev]);
|
|
||||||
}
|
}
|
||||||
setShowCreate(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePin = (id: string) => {
|
const handleTogglePublish = async (ann: Announcement) => {
|
||||||
setAnnouncements(prev => prev.map(a => a.id === id ? { ...a, pinned: !a.pinned } : a));
|
try {
|
||||||
|
await request.put(`/announcements/${ann.id}`, { is_published: !ann.is_published });
|
||||||
|
fetchList();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleEnable = (id: string) => {
|
const handleDelete = async (id: number) => {
|
||||||
setAnnouncements(prev => prev.map(a => a.id === id ? { ...a, enabled: !a.enabled } : a));
|
if (!confirm('确定删除此公告?')) return;
|
||||||
|
try {
|
||||||
|
await request.delete(`/announcements/${id}`);
|
||||||
|
fetchList();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteAnnouncement = (id: string) => {
|
/* ── render ── */
|
||||||
setAnnouncements(prev => prev.filter(a => a.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortedAnnouncements = [...announcements].sort((a, b) => {
|
|
||||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
|
||||||
return new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">公告管理</h1>
|
<h1 className="text-xl font-semibold text-[var(--dg-text-primary)]">公告管理</h1>
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">发布和管理系统公告,支持按角色定向推送</p>
|
<p className="text-sm text-[var(--dg-text-muted)] mt-1">发布和管理系统公告,支持 Markdown 格式</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={openCreate} className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
<button
|
||||||
<Plus className="w-4 h-4" /> 发布公告
|
onClick={openCreate}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-[var(--dg-blue)] text-white hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> 创建公告
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{[
|
{[
|
||||||
{ label: '公告总数', value: announcements.length, icon: Megaphone, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
{ label: '公告总数', value: announcements.length, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||||
{ label: '已置顶', value: announcements.filter(a => a.pinned).length, icon: Pin, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
{ label: '已发布', value: announcements.filter(a => a.is_published).length, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||||
{ label: '生效中', value: announcements.filter(a => a.enabled).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
|
{ label: '草稿', value: announcements.filter(a => !a.is_published).length, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
|
||||||
{ label: '已失效', value: announcements.filter(a => !a.enabled).length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
|
|
||||||
].map((s, i) => (
|
].map((s, i) => (
|
||||||
<Card key={i} className="dg-card">
|
<div key={i} className="dg-card p-4 flex items-center gap-3">
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}>
|
||||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
|
<Megaphone className={`w-5 h-5 ${s.color}`} />
|
||||||
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
|
</div>
|
||||||
</CardContent>
|
<div>
|
||||||
</Card>
|
<div className="text-2xl font-bold text-[var(--dg-text-primary)]">{s.value}</div>
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">加载中…</div>
|
||||||
|
) : announcements.length === 0 ? (
|
||||||
|
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">暂无公告,点击右上角创建</div>
|
||||||
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{sortedAnnouncements.map(ann => (
|
{announcements.map(ann => (
|
||||||
<Card key={ann.id} className={`dg-card ${!ann.enabled ? 'opacity-50' : ''} ${ann.pinned ? 'border-l-2 border-l-purple-500' : ''}`}>
|
<div key={ann.id} className={`dg-card px-5 py-4 flex items-center gap-4 ${!ann.is_published ? 'opacity-60' : ''}`}>
|
||||||
<CardContent className="p-5">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<div className="flex-1">
|
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] truncate">{ann.title}</h3>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<span
|
||||||
{ann.pinned && <Pin className="w-4 h-4 text-purple-400" />}
|
className={`text-[11px] px-2 py-0.5 rounded-full flex-shrink-0 ${
|
||||||
<h3 className="font-semibold text-sm">{ann.title}</h3>
|
ann.is_published
|
||||||
<span className={`text-[11px] px-2 py-0.5 rounded-full border ${priorityColors[ann.priority]}`}>
|
? 'bg-green-500/15 text-green-400'
|
||||||
{priorityLabels[ann.priority]}
|
: 'bg-yellow-500/15 text-yellow-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ann.is_published ? '已发布' : '草稿'}
|
||||||
</span>
|
</span>
|
||||||
{!ann.enabled && <span className="text-[11px] px-2 py-0.5 rounded-full bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">已失效</span>}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[var(--dg-text-secondary)] line-clamp-2 mb-3">{ann.content.replace(/\n/g, ' ')}</p>
|
<p className="text-xs text-[var(--dg-text-muted)] truncate">{ann.content.replace(/\n/g, ' ')}</p>
|
||||||
<div className="flex items-center gap-4 text-xs text-[var(--dg-text-muted)]">
|
<div className="flex items-center gap-3 mt-2 text-xs text-[var(--dg-text-muted)]">
|
||||||
<span className="flex items-center gap-1"><Send className="w-3 h-3" /> {ann.author}</span>
|
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {ann.created_at}</span>
|
||||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {ann.publishTime}</span>
|
{ann.published_at && (
|
||||||
<span className="flex items-center gap-1"><Calendar className="w-3 h-3" /> 到期: {ann.expireTime}</span>
|
<span className="flex items-center gap-1"><CheckCircle className="w-3 h-3" /> 发布于 {ann.published_at}</span>
|
||||||
<span className="flex items-center gap-1"><Eye className="w-3 h-3" /> {ann.readCount} 次阅读</span>
|
)}
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span>可见范围:</span>
|
|
||||||
{ann.targetRoles.map(r => (
|
|
||||||
<span key={r} className="text-[10px] px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded">
|
|
||||||
{r === 'admin' ? '管理员' : r === 'dba' ? 'DBA' : r === 'developer' ? '开发' : '访客'}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
<div className="flex items-center gap-1 ml-4">
|
<button
|
||||||
<button onClick={() => togglePin(ann.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-purple-500/15 hover:text-purple-400 transition-colors" title={ann.pinned ? '取消置顶' : '置顶'}>
|
onClick={() => setViewAnn(ann)}
|
||||||
{ann.pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
</button>
|
title="预览"
|
||||||
<button onClick={() => setPreviewAnn(ann)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="预览">
|
>
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => openEdit(ann)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="编辑">
|
<button
|
||||||
|
onClick={() => openEdit(ann)}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
<Edit2 className="w-4 h-4" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => toggleEnable(ann.id)} className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" title={ann.enabled ? '失效' : '生效'}>
|
<button
|
||||||
{ann.enabled ? <CheckCircle className="w-4 h-4 text-green-400" /> : <AlertTriangle className="w-4 h-4 text-yellow-400" />}
|
onClick={() => handleTogglePublish(ann)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
ann.is_published
|
||||||
|
? 'bg-yellow-500/15 text-yellow-400 hover:bg-yellow-500/25'
|
||||||
|
: 'bg-green-500/15 text-green-400 hover:bg-green-500/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ann.is_published ? '取消发布' : '发布'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => deleteAnnouncement(ann.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/15 hover:text-red-400 transition-colors" title="删除">
|
<button
|
||||||
|
onClick={() => handleDelete(ann.id)}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/15 hover:text-red-400 transition-colors"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 预览弹窗 */}
|
{/* ── View Preview Modal ── */}
|
||||||
{previewAnn && (
|
{viewAnn && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setPreviewAnn(null)}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setViewAnn(null)}>
|
||||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4" onClick={e => e.stopPropagation()}>
|
<div
|
||||||
|
className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[85vh] overflow-y-auto"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-lg font-semibold">{previewAnn.title}</h3>
|
<h3 className="text-lg font-semibold text-[var(--dg-text-primary)]">{viewAnn.title}</h3>
|
||||||
<span className={`text-[11px] px-2 py-0.5 rounded-full border ${priorityColors[previewAnn.priority]}`}>
|
<span
|
||||||
{priorityLabels[previewAnn.priority]}
|
className={`text-[11px] px-2 py-0.5 rounded-full ${
|
||||||
|
viewAnn.is_published ? 'bg-green-500/15 text-green-400' : 'bg-yellow-500/15 text-yellow-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{viewAnn.is_published ? '已发布' : '草稿'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setPreviewAnn(null)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
<button onClick={() => setViewAnn(null)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)]">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] flex items-center gap-3 mb-4 pb-3 border-b border-[var(--dg-border)]">
|
<div className="text-xs text-[var(--dg-text-muted)] flex items-center gap-3 mb-4 pb-3 border-b border-[var(--dg-border)]">
|
||||||
<span className="flex items-center gap-1"><Send className="w-3 h-3" /> {previewAnn.author}</span>
|
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {viewAnn.created_at}</span>
|
||||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {previewAnn.publishTime}</span>
|
|
||||||
<span className="flex items-center gap-1"><Eye className="w-3 h-3" /> {previewAnn.readCount} 次阅读</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--dg-text-secondary)] whitespace-pre-line leading-relaxed max-h-96 overflow-y-auto">
|
|
||||||
{previewAnn.content}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end mt-4 pt-4 border-t border-[var(--dg-border)]">
|
|
||||||
<button onClick={() => setPreviewAnn(null)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">关闭</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-sm text-[var(--dg-text-secondary)] leading-relaxed prose-sm"
|
||||||
|
dangerouslySetInnerHTML={{ __html: simpleMarkdown(viewAnn.content) }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 创建/编辑弹窗 */}
|
{/* ── Create / Edit Modal ── */}
|
||||||
{showCreate && (
|
{showModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowModal(false)}>
|
||||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
<div
|
||||||
|
className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h3 className="text-xl font-semibold">{editingAnn ? '编辑公告' : '发布公告'}</h3>
|
<h3 className="text-xl font-semibold text-[var(--dg-text-primary)]">{editingId ? '编辑公告' : '创建公告'}</h3>
|
||||||
<button onClick={() => setShowCreate(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
<button onClick={() => setShowModal(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)]">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">公告标题 <span className="text-red-400">*</span></label>
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">
|
||||||
<input type="text" value={formTitle} onChange={e => setFormTitle(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" placeholder="输入公告标题" />
|
标题 <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formTitle}
|
||||||
|
onChange={e => setFormTitle(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="输入公告标题"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">公告内容 <span className="text-red-400">*</span></label>
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<textarea value={formContent} onChange={e => setFormContent(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 resize-none" rows={8} placeholder="输入公告内容,支持多行文本..." />
|
<label className="text-xs text-[var(--dg-text-muted)]">
|
||||||
</div>
|
内容 <span className="text-red-400">*</span> <span className="text-[var(--dg-text-muted)]">(支持 Markdown)</span>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
</label>
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">优先级</label>
|
|
||||||
<select value={formPriority} onChange={e => setFormPriority(e.target.value as AnnouncementPriority)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500">
|
|
||||||
{Object.entries(priorityLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">到期时间</label>
|
|
||||||
<input type="datetime-local" value={formExpireTime} onChange={e => setFormExpireTime(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch checked={formPinned} onCheckedChange={setFormPinned} />
|
|
||||||
<span className="text-sm">置顶公告</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-2 block">可见角色</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{roleOptions.map(role => (
|
|
||||||
<button
|
<button
|
||||||
key={role.id}
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
onClick={() => toggleRole(role.id)}
|
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs border transition-colors ${
|
showPreview
|
||||||
formTargetRoles.includes(role.id)
|
? 'bg-[var(--dg-blue)] text-white'
|
||||||
? 'bg-blue-500/15 text-blue-400 border-blue-500/30'
|
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border-[var(--dg-border)]'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{role.label}
|
{showPreview ? '编辑' : '预览'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showPreview ? (
|
||||||
|
<div
|
||||||
|
className="w-full min-h-[200px] px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] leading-relaxed overflow-auto"
|
||||||
|
dangerouslySetInnerHTML={{ __html: simpleMarkdown(formContent) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
value={formContent}
|
||||||
|
onChange={e => setFormContent(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)] resize-none font-mono"
|
||||||
|
rows={12}
|
||||||
|
placeholder="输入公告内容,支持 Markdown 语法... **粗体** *斜体* `代码` ## 二级标题 - 列表项 1. 有序列表"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[var(--dg-border)]">
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[var(--dg-border)]">
|
||||||
<button onClick={() => setShowCreate(false)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
<button
|
||||||
<button onClick={saveAnnouncement} disabled={!formTitle.trim() || !formContent.trim()} className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed">
|
onClick={() => setShowModal(false)}
|
||||||
<Save className="w-4 h-4" /> {editingAnn ? '保存修改' : '发布公告'}
|
className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!formTitle.trim() || !formContent.trim() || saving}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" /> {saving ? '保存中…' : editingId ? '保存修改' : '创建'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import request from '../api/request';
|
import request from '../api/request';
|
||||||
|
|
||||||
interface AuditRule {
|
interface AuditRule {
|
||||||
@@ -11,278 +11,237 @@ interface AuditRule {
|
|||||||
pattern: string | null;
|
pattern: string | null;
|
||||||
is_enabled: boolean;
|
is_enabled: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
|
value: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RULE_TYPES = [
|
const DB_TABS = [
|
||||||
{ value: 'syntax', label: '语法检查', color: 'bg-blue-100 text-blue-700' },
|
{ key: 'mysql', label: 'MySQL', icon: '🐬' },
|
||||||
{ value: 'performance', label: '性能优化', color: 'bg-yellow-100 text-yellow-700' },
|
{ key: 'postgresql', label: 'PostgreSQL', icon: '🐘' },
|
||||||
{ value: 'safety', label: '安全检查', color: 'bg-red-100 text-red-700' },
|
{ key: 'redis', label: 'Redis', icon: '🔴' },
|
||||||
{ value: 'naming', label: '命名规范', color: 'bg-green-100 text-green-700' },
|
] as const;
|
||||||
];
|
|
||||||
|
|
||||||
const SEVERITY_MAP: Record<string, { label: string; color: string }> = {
|
type DbType = (typeof DB_TABS)[number]['key'];
|
||||||
error: { label: '错误', color: 'bg-red-500 text-white' },
|
|
||||||
warning: { label: '警告', color: 'bg-yellow-500 text-white' },
|
function isDDL(name: string): boolean {
|
||||||
info: { label: '提示', color: 'bg-blue-500 text-white' },
|
const ddlKeywords = [
|
||||||
};
|
'DDL', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME',
|
||||||
|
'TABLE', 'INDEX', 'KEY', 'COLUMN', 'PARTITION',
|
||||||
|
];
|
||||||
|
const upper = name.toUpperCase();
|
||||||
|
return ddlKeywords.some((kw) => upper.includes(kw));
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuditRulesPage() {
|
export default function AuditRulesPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<DbType>('mysql');
|
||||||
const [rules, setRules] = useState<AuditRule[]>([]);
|
const [rules, setRules] = useState<AuditRule[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(false);
|
||||||
const [filter, setFilter] = useState({ rule_type: '', severity: '', is_enabled: '' });
|
const [seeding, setSeeding] = useState(false);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [editingValue, setEditingValue] = useState<Record<number, string>>({});
|
||||||
const [stats, setStats] = useState<any>(null);
|
const [saving, setSaving] = useState<Record<number, boolean>>({});
|
||||||
const [form, setForm] = useState({
|
|
||||||
name: '', description: '', rule_type: 'safety', severity: 'warning',
|
|
||||||
db_type: 'mysql', pattern: '', is_enabled: true, message: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => { fetchRules(); fetchStats(); }, []);
|
const fetchRules = useCallback(async (dbType: DbType) => {
|
||||||
|
|
||||||
async function fetchRules() {
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const params = new URLSearchParams();
|
const res = await request.get('/audit-rules', {
|
||||||
if (filter.rule_type) params.set('rule_type', filter.rule_type);
|
params: { db_type: dbType },
|
||||||
if (filter.severity) params.set('severity', filter.severity);
|
});
|
||||||
if (filter.is_enabled !== '') params.set('is_enabled', filter.is_enabled);
|
setRules(Array.isArray(res.data) ? res.data : Array.isArray(res) ? res : []);
|
||||||
const res = await request.get('/audit-rules', { params });
|
} catch {
|
||||||
setRules(Array.isArray(res.data) ? res.data : []);
|
setRules([]);
|
||||||
} catch { setRules([]); } finally { setLoading(false); }
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function fetchStats() {
|
useEffect(() => {
|
||||||
try {
|
fetchRules(activeTab);
|
||||||
const res = await request.get('/audit-rules/stats/summary');
|
}, [activeTab, fetchRules]);
|
||||||
setStats(res.data || null);
|
|
||||||
} catch { setStats(null); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate() {
|
const handleSeed = async () => {
|
||||||
try {
|
try {
|
||||||
await request.post('/audit-rules', form);
|
setSeeding(true);
|
||||||
setShowCreate(false);
|
await request.post(`/audit-rules/seed/${activeTab}`);
|
||||||
setForm({ name: '', description: '', rule_type: 'safety', severity: 'warning', db_type: 'mysql', pattern: '', is_enabled: true, message: '' });
|
fetchRules(activeTab);
|
||||||
fetchRules();
|
|
||||||
fetchStats();
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.response?.data?.detail || '创建失败');
|
alert(err.response?.data?.detail || '初始化失败');
|
||||||
}
|
} finally {
|
||||||
|
setSeeding(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
async function handleToggle(rule: AuditRule) {
|
const handleToggle = async (rule: AuditRule) => {
|
||||||
try {
|
try {
|
||||||
await request.post('/audit-rules/' + rule.id + '/toggle');
|
await request.post(`/audit-rules/${rule.id}/toggle`);
|
||||||
fetchRules();
|
setRules((prev) =>
|
||||||
fetchStats();
|
prev.map((r) => (r.id === rule.id ? { ...r, is_enabled: !r.is_enabled } : r)),
|
||||||
} catch { /* */ }
|
);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
const handleSaveValue = async (rule: AuditRule) => {
|
||||||
if (!confirm('确认删除此规则?')) return;
|
const newVal = editingValue[rule.id];
|
||||||
|
if (newVal === undefined || newVal === rule.value) return;
|
||||||
try {
|
try {
|
||||||
await request.delete('/audit-rules/' + id);
|
setSaving((prev) => ({ ...prev, [rule.id]: true }));
|
||||||
fetchRules();
|
await request.put(`/audit-rules/${rule.id}`, { value: newVal });
|
||||||
fetchStats();
|
setRules((prev) =>
|
||||||
} catch { /* */ }
|
prev.map((r) => (r.id === rule.id ? { ...r, value: newVal } : r)),
|
||||||
|
);
|
||||||
|
setEditingValue((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[rule.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setSaving((prev) => ({ ...prev, [rule.id]: false }));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getTypeInfo = (type: string) => RULE_TYPES.find(t => t.value === type) || RULE_TYPES[0];
|
// Group rules into DDL / DML
|
||||||
const getSeverityInfo = (sev: string) => SEVERITY_MAP[sev] || SEVERITY_MAP.info;
|
const ddlRules = rules.filter((r) => isDDL(r.name));
|
||||||
|
const dmlRules = rules.filter((r) => !isDDL(r.name));
|
||||||
|
|
||||||
|
const renderRuleGroup = (title: string, icon: string, groupRules: AuditRule[]) => {
|
||||||
|
if (groupRules.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)] flex items-center gap-2 px-1">
|
||||||
|
<span>{icon}</span>
|
||||||
|
<span>{title}</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">({groupRules.length})</span>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{groupRules.map((rule) => {
|
||||||
|
const hasValue = rule.value !== null && rule.value !== undefined && rule.value !== '';
|
||||||
|
const currentEditVal = editingValue[rule.id];
|
||||||
|
const displayValue = currentEditVal !== undefined ? currentEditVal : rule.value ?? '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div
|
||||||
|
key={rule.id}
|
||||||
|
className={`dg-card px-4 py-3 flex items-center gap-4 transition-opacity ${
|
||||||
|
!rule.is_enabled ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(rule)}
|
||||||
|
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ${
|
||||||
|
rule.is_enabled
|
||||||
|
? 'bg-[var(--dg-green)]'
|
||||||
|
: 'bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
||||||
|
rule.is_enabled ? 'translate-x-4.5' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Name + Message */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[var(--dg-text-primary)] leading-tight">
|
||||||
|
{rule.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5 truncate">
|
||||||
|
{rule.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value input (if applicable) */}
|
||||||
|
{hasValue && (
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">=</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayValue}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingValue((prev) => ({ ...prev, [rule.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
onBlur={() => handleSaveValue(rule)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSaveValue(rule);
|
||||||
|
}}
|
||||||
|
disabled={saving[rule.id]}
|
||||||
|
className="w-20 px-2 py-1 text-sm font-mono text-center rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] focus:border-[var(--dg-blue)] focus:outline-none focus:ring-1 focus:ring-[var(--dg-input-focus-ring)] disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
{saving[rule.id] && (
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">保存中…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-gray-900">审核规则</h1>
|
<h1 className="text-xl font-semibold text-[var(--dg-text-primary)]">审核规则</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">管理 SQL 审核引擎的检查规则</p>
|
<p className="text-sm text-[var(--dg-text-muted)] mt-1">按数据库类型管理 SQL 审核规则模版</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={handleSeed}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700"
|
disabled={seeding}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-[var(--dg-blue)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||||
>
|
>
|
||||||
新建规则
|
{seeding ? '初始化中…' : '初始化规则'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* DB Type Tabs */}
|
||||||
{stats && (
|
<div className="dg-card p-1.5 flex gap-1">
|
||||||
<div className="grid grid-cols-4 gap-4">
|
{DB_TABS.map((tab) => (
|
||||||
{[
|
<button
|
||||||
{ label: '总规则数', value: stats.total, color: 'text-gray-900' },
|
key={tab.key}
|
||||||
{ label: '已启用', value: stats.enabled, color: 'text-green-600' },
|
onClick={() => setActiveTab(tab.key)}
|
||||||
{ label: '已禁用', value: stats.disabled, color: 'text-gray-400' },
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
{ label: '类型', value: Object.keys(stats.by_type || {}).length, color: 'text-blue-600' },
|
activeTab === tab.key
|
||||||
].map(s => (
|
? 'bg-[var(--dg-blue)] text-white'
|
||||||
<div key={s.label} className="bg-white rounded-lg p-4 shadow-sm">
|
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||||
<p className="text-sm text-gray-500">{s.label}</p>
|
}`}
|
||||||
<p className={'text-2xl font-bold ' + s.color}>{s.value}</p>
|
>
|
||||||
</div>
|
<span>{tab.icon}</span>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Rule List */}
|
||||||
<div className="flex gap-3">
|
|
||||||
<select
|
|
||||||
value={filter.rule_type}
|
|
||||||
onChange={e => setFilter({ ...filter, rule_type: e.target.value })}
|
|
||||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
|
||||||
>
|
|
||||||
<option value="">全部类型</option>
|
|
||||||
{RULE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={filter.severity}
|
|
||||||
onChange={e => setFilter({ ...filter, severity: e.target.value })}
|
|
||||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
|
||||||
>
|
|
||||||
<option value="">全部级别</option>
|
|
||||||
<option value="error">错误</option>
|
|
||||||
<option value="warning">警告</option>
|
|
||||||
<option value="info">提示</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={filter.is_enabled}
|
|
||||||
onChange={e => setFilter({ ...filter, is_enabled: e.target.value })}
|
|
||||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
|
||||||
>
|
|
||||||
<option value="">全部状态</option>
|
|
||||||
<option value="true">已启用</option>
|
|
||||||
<option value="false">已禁用</option>
|
|
||||||
</select>
|
|
||||||
<button onClick={fetchRules} className="px-3 py-2 bg-gray-100 rounded-lg text-sm hover:bg-gray-200">
|
|
||||||
刷新
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create Modal */}
|
|
||||||
{showCreate && (
|
|
||||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
|
||||||
<h2 className="text-lg font-semibold mb-4">新建审核规则</h2>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<input
|
|
||||||
placeholder="规则名称"
|
|
||||||
value={form.name}
|
|
||||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
placeholder="描述(可选)"
|
|
||||||
value={form.description}
|
|
||||||
onChange={e => setForm({ ...form, description: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<select
|
|
||||||
value={form.rule_type}
|
|
||||||
onChange={e => setForm({ ...form, rule_type: e.target.value })}
|
|
||||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
|
||||||
>
|
|
||||||
{RULE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={form.severity}
|
|
||||||
onChange={e => setForm({ ...form, severity: e.target.value })}
|
|
||||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
|
||||||
>
|
|
||||||
<option value="error">错误</option>
|
|
||||||
<option value="warning">警告</option>
|
|
||||||
<option value="info">提示</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
placeholder="匹配模式(正则表达式,可选)"
|
|
||||||
value={form.pattern}
|
|
||||||
onChange={e => setForm({ ...form, pattern: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
placeholder="提示消息"
|
|
||||||
value={form.message}
|
|
||||||
onChange={e => setForm({ ...form, message: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 mt-5">
|
|
||||||
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button onClick={handleCreate} className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">
|
|
||||||
创建
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Rules Table */}
|
|
||||||
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-8 text-center text-gray-400">加载中...</div>
|
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">加载中…</div>
|
||||||
) : rules.length === 0 ? (
|
) : rules.length === 0 ? (
|
||||||
<div className="p-8 text-center text-gray-400">暂无规则,点击"新建规则"添加</div>
|
<div className="dg-card p-8 text-center space-y-3">
|
||||||
|
<p className="text-[var(--dg-text-muted)] text-sm">
|
||||||
|
当前没有 {DB_TABS.find((t) => t.key === activeTab)?.label} 的审核规则
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleSeed}
|
||||||
|
disabled={seeding}
|
||||||
|
className="px-4 py-2 text-sm rounded-lg border border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
点击初始化默认规则
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full">
|
<div className="space-y-6">
|
||||||
<thead>
|
{renderRuleGroup('DDL 规则', '🏗️', ddlRules)}
|
||||||
<tr className="bg-gray-50 text-left text-xs text-gray-500">
|
{renderRuleGroup('DML 规则', '📝', dmlRules)}
|
||||||
<th className="px-4 py-3">规则名称</th>
|
|
||||||
<th className="px-4 py-3">类型</th>
|
|
||||||
<th className="px-4 py-3">级别</th>
|
|
||||||
<th className="px-4 py-3">提示消息</th>
|
|
||||||
<th className="px-4 py-3">状态</th>
|
|
||||||
<th className="px-4 py-3 text-right">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{rules.map(rule => {
|
|
||||||
const typeInfo = getTypeInfo(rule.rule_type);
|
|
||||||
const sevInfo = getSeverityInfo(rule.severity);
|
|
||||||
return (
|
|
||||||
<tr key={rule.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-sm text-gray-900">{rule.name}</p>
|
|
||||||
{rule.description && <p className="text-xs text-gray-400 mt-0.5">{rule.description}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className={'px-2 py-0.5 rounded text-xs ' + typeInfo.color}>{typeInfo.label}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className={'px-2 py-0.5 rounded text-xs ' + sevInfo.color}>{sevInfo.label}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600 max-w-xs truncate">{rule.message}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle(rule)}
|
|
||||||
className={'px-2 py-1 rounded text-xs ' + (
|
|
||||||
rule.is_enabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'
|
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
{rule.is_enabled ? '启用' : '禁用'}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(rule.id)}
|
|
||||||
className="text-xs text-red-500 hover:text-red-700"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import request from '@/api/request';
|
||||||
|
import { Save, Shield, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
/* ── types ── */
|
||||||
|
interface LdapConfig {
|
||||||
|
ldap_host: string;
|
||||||
|
ldap_port: number;
|
||||||
|
use_ldaps: boolean;
|
||||||
|
admin_dn: string;
|
||||||
|
admin_password: string;
|
||||||
|
search_filter: string;
|
||||||
|
base_dn: string;
|
||||||
|
user_mapping: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultConfig: LdapConfig = {
|
||||||
|
ldap_host: '',
|
||||||
|
ldap_port: 389,
|
||||||
|
use_ldaps: false,
|
||||||
|
admin_dn: '',
|
||||||
|
admin_password: '',
|
||||||
|
search_filter: '(objectClass=person)',
|
||||||
|
base_dn: '',
|
||||||
|
user_mapping: { username: 'uid', email: 'mail', display_name: 'cn' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LdapConfigPage() {
|
||||||
|
const [config, setConfig] = useState<LdapConfig>(defaultConfig);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveMsg, setSaveMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
|
||||||
|
/* test */
|
||||||
|
const [testUser, setTestUser] = useState('');
|
||||||
|
const [testPass, setTestPass] = useState('');
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await request.get('/ldap-config');
|
||||||
|
const data = (res as any).data ?? res;
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
setConfig({
|
||||||
|
ldap_host: data.ldap_host || '',
|
||||||
|
ldap_port: data.ldap_port || 389,
|
||||||
|
use_ldaps: data.use_ldaps ?? false,
|
||||||
|
admin_dn: data.admin_dn || '',
|
||||||
|
admin_password: data.admin_password || '',
|
||||||
|
search_filter: data.search_filter || '',
|
||||||
|
base_dn: data.base_dn || '',
|
||||||
|
user_mapping: typeof data.user_mapping === 'string'
|
||||||
|
? JSON.parse(data.user_mapping)
|
||||||
|
: data.user_mapping || defaultConfig.user_mapping,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 404 = no config yet, use defaults */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
/* update helpers */
|
||||||
|
const updateField = <K extends keyof LdapConfig>(key: K, value: LdapConfig[K]) => {
|
||||||
|
setConfig(prev => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMapping = (key: string, value: string) => {
|
||||||
|
setConfig(prev => ({
|
||||||
|
...prev,
|
||||||
|
user_mapping: { ...prev.user_mapping, [key]: value },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
/* save */
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
setSaveMsg(null);
|
||||||
|
await request.put('/ldap-config', config);
|
||||||
|
setSaveMsg({ ok: true, text: '保存成功' });
|
||||||
|
} catch (err: any) {
|
||||||
|
setSaveMsg({ ok: false, text: err.response?.data?.detail || '保存失败' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
setTimeout(() => setSaveMsg(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* test connection */
|
||||||
|
const handleTest = async () => {
|
||||||
|
if (!testUser.trim() || !testPass.trim()) return;
|
||||||
|
try {
|
||||||
|
setTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
const res = await request.post('/ldap-config/test', { test_user: testUser, test_password: testPass });
|
||||||
|
const data = (res as any).data ?? res;
|
||||||
|
setTestResult({ success: data.success ?? true, message: data.message || '连接成功' });
|
||||||
|
} catch (err: any) {
|
||||||
|
setTestResult({ success: false, message: err.response?.data?.detail || '连接失败' });
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">加载中…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-[var(--dg-text-primary)]">LDAP 设置</h1>
|
||||||
|
<p className="text-sm text-[var(--dg-text-muted)] mt-1">配置 LDAP/AD 域账号认证集成</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-[var(--dg-blue)] text-white hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" /> {saving ? '保存中…' : '保存配置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveMsg && (
|
||||||
|
<div
|
||||||
|
className={`dg-card px-4 py-3 flex items-center gap-2 text-sm ${
|
||||||
|
saveMsg.ok ? 'text-green-400 bg-green-500/10' : 'text-red-400 bg-red-500/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saveMsg.ok ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
|
||||||
|
{saveMsg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Config Form */}
|
||||||
|
<div className="dg-card p-5 space-y-5">
|
||||||
|
{/* Server */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)] mb-3 flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4" /> LDAP 服务器
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">LDAP 地址</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.ldap_host}
|
||||||
|
onChange={e => updateField('ldap_host', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="ldap.company.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">端口</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={config.ldap_port}
|
||||||
|
onChange={e => updateField('ldap_port', parseInt(e.target.value) || 389)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateField('use_ldaps', !config.use_ldaps)}
|
||||||
|
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ${
|
||||||
|
config.use_ldaps ? 'bg-[var(--dg-green)]' : 'bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
||||||
|
config.use_ldaps ? 'translate-x-4.5' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-[var(--dg-text-secondary)]">启用 LDAPS (SSL/TLS)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin */}
|
||||||
|
<div className="pt-4 border-t border-[var(--dg-border)]">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)] mb-3">管理员账号</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">管理员 DN</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.admin_dn}
|
||||||
|
onChange={e => updateField('admin_dn', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="cn=admin,dc=company,dc=com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">管理员密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={config.admin_password}
|
||||||
|
onChange={e => updateField('admin_password', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="pt-4 border-t border-[var(--dg-border)]">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)] mb-3">搜索配置</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">Search Filter</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.search_filter}
|
||||||
|
onChange={e => updateField('search_filter', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="(objectClass=person)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">Base DN</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.base_dn}
|
||||||
|
onChange={e => updateField('base_dn', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="ou=users,dc=company,dc=com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Mapping */}
|
||||||
|
<div className="pt-4 border-t border-[var(--dg-border)]">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)] mb-3">用户属性映射</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">username 字段</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.user_mapping.username || ''}
|
||||||
|
onChange={e => updateMapping('username', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="uid"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">email 字段</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.user_mapping.email || ''}
|
||||||
|
onChange={e => updateMapping('email', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="mail"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">display_name 字段</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.user_mapping.display_name || ''}
|
||||||
|
onChange={e => updateMapping('display_name', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="cn"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Connection */}
|
||||||
|
<div className="dg-card p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)] mb-3 flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4" /> 测试 LDAP 连接
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[var(--dg-text-muted)] mb-4">使用 LDAP 账号验证配置是否正确</p>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">测试用户名</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={testUser}
|
||||||
|
onChange={e => setTestUser(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="LDAP 用户名"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">测试密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={testPass}
|
||||||
|
onChange={e => setTestPass(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testing || !testUser.trim() || !testPass.trim()}
|
||||||
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-500/15 text-blue-400 hover:bg-blue-500/25 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{testing ? '测试中…' : '测试连接'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{testResult && (
|
||||||
|
<div
|
||||||
|
className={`mt-4 px-4 py-3 rounded-lg text-sm flex items-center gap-2 ${
|
||||||
|
testResult.success
|
||||||
|
? 'bg-green-500/10 text-green-400'
|
||||||
|
: 'bg-red-500/10 text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
|
||||||
|
{testResult.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import request from '@/api/request';
|
||||||
|
import { Plus, Trash2, Send, Bell, Mail, Settings, X } from 'lucide-react';
|
||||||
|
|
||||||
|
/* ── types ── */
|
||||||
|
type ChannelType = 'dingtalk' | 'webhook_feishu' | 'email';
|
||||||
|
|
||||||
|
interface NotifChannel {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
channel_type: ChannelType;
|
||||||
|
config: Record<string, any>;
|
||||||
|
is_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANNEL_LABELS: Record<ChannelType, string> = {
|
||||||
|
dingtalk: '钉钉',
|
||||||
|
webhook_feishu: '飞书',
|
||||||
|
email: '邮件',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNEL_ICONS: Record<ChannelType, React.ElementType> = {
|
||||||
|
dingtalk: Bell,
|
||||||
|
webhook_feishu: Send,
|
||||||
|
email: Mail,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string;
|
||||||
|
channel_type: ChannelType;
|
||||||
|
config: Record<string, any>;
|
||||||
|
is_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultForm = (): FormState => ({
|
||||||
|
name: '',
|
||||||
|
channel_type: 'dingtalk',
|
||||||
|
config: { webhook_url: '' },
|
||||||
|
is_enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dingtalkDefault = () => ({ webhook_url: '', secret: '' });
|
||||||
|
const feishuDefault = () => ({ webhook_url: '' });
|
||||||
|
const emailDefault = () => ({ smtp_host: '', smtp_port: 465, smtp_user: '', smtp_password: '', from_addr: '', use_tls: true });
|
||||||
|
|
||||||
|
const configDefaults: Record<ChannelType, () => Record<string, any>> = {
|
||||||
|
dingtalk: dingtalkDefault,
|
||||||
|
webhook_feishu: feishuDefault,
|
||||||
|
email: emailDefault,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotificationPage() {
|
||||||
|
const [channels, setChannels] = useState<NotifChannel[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
/* modal */
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [form, setForm] = useState<FormState>(defaultForm());
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
/* test */
|
||||||
|
const [testingId, setTestingId] = useState<number | null>(null);
|
||||||
|
const [testResult, setTestResult] = useState<Record<number, { success: boolean; message: string } | null>>({});
|
||||||
|
|
||||||
|
const fetchList = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await request.get('/notifications');
|
||||||
|
setChannels(Array.isArray(res.data) ? res.data : Array.isArray(res) ? res : []);
|
||||||
|
} catch {
|
||||||
|
setChannels([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchList();
|
||||||
|
}, [fetchList]);
|
||||||
|
|
||||||
|
/* ── helpers ── */
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(defaultForm());
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (ch: NotifChannel) => {
|
||||||
|
setEditingId(ch.id);
|
||||||
|
setForm({ name: ch.name, channel_type: ch.channel_type, config: { ...ch.config }, is_enabled: ch.is_enabled });
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTypeChange = (t: ChannelType) => {
|
||||||
|
setForm(prev => ({ ...prev, channel_type: t, config: configDefaults[t]() }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (key: string, value: any) => {
|
||||||
|
setForm(prev => ({ ...prev, config: { ...prev.config, [key]: value } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.name.trim()) return;
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
if (editingId) {
|
||||||
|
await request.put(`/notifications/${editingId}`, form);
|
||||||
|
} else {
|
||||||
|
await request.post('/notifications', form);
|
||||||
|
}
|
||||||
|
setShowModal(false);
|
||||||
|
fetchList();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.response?.data?.detail || '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (ch: NotifChannel) => {
|
||||||
|
try {
|
||||||
|
await request.put(`/notifications/${ch.id}`, { is_enabled: !ch.is_enabled });
|
||||||
|
fetchList();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm('确定删除此通知渠道?')) return;
|
||||||
|
try {
|
||||||
|
await request.delete(`/notifications/${id}`);
|
||||||
|
fetchList();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async (id: number) => {
|
||||||
|
try {
|
||||||
|
setTestingId(id);
|
||||||
|
setTestResult(prev => ({ ...prev, [id]: null }));
|
||||||
|
const res = await request.post(`/notifications/${id}/test`);
|
||||||
|
const data = (res as any).data ?? res;
|
||||||
|
setTestResult(prev => ({ ...prev, [id]: { success: true, message: data.message || '测试成功' } }));
|
||||||
|
} catch (err: any) {
|
||||||
|
setTestResult(prev => ({
|
||||||
|
...prev,
|
||||||
|
[id]: { success: false, message: err.response?.data?.detail || '测试失败' },
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setTestingId(null);
|
||||||
|
setTimeout(() => setTestResult(prev => ({ ...prev, [id]: null })), 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── config form renderer ── */
|
||||||
|
const renderConfigFields = () => {
|
||||||
|
const t = form.channel_type;
|
||||||
|
if (t === 'dingtalk') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">Webhook URL <span className="text-red-400">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.config.webhook_url || ''}
|
||||||
|
onChange={e => updateConfig('webhook_url', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">签名密钥 (Secret)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.config.secret || ''}
|
||||||
|
onChange={e => updateConfig('secret', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="可选,加签密钥"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (t === 'webhook_feishu') {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">Webhook URL <span className="text-red-400">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.config.webhook_url || ''}
|
||||||
|
onChange={e => updateConfig('webhook_url', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/* email */
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">SMTP 服务器 <span className="text-red-400">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.config.smtp_host || ''}
|
||||||
|
onChange={e => updateConfig('smtp_host', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="smtp.example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">端口</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.config.smtp_port || 465}
|
||||||
|
onChange={e => updateConfig('smtp_port', parseInt(e.target.value) || 465)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">SMTP 用户 <span className="text-red-400">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.config.smtp_user || ''}
|
||||||
|
onChange={e => updateConfig('smtp_user', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="noreply@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">SMTP 密码</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.config.smtp_password || ''}
|
||||||
|
onChange={e => updateConfig('smtp_password', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">发件地址 <span className="text-red-400">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.config.from_addr || ''}
|
||||||
|
onChange={e => updateConfig('from_addr', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="noreply@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 pt-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateConfig('use_tls', !form.config.use_tls)}
|
||||||
|
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ${
|
||||||
|
form.config.use_tls ? 'bg-[var(--dg-green)]' : 'bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
||||||
|
form.config.use_tls ? 'translate-x-4.5' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-[var(--dg-text-secondary)]">启用 TLS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── render ── */
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-[var(--dg-text-primary)]">消息推送</h1>
|
||||||
|
<p className="text-sm text-[var(--dg-text-muted)] mt-1">管理钉钉、飞书、邮件等通知渠道</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openCreate}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-[var(--dg-blue)] text-white hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> 添加渠道
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">加载中…</div>
|
||||||
|
) : channels.length === 0 ? (
|
||||||
|
<div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">暂无通知渠道,点击右上角添加</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{channels.map(ch => {
|
||||||
|
const Icon = CHANNEL_ICONS[ch.channel_type] || Settings;
|
||||||
|
const result = testResult[ch.id];
|
||||||
|
return (
|
||||||
|
<div key={ch.id} className={`dg-card px-5 py-4 flex items-center gap-4 ${!ch.is_enabled ? 'opacity-50' : ''}`}>
|
||||||
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${ch.is_enabled ? 'bg-blue-500/15' : 'bg-[var(--dg-bg-tertiary)]'}`}>
|
||||||
|
<Icon className={`w-5 h-5 ${ch.is_enabled ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">{ch.name}</h3>
|
||||||
|
<span className="text-[11px] px-2 py-0.5 rounded-full bg-blue-500/15 text-blue-400">
|
||||||
|
{CHANNEL_LABELS[ch.channel_type] || ch.channel_type}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[11px] px-2 py-0.5 rounded-full ${ch.is_enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
|
||||||
|
{ch.is_enabled ? '已启用' : '已禁用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--dg-text-muted)] truncate">
|
||||||
|
{ch.channel_type === 'email'
|
||||||
|
? `${ch.config.smtp_host}:${ch.config.smtp_port} / ${ch.config.from_addr}`
|
||||||
|
: ch.config.webhook_url || '未配置'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(ch)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
ch.is_enabled
|
||||||
|
? 'bg-yellow-500/15 text-yellow-400 hover:bg-yellow-500/25'
|
||||||
|
: 'bg-green-500/15 text-green-400 hover:bg-green-500/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ch.is_enabled ? '禁用' : '启用'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTest(ch.id)}
|
||||||
|
disabled={testingId === ch.id}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
result?.success
|
||||||
|
? 'bg-green-500/15 text-green-400'
|
||||||
|
: result?.success === false
|
||||||
|
? 'bg-red-500/15 text-red-400'
|
||||||
|
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{testingId === ch.id ? '发送中…' : result?.success ? '✓ 成功' : result?.success === false ? '✗ 失败' : '测试'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(ch)}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(ch.id)}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/15 hover:text-red-400 transition-colors"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Create / Edit Modal ── */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowModal(false)}>
|
||||||
|
<div
|
||||||
|
className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-xl font-semibold text-[var(--dg-text-primary)]">{editingId ? '编辑渠道' : '添加渠道'}</h3>
|
||||||
|
<button onClick={() => setShowModal(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)]">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">渠道名称 <span className="text-red-400">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
|
||||||
|
placeholder="例如:运维群钉钉机器人"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!editingId && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">渠道类型</label>
|
||||||
|
<div className="dg-card p-1.5 flex gap-1">
|
||||||
|
{(Object.entries(CHANNEL_LABELS) as [ChannelType, string][]).map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => handleTypeChange(key)}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
form.channel_type === key
|
||||||
|
? 'bg-[var(--dg-blue)] text-white'
|
||||||
|
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-[var(--dg-border)]">
|
||||||
|
<h4 className="text-xs font-semibold text-[var(--dg-text-secondary)] mb-3">渠道配置</h4>
|
||||||
|
{renderConfigFields()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-2 border-t border-[var(--dg-border)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm(prev => ({ ...prev, is_enabled: !prev.is_enabled }))}
|
||||||
|
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ${
|
||||||
|
form.is_enabled ? 'bg-[var(--dg-green)]' : 'bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
||||||
|
form.is_enabled ? 'translate-x-4.5' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-[var(--dg-text-secondary)]">启用此渠道</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[var(--dg-border)]">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!form.name.trim() || saving}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? '保存中…' : editingId ? '保存修改' : '添加'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import AnnouncementPage from './AnnouncementPage';
|
||||||
|
import NotificationPage from './NotificationPage';
|
||||||
|
import LdapConfigPage from './LdapConfigPage';
|
||||||
|
import AuditRulesPage from './AuditRulesPage';
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'rules', label: '审核规则', icon: '🛡️' },
|
||||||
|
{ id: 'announcement', label: '公告管理', icon: '📢' },
|
||||||
|
{ id: 'notification', label: '消息推送', icon: '🔔' },
|
||||||
|
{ id: 'ldap', label: 'LDAP', icon: '🔐' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TabId = typeof tabs[number]['id'];
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>('rules');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">系统设置</h1>
|
||||||
|
<p className="text-sm text-[var(--dg-text-muted)] mt-1">管理平台配置、审核规则、消息推送和 LDAP 认证</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 导航 */}
|
||||||
|
<div className="flex gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-xl w-fit">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] shadow-sm'
|
||||||
|
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{tab.icon}</span>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区 */}
|
||||||
|
<div>
|
||||||
|
{activeTab === 'rules' && <AuditRulesPage />}
|
||||||
|
{activeTab === 'announcement' && <AnnouncementPage />}
|
||||||
|
{activeTab === 'notification' && <NotificationPage />}
|
||||||
|
{activeTab === 'ldap' && <LdapConfigPage />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user