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:
logan committed 2026-05-22 22:26:10 +08:00
1 parent d5d19d6be4
commit bab55d3927
27 files changed
+2881 -591

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": "公告已删除"}
+53 -18
View File
@@ -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}
+142
View File
@@ -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}"}
+7 -1
View File
@@ -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)
+3
View File
@@ -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()
+15
View File
@@ -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())
+3 -2
View File
@@ -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())
+19
View File
@@ -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())
+15
View File
@@ -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())
+27
View File
@@ -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
+5 -2
View File
@@ -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
+47
View File
@@ -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)
+30
View File
@@ -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
+322
View File
@@ -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,
}
+3
View File
@@ -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://"
+240
View File
@@ -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"
+379
View File
@@ -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
+2 -4
View File
@@ -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>
+2 -38
View File
@@ -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
+1 -4
View File
@@ -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[]> = {
+1 -3
View File
@@ -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 = {
+272 -278
View File
@@ -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, '&amp;')
low: '低', .replace(/</g, '&lt;')
}; .replace(/>/g, '&gt;');
// 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, } else {
content: formContent, await request.post('/announcements', { title: formTitle, content: formContent });
priority: formPriority, }
pinned: formPinned, setShowModal(false);
targetRoles: [...formTargetRoles], fetchList();
expireTime: formExpireTime || a.expireTime, } catch (err: any) {
} : a)); alert(err.response?.data?.detail || '保存失败');
} else { } finally {
const newAnn: Announcement = { setSaving(false);
id: `ann-${Date.now()}`,
title: formTitle,
content: formContent,
priority: formPriority,
pinned: formPinned,
enabled: true,
author: 'admin',
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>
<div className="space-y-3"> {/* List */}
{sortedAnnouncements.map(ann => ( {loading ? (
<Card key={ann.id} className={`dg-card ${!ann.enabled ? 'opacity-50' : ''} ${ann.pinned ? 'border-l-2 border-l-purple-500' : ''}`}> <div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">加载中…</div>
<CardContent className="p-5"> ) : announcements.length === 0 ? (
<div className="flex items-start justify-between"> <div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">暂无公告,点击右上角创建</div>
<div className="flex-1"> ) : (
<div className="flex items-center gap-2 mb-2"> <div className="space-y-3">
{ann.pinned && <Pin className="w-4 h-4 text-purple-400" />} {announcements.map(ann => (
<h3 className="font-semibold text-sm">{ann.title}</h3> <div key={ann.id} className={`dg-card px-5 py-4 flex items-center gap-4 ${!ann.is_published ? 'opacity-60' : ''}`}>
<span className={`text-[11px] px-2 py-0.5 rounded-full border ${priorityColors[ann.priority]}`}> <div className="flex-1 min-w-0">
{priorityLabels[ann.priority]} <div className="flex items-center gap-2 mb-1">
</span> <h3 className="text-sm font-medium text-[var(--dg-text-primary)] truncate">{ann.title}</h3>
{!ann.enabled && <span className="text-[11px] px-2 py-0.5 rounded-full bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">已失效</span>} <span
</div> className={`text-[11px] px-2 py-0.5 rounded-full flex-shrink-0 ${
<p className="text-xs text-[var(--dg-text-secondary)] line-clamp-2 mb-3">{ann.content.replace(/\n/g, ' ')}</p> ann.is_published
<div className="flex items-center gap-4 text-xs text-[var(--dg-text-muted)]"> ? 'bg-green-500/15 text-green-400'
<span className="flex items-center gap-1"><Send className="w-3 h-3" /> {ann.author}</span> : 'bg-yellow-500/15 text-yellow-400'
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {ann.publishTime}</span> }`}
<span className="flex items-center gap-1"><Calendar className="w-3 h-3" /> 到期: {ann.expireTime}</span> >
<span className="flex items-center gap-1"><Eye className="w-3 h-3" /> {ann.readCount} 次阅读</span> {ann.is_published ? '已发布' : '草稿'}
<div className="flex items-center gap-1"> </span>
<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 className="flex items-center gap-1 ml-4"> <p className="text-xs text-[var(--dg-text-muted)] truncate">{ann.content.replace(/\n/g, ' ')}</p>
<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 ? '取消置顶' : '置顶'}> <div className="flex items-center gap-3 mt-2 text-xs text-[var(--dg-text-muted)]">
{ann.pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />} <span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {ann.created_at}</span>
</button> {ann.published_at && (
<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="预览"> <span className="flex items-center gap-1"><CheckCircle className="w-3 h-3" /> 发布于 {ann.published_at}</span>
<Eye className="w-4 h-4" /> )}
</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="编辑">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => toggleEnable(ann.id)} className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" title={ann.enabled ? '失效' : '生效'}>
{ann.enabled ? <CheckCircle className="w-4 h-4 text-green-400" /> : <AlertTriangle className="w-4 h-4 text-yellow-400" />}
</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="删除">
<Trash2 className="w-4 h-4" />
</button>
</div> </div>
</div> </div>
</CardContent> <div className="flex items-center gap-1 flex-shrink-0">
</Card> <button
))} onClick={() => setViewAnn(ann)}
</div> 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" />
</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="编辑"
>
<Edit2 className="w-4 h-4" />
</button>
<button
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
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" />
</button>
</div>
</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> <button
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">优先级</label> onClick={() => setShowPreview(!showPreview)}
<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"> className={`text-xs px-2 py-1 rounded transition-colors ${
{Object.entries(priorityLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)} showPreview
</select> ? 'bg-[var(--dg-blue)] text-white'
</div> : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
<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" /> {showPreview ? '编辑' : '预览'}
</div> </button>
</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
key={role.id}
onClick={() => toggleRole(role.id)}
className={`px-3 py-1.5 rounded-lg text-xs border transition-colors ${
formTargetRoles.includes(role.id)
? 'bg-blue-500/15 text-blue-400 border-blue-500/30'
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border-[var(--dg-border)]'
}`}
>
{role.label}
</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 语法...&#10;&#10;**粗体** *斜体* `代码`&#10;## 二级标题&#10;- 列表项&#10;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>
+200 -241
View File
@@ -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() {
try {
const res = await request.get('/audit-rules/stats/summary');
setStats(res.data || null);
} catch { setStats(null); }
}
async function handleCreate() {
try {
await request.post('/audit-rules', form);
setShowCreate(false);
setForm({ name: '', description: '', rule_type: 'safety', severity: 'warning', db_type: 'mysql', pattern: '', is_enabled: true, message: '' });
fetchRules();
fetchStats();
} catch (err: any) {
alert(err.response?.data?.detail || '创建失败');
} }
} }, []);
async function handleToggle(rule: AuditRule) { useEffect(() => {
fetchRules(activeTab);
}, [activeTab, fetchRules]);
const handleSeed = async () => {
try { try {
await request.post('/audit-rules/' + rule.id + '/toggle'); setSeeding(true);
fetchRules(); await request.post(`/audit-rules/seed/${activeTab}`);
fetchStats(); fetchRules(activeTab);
} catch { /* */ } } catch (err: any) {
} alert(err.response?.data?.detail || '初始化失败');
} finally {
setSeeding(false);
}
};
async function handleDelete(id: number) { const handleToggle = async (rule: AuditRule) => {
if (!confirm('确认删除此规则?')) return;
try { try {
await request.delete('/audit-rules/' + id); 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 */
}
};
const getTypeInfo = (type: string) => RULE_TYPES.find(t => t.value === type) || RULE_TYPES[0]; const handleSaveValue = async (rule: AuditRule) => {
const getSeverityInfo = (sev: string) => SEVERITY_MAP[sev] || SEVERITY_MAP.info; const newVal = editingValue[rule.id];
if (newVal === undefined || newVal === rule.value) return;
try {
setSaving((prev) => ({ ...prev, [rule.id]: true }));
await request.put(`/audit-rules/${rule.id}`, { value: newVal });
setRules((prev) =>
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 }));
}
};
// Group rules into DDL / DML
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 (
<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 ( return (
<div className="space-y-6"> <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>
</div> </button>
)} ))}
{/* Filters */}
<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> </div>
{/* Create Modal */} {/* Rule List */}
{showCreate && ( {loading ? (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50"> <div className="dg-card p-8 text-center text-[var(--dg-text-muted)] text-sm">加载中…</div>
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6"> ) : rules.length === 0 ? (
<h2 className="text-lg font-semibold mb-4">新建审核规则</h2> <div className="dg-card p-8 text-center space-y-3">
<div className="space-y-3"> <p className="text-[var(--dg-text-muted)] text-sm">
<input 当前没有 {DB_TABS.find((t) => t.key === activeTab)?.label} 的审核规则
placeholder="规则名称" </p>
value={form.name} <button
onChange={e => setForm({ ...form, name: e.target.value })} onClick={handleSeed}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" 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"
<textarea >
placeholder="描述(可选)" 点击初始化默认规则
value={form.description} </button>
onChange={e => setForm({ ...form, description: e.target.value })} </div>
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" ) : (
rows={2} <div className="space-y-6">
/> {renderRuleGroup('DDL 规则', '🏗️', ddlRules)}
<div className="grid grid-cols-2 gap-3"> {renderRuleGroup('DML 规则', '📝', dmlRules)}
<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> </div>
)} )}
{/* Rules Table */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-400">加载中...</div>
) : rules.length === 0 ? (
<div className="p-8 text-center text-gray-400">暂无规则,点击"新建规则"添加</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-gray-50 text-left text-xs text-gray-500">
<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>
</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>
); );
} }
+335
View File
@@ -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>
);
}
+456
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}