feat: Dashboard数据关联修复 + 通知模板优化 + 工单创建页布局
1. Dashboard: stats新增queryStats查询统计, trend API真实工单趋势数据 2. Layout铃铛: 显示真实待审批工单数量 3. 通知模板优化: - 创建工单通知: 含申请人+目标实例+库+SQL摘要 - 审批通过/拒绝通知: 含审批人+目标实例 - 执行完成/失败通知: 含执行人+目标实例+库 4. 飞书卡片颜色: 创建=turquoise, 审批=blue, 成功=green, 失败=red 5. 钉钉消息: 添加状态emoji标签(🆕✅❌⚠️) 6. TicketCreatePage: 底部操作栏(SQL检测+提交审批) 7. ticket_executor: 移除aiomysql不支持的read_timeout/write_timeout Tests: 1057 passed, 17 pre-existing failures (notification 35/35 passed)
This commit is contained in:
1 parent
7109031f71
commit
880dbfefa4
9 files changed
+403
-189
No files matched your search
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select, func
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, func, case, extract
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -9,7 +9,8 @@ from app.models.user import User
|
||||
from app.models.ticket import Ticket
|
||||
from app.models.instance import Instance
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.schemas.dashboard import DashboardStats
|
||||
from app.models.query_history import QueryHistory
|
||||
from app.schemas.dashboard import DashboardStats, TrendData, TrendPoint
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["仪表盘"])
|
||||
|
||||
@@ -44,12 +45,41 @@ async def get_stats(
|
||||
)).scalar() or 0
|
||||
|
||||
queries_today = (await db.execute(
|
||||
select(func.count(AuditLog.id)).where(
|
||||
AuditLog.action == "QUERY",
|
||||
AuditLog.created_at >= today_start,
|
||||
select(func.count(QueryHistory.id)).where(
|
||||
QueryHistory.created_at >= today_start,
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# --- 查询统计 ---
|
||||
qh_today = select(QueryHistory).where(QueryHistory.created_at >= today_start)
|
||||
total_q = (await db.execute(
|
||||
select(func.count(QueryHistory.id))
|
||||
)).scalar() or 0
|
||||
avg_time = (await db.execute(
|
||||
select(func.avg(QueryHistory.execution_time_ms))
|
||||
)).scalar() or 0
|
||||
slow_count = (await db.execute(
|
||||
select(func.count(QueryHistory.id)).where(QueryHistory.execution_time_ms > 1000)
|
||||
)).scalar() or 0
|
||||
|
||||
# 按数据库类型分布
|
||||
type_rows = (await db.execute(
|
||||
select(QueryHistory.db_type, func.count(QueryHistory.id).label('cnt'))
|
||||
.group_by(QueryHistory.db_type)
|
||||
)).all()
|
||||
type_distribution = [
|
||||
{"label": row[0] or "unknown", "count": row[1]}
|
||||
for row in type_rows
|
||||
]
|
||||
|
||||
from app.schemas.dashboard import QueryStatsData
|
||||
query_stats = QueryStatsData(
|
||||
total=total_q,
|
||||
avg_time_ms=int(avg_time),
|
||||
slow_count=slow_count,
|
||||
type_distribution=type_distribution,
|
||||
)
|
||||
|
||||
return DashboardStats(
|
||||
ticket_total=ticket_total,
|
||||
ticket_pending=ticket_pending,
|
||||
@@ -59,4 +89,45 @@ async def get_stats(
|
||||
instance_online=instance_online,
|
||||
user_total=user_total,
|
||||
queries_today=queries_today,
|
||||
query_stats=query_stats,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/trend", response_model=TrendData)
|
||||
async def get_trend(
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""工单趋势数据 — 按天聚合最近 N 天的提交/成功/失败数"""
|
||||
now = datetime.now(timezone.utc)
|
||||
start = now - timedelta(days=days)
|
||||
|
||||
# 按天聚合工单数
|
||||
rows = (await db.execute(
|
||||
select(
|
||||
func.date(Ticket.create_time).label('d'),
|
||||
func.count(Ticket.id).label('total'),
|
||||
func.sum(case((Ticket.status.in_(['completed', 'success']), 1), else_=0)).label('success'),
|
||||
func.sum(case((Ticket.status.in_(['failed', 'rejected']), 1), else_=0)).label('failed'),
|
||||
)
|
||||
.where(Ticket.create_time >= start)
|
||||
.group_by(func.date(Ticket.create_time))
|
||||
.order_by(func.date(Ticket.create_time))
|
||||
)).all()
|
||||
|
||||
# 构建日期映射
|
||||
data_map = {}
|
||||
for row in rows:
|
||||
d = str(row[0])
|
||||
data_map[d] = {"submitted": row[1], "success": int(row[2] or 0), "failed": int(row[3] or 0)}
|
||||
|
||||
# 填充缺失日期
|
||||
items = []
|
||||
for i in range(days):
|
||||
dt = (start + timedelta(days=i)).strftime('%Y-%m-%d')
|
||||
d = data_map.get(dt, {"submitted": 0, "success": 0, "failed": 0})
|
||||
label = dt[5:] # MM-DD
|
||||
items.append(TrendPoint(date=label, **d))
|
||||
|
||||
return TrendData(items=items)
|
||||
@@ -447,6 +447,12 @@ async def create_ticket(
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
# 9. 发送创建通知
|
||||
try:
|
||||
await _send_ticket_notification(db, ticket, "ticket_created")
|
||||
except Exception as notify_err:
|
||||
logger.error("创建通知发送失败: %s", notify_err)
|
||||
|
||||
result = await _enrich_ticket(ticket, db)
|
||||
if v2_report:
|
||||
result["audit_report"] = v2_report.to_dict()
|
||||
@@ -663,6 +669,17 @@ async def review_ticket(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
# 发送审批通知
|
||||
try:
|
||||
event_type = "ticket_approved" if data.action == "approve" else "ticket_rejected"
|
||||
await _send_ticket_notification(db, ticket, event_type, {
|
||||
"approver": current_user.display_name,
|
||||
"comment": data.comment or ("审批通过" if data.action == "approve" else "审批拒绝"),
|
||||
})
|
||||
except Exception as notify_err:
|
||||
logger.error("审批通知发送失败: %s", notify_err)
|
||||
|
||||
return await _enrich_ticket(ticket, db)
|
||||
|
||||
|
||||
@@ -1027,65 +1044,122 @@ async def execute_ticket(
|
||||
return ticket_data
|
||||
|
||||
|
||||
async def _send_execution_notification(
|
||||
db: AsyncSession, ticket: Ticket, report: "ExecutionReport"
|
||||
) -> None:
|
||||
"""发送工单执行完成通知。
|
||||
async def _get_user_name(db: AsyncSession, user_id: Optional[int]) -> str:
|
||||
"""获取用户显示名称。"""
|
||||
if not user_id:
|
||||
return "系统"
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
return user.display_name if user else "未知"
|
||||
|
||||
查询已启用的通知渠道并发送执行结果通知。
|
||||
|
||||
async def _get_instance_name(db: AsyncSession, instance_id: Optional[int]) -> str:
|
||||
"""获取实例名称。"""
|
||||
if not instance_id:
|
||||
return "未知实例"
|
||||
result = await db.execute(select(Instance).where(Instance.id == instance_id))
|
||||
inst = result.scalar_one_or_none()
|
||||
return inst.name if inst else "未知实例"
|
||||
|
||||
|
||||
async def _get_enabled_channels(db: AsyncSession) -> list:
|
||||
"""获取已启用的通知渠道。"""
|
||||
from app.models.notification import NotificationChannel
|
||||
result = await db.execute(
|
||||
select(NotificationChannel).where(NotificationChannel.is_enabled == True)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def _send_ticket_notification(
|
||||
db: AsyncSession, ticket: Ticket, event_type: str,
|
||||
extra_data: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""通用工单通知发送。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
ticket: 工单对象
|
||||
report: 执行报告
|
||||
event_type: 事件类型 (ticket_created / ticket_approved / ticket_rejected / ticket_executed / ticket_failed)
|
||||
extra_data: 额外模板数据 (如 approver, operator, error 等)
|
||||
"""
|
||||
from app.models.notification import NotificationChannel
|
||||
from app.services.notification_sender import NotificationSender
|
||||
|
||||
result = await db.execute(
|
||||
select(NotificationChannel).where(NotificationChannel.is_enabled == True)
|
||||
)
|
||||
channels = result.scalars().all()
|
||||
|
||||
channels = await _get_enabled_channels(db)
|
||||
if not channels:
|
||||
return
|
||||
|
||||
sender = NotificationSender()
|
||||
|
||||
# 确定事件类型
|
||||
if report.status == "success":
|
||||
event_type = "ticket_executed"
|
||||
else:
|
||||
event_type = "ticket_failed"
|
||||
|
||||
# 构建 SQL 预览(前 100 字符)
|
||||
# 获取关联信息
|
||||
applicant_name = await _get_user_name(db, ticket.applicant_id)
|
||||
instance_name = await _get_instance_name(db, ticket.instance_id)
|
||||
sql_preview = ticket.sql_content[:100] + "..." if len(ticket.sql_content) > 100 else ticket.sql_content
|
||||
|
||||
# 获取申请人信息
|
||||
applicant_name = "未知"
|
||||
if ticket.applicant_id:
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == ticket.applicant_id)
|
||||
)
|
||||
applicant = result.scalar_one_or_none()
|
||||
if applicant:
|
||||
applicant_name = applicant.display_name
|
||||
|
||||
notification_data = {
|
||||
"title": ticket.title,
|
||||
"ticket_id": ticket.id,
|
||||
"ticket_no": ticket.ticket_no or str(ticket.id),
|
||||
"db_type": ticket.db_type,
|
||||
"target_database": ticket.target_database,
|
||||
"target_database": ticket.target_database or "",
|
||||
"instance_name": instance_name,
|
||||
"sql_preview": sql_preview,
|
||||
"priority": ticket.priority,
|
||||
"applicant": applicant_name,
|
||||
"approver": "未知",
|
||||
"operator": "系统",
|
||||
"comment": "",
|
||||
"result": "",
|
||||
"duration": 0,
|
||||
"affected_rows": 0,
|
||||
"error": "",
|
||||
}
|
||||
|
||||
# 合并额外数据
|
||||
if extra_data:
|
||||
notification_data.update(extra_data)
|
||||
|
||||
sender = NotificationSender()
|
||||
await sender.notify_ticket_event(
|
||||
ticket_id=ticket.id,
|
||||
event_type=event_type,
|
||||
channels=channels,
|
||||
ticket_data=notification_data,
|
||||
)
|
||||
|
||||
|
||||
async def _send_execution_notification(
|
||||
db: AsyncSession, ticket: Ticket, report: "ExecutionReport"
|
||||
) -> None:
|
||||
"""发送工单执行完成通知(兼容旧调用)。"""
|
||||
from app.services.notification_sender import NotificationSender
|
||||
|
||||
channels = await _get_enabled_channels(db)
|
||||
if not channels:
|
||||
return
|
||||
|
||||
event_type = "ticket_executed" if report.status == "success" else "ticket_failed"
|
||||
operator_name = await _get_user_name(db, ticket.approver_id)
|
||||
instance_name = await _get_instance_name(db, ticket.instance_id)
|
||||
applicant_name = await _get_user_name(db, ticket.applicant_id)
|
||||
sql_preview = ticket.sql_content[:100] + "..." if len(ticket.sql_content) > 100 else ticket.sql_content
|
||||
|
||||
notification_data = {
|
||||
"title": ticket.title,
|
||||
"ticket_id": ticket.id,
|
||||
"ticket_no": ticket.ticket_no or str(ticket.id),
|
||||
"db_type": ticket.db_type,
|
||||
"target_database": ticket.target_database or "",
|
||||
"instance_name": instance_name,
|
||||
"sql_preview": sql_preview,
|
||||
"applicant": applicant_name,
|
||||
"operator": operator_name,
|
||||
"result": report.status,
|
||||
"duration": report.duration_ms,
|
||||
"affected_rows": report.affected_rows,
|
||||
"error": "; ".join(report.errors[:3]) if report.errors else "",
|
||||
"operator": "dba",
|
||||
"approver": operator_name,
|
||||
"comment": "",
|
||||
}
|
||||
|
||||
sender = NotificationSender()
|
||||
await sender.notify_ticket_event(
|
||||
ticket_id=ticket.id,
|
||||
event_type=event_type,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class QueryStatsData(BaseModel):
|
||||
total: int = 0
|
||||
avg_time_ms: int = 0
|
||||
slow_count: int = 0
|
||||
type_distribution: List[dict] = []
|
||||
|
||||
|
||||
class DashboardStats(BaseModel):
|
||||
@@ -11,3 +18,15 @@ class DashboardStats(BaseModel):
|
||||
instance_online: int = 0
|
||||
user_total: int = 0
|
||||
queries_today: int = 0
|
||||
query_stats: Optional[QueryStatsData] = None
|
||||
|
||||
|
||||
class TrendPoint(BaseModel):
|
||||
date: str
|
||||
submitted: int = 0
|
||||
success: int = 0
|
||||
failed: int = 0
|
||||
|
||||
|
||||
class TrendData(BaseModel):
|
||||
items: List[TrendPoint] = []
|
||||
@@ -18,43 +18,69 @@ class NotificationSender:
|
||||
# 内置消息模板
|
||||
TEMPLATES = {
|
||||
"ticket_created": {
|
||||
"title": "新工单待审批: {title}",
|
||||
"body": "**申请人**: {applicant}\n**数据库**: {db_type}@{target_database}\n**SQL摘要**: {sql_preview}",
|
||||
"title": "新工单待审批",
|
||||
"body": "**申请人**: {applicant}\n**目标实例**: {instance_name} ({db_type})\n**目标库**: {target_database}\n**工单标题**: {title}\n**SQL摘要**: {sql_preview}",
|
||||
},
|
||||
"ticket_approved": {
|
||||
"title": "工单已审批通过: {title}",
|
||||
"body": "**审批人**: {approver}\n**工单ID**: {ticket_id}\n**状态**: 已批准,可执行",
|
||||
"title": "工单已审批通过",
|
||||
"body": "**工单标题**: {title}\n**审批人**: {approver}\n**目标实例**: {instance_name} ({db_type})\n**状态**: 已批准,可执行",
|
||||
},
|
||||
"ticket_rejected": {
|
||||
"title": "工单已被拒绝: {title}",
|
||||
"body": "**审批人**: {approver}\n**拒绝原因**: {comment}\n**工单ID**: {ticket_id}",
|
||||
"title": "工单已被拒绝",
|
||||
"body": "**工单标题**: {title}\n**审批人**: {approver}\n**拒绝原因**: {comment}\n**目标实例**: {instance_name} ({db_type})",
|
||||
},
|
||||
"ticket_executed": {
|
||||
"title": "工单执行完成: {title}",
|
||||
"body": "**执行结果**: {result}\n**耗时**: {duration}ms\n**影响行数**: {affected_rows}",
|
||||
"title": "工单执行完成",
|
||||
"body": "**工单标题**: {title}\n**执行人**: {operator}\n**目标实例**: {instance_name} ({db_type})\n**目标库**: {target_database}\n**执行结果**: 成功\n**耗时**: {duration}ms\n**影响行数**: {affected_rows}",
|
||||
},
|
||||
"ticket_failed": {
|
||||
"title": "工单执行失败: {title}",
|
||||
"body": "**错误信息**: {error}\n**工单ID**: {ticket_id}\n请及时处理",
|
||||
"title": "工单执行失败",
|
||||
"body": "**工单标题**: {title}\n**执行人**: {operator}\n**目标实例**: {instance_name} ({db_type})\n**目标库**: {target_database}\n**错误信息**: {error}",
|
||||
},
|
||||
"ticket_rollback": {
|
||||
"title": "工单已回滚: {title}",
|
||||
"title": "工单已回滚",
|
||||
"body": "**回滚人**: {operator}\n**工单ID**: {ticket_id}",
|
||||
},
|
||||
"backup_completed": {
|
||||
"title": "备份完成: {title}",
|
||||
"title": "备份完成",
|
||||
"body": "**数据库**: {database}\n**文件大小**: {size}\n**耗时**: {duration}s",
|
||||
},
|
||||
"backup_failed": {
|
||||
"title": "备份失败: {title}",
|
||||
"title": "备份失败",
|
||||
"body": "**错误信息**: {error}\n请检查数据库连接和磁盘空间",
|
||||
},
|
||||
"schema_drift_detected": {
|
||||
"title": "检测到 Schema 漂移: {instance_name}",
|
||||
"body": "**数据库**: {database}\n**发现 {drift_count} 处变更**:\n{drift_details}",
|
||||
"title": "检测到 Schema 漂移",
|
||||
"body": "**实例**: {instance_name}\n**数据库**: {database}\n**发现 {drift_count} 处变更**:\n{drift_details}",
|
||||
},
|
||||
}
|
||||
|
||||
# 飞书卡片颜色映射
|
||||
FEISHU_COLORS = {
|
||||
"ticket_created": "turquoise",
|
||||
"ticket_approved": "blue",
|
||||
"ticket_rejected": "red",
|
||||
"ticket_executed": "green",
|
||||
"ticket_failed": "red",
|
||||
"ticket_rollback": "orange",
|
||||
"backup_completed": "green",
|
||||
"backup_failed": "red",
|
||||
"schema_drift_detected": "orange",
|
||||
}
|
||||
|
||||
# 钉钉消息颜色标记
|
||||
DINGTALK_TAGS = {
|
||||
"ticket_created": "🆕",
|
||||
"ticket_approved": "✅",
|
||||
"ticket_rejected": "❌",
|
||||
"ticket_executed": "✅",
|
||||
"ticket_failed": "❌",
|
||||
"ticket_rollback": "⚠️",
|
||||
"backup_completed": "✅",
|
||||
"backup_failed": "❌",
|
||||
"schema_drift_detected": "⚠️",
|
||||
}
|
||||
|
||||
async def send(self, channel_type: str, config: Dict[str, Any],
|
||||
event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""发送通知。
|
||||
@@ -95,11 +121,12 @@ class NotificationSender:
|
||||
if "?" in webhook_url:
|
||||
webhook_url = f"{webhook_url}×tamp={timestamp}&sign={sign}"
|
||||
else:
|
||||
webhook_url = f"{webhook_url}×tamp={timestamp}&sign={sign}"
|
||||
webhook_url = f"{webhook_url}?timestamp={timestamp}&sign={sign}"
|
||||
|
||||
template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"])
|
||||
title = template["title"].format(**data)
|
||||
body = template["body"].format(**data)
|
||||
tag = self.DINGTALK_TAGS.get(event_type, "📌")
|
||||
|
||||
# 钉钉 markdown 单个 \n 视为空格,需要 \n\n 才能换行
|
||||
body = body.replace("\n", "\n\n")
|
||||
@@ -107,8 +134,8 @@ class NotificationSender:
|
||||
payload = {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"title": title,
|
||||
"text": f"## {title}\n\n{body}\n\n---\n*来自 SQL 审核平台*",
|
||||
"title": f"{tag} {title}",
|
||||
"text": f"### {tag} {title}\n\n{body}\n\n---\n*来自 SQL 审核平台*",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +149,7 @@ class NotificationSender:
|
||||
|
||||
async def _send_feishu(self, config: Dict[str, Any], event_type: str,
|
||||
data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""发送飞书消息。"""
|
||||
"""发送飞书消息 — 使用 Interactive Card。"""
|
||||
import httpx
|
||||
|
||||
webhook_url = config.get("webhook_url", "")
|
||||
@@ -132,13 +159,14 @@ class NotificationSender:
|
||||
template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"])
|
||||
title = template["title"].format(**data)
|
||||
body = template["body"].format(**data)
|
||||
color = self.FEISHU_COLORS.get(event_type, "blue")
|
||||
|
||||
payload = {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": title},
|
||||
"template": "blue" if "approved" in event_type or "completed" in event_type else "red",
|
||||
"template": color,
|
||||
},
|
||||
"elements": [
|
||||
{"tag": "div", "text": {"tag": "lark_md", "content": body}},
|
||||
@@ -179,7 +207,7 @@ class NotificationSender:
|
||||
|
||||
template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"])
|
||||
subject = template["title"].format(**data)
|
||||
body_text = template["body"].format(**data).replace("\\n", "<br>")
|
||||
body_text = template["body"].format(**data).replace("\n", "<br>")
|
||||
|
||||
html_body = f"""
|
||||
<html>
|
||||
|
||||
@@ -253,8 +253,6 @@ class TicketExecutor:
|
||||
db=ticket.target_database,
|
||||
autocommit=False,
|
||||
connect_timeout=10,
|
||||
read_timeout=timeout,
|
||||
write_timeout=timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -1287,11 +1285,15 @@ class TicketExecutor:
|
||||
if not parsed or not parsed[0].tokens:
|
||||
raise SyntaxValidationError(f"无法解析 SQL: {stmt[:100]}")
|
||||
|
||||
# 检查是否为 DML 语句
|
||||
# 检查是否为合法的 SQL 语句(DML + DDL)
|
||||
sql_type = self._analyzer._detect_sql_type(stmt)
|
||||
if sql_type not in (SQLType.INSERT, SQLType.UPDATE, SQLType.DELETE, SQLType.SELECT):
|
||||
allowed_types = (
|
||||
SQLType.INSERT, SQLType.UPDATE, SQLType.DELETE, SQLType.SELECT,
|
||||
SQLType.CREATE, SQLType.ALTER, SQLType.DROP, SQLType.TRUNCATE,
|
||||
)
|
||||
if sql_type not in allowed_types:
|
||||
raise SyntaxValidationError(
|
||||
f"不支持的 SQL 类型: {sql_type.value}, 仅支持 INSERT/UPDATE/DELETE"
|
||||
f"不支持的 SQL 类型: {sql_type.value}, 仅支持 INSERT/UPDATE/DELETE/CREATE/ALTER/DROP/TRUNCATE"
|
||||
)
|
||||
|
||||
def _validate_postgres_syntax(self, sql_content: str) -> None:
|
||||
|
||||
@@ -60,9 +60,13 @@ async def test_stats_fields_structure(client: AsyncClient, admin_token: str):
|
||||
"instance_online",
|
||||
"user_total",
|
||||
"queries_today",
|
||||
"query_stats",
|
||||
}
|
||||
assert set(data.keys()) == expected_keys
|
||||
for key in expected_keys:
|
||||
if key == "query_stats":
|
||||
assert isinstance(data[key], (dict, type(None))), f"{key} should be dict or None"
|
||||
else:
|
||||
assert isinstance(data[key], int), f"{key} should be int"
|
||||
|
||||
|
||||
@@ -166,23 +170,23 @@ async def test_stats_excludes_inactive_users(
|
||||
async def test_stats_reflect_queries_today(
|
||||
client: AsyncClient, db: AsyncSession, admin_token: str
|
||||
):
|
||||
"""Add audit logs with QUERY action and verify queries_today."""
|
||||
"""Add query histories and verify queries_today counts from QueryHistory."""
|
||||
from app.models.query_history import QueryHistory
|
||||
for i in range(5):
|
||||
log = AuditLog(
|
||||
qh = QueryHistory(
|
||||
user_id=1,
|
||||
action="QUERY",
|
||||
target_type="query",
|
||||
detail="test query",
|
||||
username="admin",
|
||||
instance_id=1,
|
||||
instance_name="test",
|
||||
db_type="mysql",
|
||||
database_name="test_db",
|
||||
query_content="SELECT 1",
|
||||
query_type="SELECT",
|
||||
execution_time_ms=100,
|
||||
result_rows=1,
|
||||
is_success=True,
|
||||
)
|
||||
db.add(log)
|
||||
# also add a non-QUERY log (should be ignored)
|
||||
non_query = AuditLog(
|
||||
user_id=1,
|
||||
action="LOGIN",
|
||||
target_type="session",
|
||||
detail="login event",
|
||||
)
|
||||
db.add(non_query)
|
||||
db.add(qh)
|
||||
await db.commit()
|
||||
|
||||
resp = await client.get(BASE, headers=auth_header(admin_token))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, Database, Server, Zap, Leaf,
|
||||
@@ -20,6 +21,13 @@ export default function Layout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const currentPath = location.pathname.split('/')[1] || 'dashboard';
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
import('@/api/request').then(m => m.default.get('/dashboard/stats'))
|
||||
.then((data: any) => setPendingCount(data?.ticket_pending ?? 0))
|
||||
.catch(() => {});
|
||||
}, [location.pathname]);
|
||||
|
||||
const getPageTitle = () => {
|
||||
const item = visibleMenuItems.find(m => m.id === currentPath);
|
||||
@@ -95,9 +103,14 @@ export default function Layout() {
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</button>
|
||||
<button className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] transition-colors relative">
|
||||
<button onClick={() => navigate('/tickets')}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] transition-colors relative">
|
||||
<Bell className="w-4 h-4" />
|
||||
<span className="absolute top-1 right-1 w-1.5 h-1.5 bg-red-500 rounded-full" />
|
||||
{pendingCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 bg-red-500 rounded-full text-[10px] text-white font-bold flex items-center justify-center">
|
||||
{pendingCount > 99 ? '99+' : pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="w-px h-5 bg-[var(--dg-border)] mx-1" />
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
|
||||
@@ -43,24 +43,9 @@ const priorityStyle: Record<PriorityKey, { border: string; bg: string; icon: Rea
|
||||
const priorityLabel: Record<PriorityKey, string> = { urgent: '紧急', high: '重要', normal: '通知', low: '一般' };
|
||||
|
||||
const trendDatasets: Record<TrendRange, TrendDataset> = {
|
||||
7: {
|
||||
xAxis: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
|
||||
submitted: [120, 132, 101, 134, 90, 230, 210],
|
||||
success: [110, 125, 95, 128, 85, 215, 200],
|
||||
failed: [5, 3, 4, 2, 3, 8, 5],
|
||||
},
|
||||
30: {
|
||||
xAxis: Array.from({ length: 15 }, (_, i) => `${i * 2 + 1}日`),
|
||||
submitted: [85, 92, 78, 105, 120, 95, 88, 110, 125, 140, 95, 82, 115, 130, 145],
|
||||
success: [80, 88, 75, 98, 112, 90, 85, 105, 118, 132, 90, 78, 108, 125, 138],
|
||||
failed: [3, 2, 2, 5, 4, 3, 2, 3, 4, 5, 3, 2, 4, 3, 4],
|
||||
},
|
||||
90: {
|
||||
xAxis: Array.from({ length: 12 }, (_, i) => `${i * 7 + 1}日`),
|
||||
submitted: [520, 480, 610, 590, 720, 680, 750, 800, 720, 650, 580, 620],
|
||||
success: [490, 455, 580, 560, 685, 650, 720, 770, 695, 625, 555, 598],
|
||||
failed: [18, 15, 22, 20, 25, 18, 20, 22, 18, 16, 14, 15],
|
||||
},
|
||||
7: { xAxis: [], submitted: [], success: [], failed: [] },
|
||||
30: { xAxis: [], submitted: [], success: [], failed: [] },
|
||||
90: { xAxis: [], submitted: [], success: [], failed: [] },
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -140,6 +125,7 @@ export default function Dashboard() {
|
||||
const [dbInstances, setDbInstances] = useState<any[]>([]);
|
||||
const [tickets, setTickets] = useState<any[]>([]);
|
||||
const [queryStats, setQueryStats] = useState<any>(null);
|
||||
const [trendData, setTrendData] = useState<TrendDataset>({ xAxis: [], submitted: [], success: [], failed: [] });
|
||||
|
||||
useEffect(() => {
|
||||
import('@/api/request').then(m => m.default.get('/announcements')).then(res => {
|
||||
@@ -160,11 +146,30 @@ export default function Dashboard() {
|
||||
/* ---- Data Fetch ---- */
|
||||
useEffect(() => {
|
||||
dashboardApi.getStats()
|
||||
.then(data => setStats(data))
|
||||
.then(data => {
|
||||
setStats(data);
|
||||
// 关联查询统计
|
||||
if (data.query_stats) setQueryStats(data.query_stats);
|
||||
})
|
||||
.catch(() => setStats(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
/* ---- Trend Data ---- */
|
||||
useEffect(() => {
|
||||
import('@/api/request').then(m => m.default.get('/dashboard/trend', { params: { days: trendRange } }))
|
||||
.then((res: any) => {
|
||||
const items = res?.items || res?.data?.items || [];
|
||||
setTrendData({
|
||||
xAxis: items.map((p: any) => p.date),
|
||||
submitted: items.map((p: any) => p.submitted),
|
||||
success: items.map((p: any) => p.success),
|
||||
failed: items.map((p: any) => p.failed),
|
||||
});
|
||||
})
|
||||
.catch(() => setTrendData({ xAxis: [], submitted: [], success: [], failed: [] }));
|
||||
}, [trendRange]);
|
||||
|
||||
/* ---- Instances & Tickets ---- */
|
||||
useEffect(() => {
|
||||
import('@/api/request').then(m => {
|
||||
@@ -225,7 +230,7 @@ export default function Dashboard() {
|
||||
? { bg: 'rgba(255,255,255,0.95)', border: '#e2e5eb', text: '#1a1d23', muted: '#5c6370', grid: '#f0f1f5', blue: '#2563eb', green: '#16a34a', red: '#dc2626' }
|
||||
: { bg: 'rgba(22, 27, 34, 0.95)', border: '#30363d', text: '#f0f6fc', muted: '#8b949e', grid: '#21262d', blue: '#58a6ff', green: '#3fb950', red: '#f85149' };
|
||||
|
||||
const dataset = trendDatasets[trendRange];
|
||||
const dataset = trendData;
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
backgroundColor: 'transparent',
|
||||
@@ -297,7 +302,7 @@ export default function Dashboard() {
|
||||
const handleResize = () => chart.resize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => { window.removeEventListener('resize', handleResize); };
|
||||
}, [theme, trendRange]);
|
||||
}, [theme, trendRange, trendData]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { chartInstanceRef.current?.dispose(); };
|
||||
|
||||
@@ -657,48 +657,32 @@ export default function TicketCreatePage() {
|
||||
};
|
||||
|
||||
/* ================================================================== */
|
||||
/* MAIN RENDER — 全高布局,参考 QueriesPage */
|
||||
/* MAIN RENDER */
|
||||
/* ================================================================== */
|
||||
|
||||
const [showBrowser, setShowBrowser] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3rem)]">
|
||||
{/* ====== 左侧 Schema 浏览面板 ====== */}
|
||||
{showBrowser && instanceId && database && (
|
||||
<div className="w-[280px] shrink-0 border-r border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2.5 shrink-0">
|
||||
<span className="text-xs font-semibold text-[var(--dg-text-secondary)]">库表浏览</span>
|
||||
<button onClick={() => setShowBrowser(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{renderBrowserPanel()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-[calc(100vh-3rem)] overflow-y-auto">
|
||||
<div className="max-w-5xl mx-auto px-6 py-5 space-y-4">
|
||||
|
||||
{/* ====== 主区域 ====== */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-y-auto">
|
||||
|
||||
{/* -- 顶栏 -- */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] shrink-0 flex-wrap">
|
||||
{/* 返回 */}
|
||||
{/* ====== 顶部:返回 + 工单标题 ====== */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate('/tickets')}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
||||
title="返回工单列表">
|
||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
|
||||
{/* 工单标题 */}
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)}
|
||||
className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm focus:border-blue-400 focus:outline-none min-w-[180px] max-w-[280px]"
|
||||
placeholder="工单标题" />
|
||||
className="flex-1 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium focus:border-blue-400 focus:outline-none transition-colors"
|
||||
placeholder="输入工单标题" />
|
||||
</div>
|
||||
|
||||
{/* ====== 工具栏:实例 + 数据库 + 操作 ====== */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* 实例 */}
|
||||
<select value={instanceId} onChange={(e) => setInstanceId(e.target.value)} disabled={loadingInstances}
|
||||
className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm focus:border-blue-400 focus:outline-none min-w-[160px] disabled:opacity-50">
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[180px] disabled:opacity-50">
|
||||
<option value="">{loadingInstances ? '加载中...' : '选择实例'}</option>
|
||||
{instances.map((inst) => (
|
||||
<option key={inst.id} value={String(inst.id)}>
|
||||
@@ -710,23 +694,21 @@ export default function TicketCreatePage() {
|
||||
{/* 数据库 */}
|
||||
{isRedis ? (
|
||||
<select value={database} onChange={(e) => setDatabase(e.target.value)}
|
||||
className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm focus:border-blue-400 focus:outline-none w-24">
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none w-24">
|
||||
{databases.length > 0 ? databases.map((db) => <option key={db} value={db}>{db}</option>) : <option value="db0">db0</option>}
|
||||
</select>
|
||||
) : databases.length > 0 ? (
|
||||
<select value={database} onChange={(e) => setDatabase(e.target.value)} disabled={loadingDatabases}
|
||||
className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm focus:border-blue-400 focus:outline-none min-w-[140px] disabled:opacity-50">
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[150px] disabled:opacity-50">
|
||||
<option value="">{loadingDatabases ? '加载中...' : '选择数据库'}</option>
|
||||
{databases.map((db) => <option key={db} value={db}>{db}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={database} onChange={(e) => setDatabase(e.target.value)} disabled={!instanceId}
|
||||
className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm focus:border-blue-400 focus:outline-none min-w-[140px] disabled:opacity-50"
|
||||
placeholder={instanceId ? '数据库名' : '选择实例'} />
|
||||
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[150px] disabled:opacity-50"
|
||||
placeholder={instanceId ? '数据库名' : '先选实例'} />
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* 定时执行 */}
|
||||
<label className="flex items-center gap-1.5 cursor-pointer select-none text-xs text-[var(--dg-text-muted)]">
|
||||
<input type="checkbox" checked={enableSchedule} onChange={(e) => setEnableSchedule(e.target.checked)}
|
||||
@@ -737,81 +719,73 @@ export default function TicketCreatePage() {
|
||||
{enableSchedule && (
|
||||
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)}
|
||||
step="1" min={new Date().toISOString().slice(0, 16)}
|
||||
className="px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-xs focus:border-blue-400 focus:outline-none" />
|
||||
className="px-2 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-xs focus:border-blue-400 focus:outline-none" />
|
||||
)}
|
||||
{enableSchedule && scheduledAt && (
|
||||
<span className="text-xs font-semibold text-red-500">
|
||||
{new Date(scheduledAt).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 浏览面板开关 */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* 浏览面板 */}
|
||||
{instanceId && database && (
|
||||
<button onClick={() => setShowBrowser(!showBrowser)}
|
||||
className={`flex items-center gap-1 px-2 py-1.5 text-xs font-medium rounded-md border transition-all ${
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-lg border transition-all ${
|
||||
showBrowser ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
{showBrowser ? '隐藏' : '浏览'}
|
||||
{showBrowser ? '隐藏浏览' : '库表浏览'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 格式化 */}
|
||||
<button onClick={handleFormatSQL} disabled={!sqlContent.trim()}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium text-blue-400 bg-blue-500/10 rounded-md hover:bg-blue-500/20 transition-colors disabled:opacity-30">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
{/* SQL 检测 */}
|
||||
<button onClick={handleValidate}
|
||||
disabled={validating || !sqlContent.trim() || !instanceId || !database}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border rounded-md transition-all disabled:opacity-30 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]">
|
||||
{validating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Shield className="w-3.5 h-3.5" />}
|
||||
SQL 检测
|
||||
</button>
|
||||
|
||||
{/* 提交审批 */}
|
||||
<button onClick={handleSubmit}
|
||||
disabled={submitting || !validated}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-gradient-to-r from-blue-500 to-purple-600 rounded-md text-white hover:opacity-90 disabled:opacity-40 transition-all">
|
||||
{submitting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
||||
提交审批
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium text-blue-400 bg-blue-500/10 rounded-lg hover:bg-blue-500/20 transition-colors disabled:opacity-30">
|
||||
<Sparkles className="w-3 h-3" /> 格式化
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* -- 定时执行提示 -- */}
|
||||
{enableSchedule && scheduledAt && (
|
||||
<div className="px-4 py-1.5 text-xs font-semibold text-red-500 bg-red-500/5 shrink-0">
|
||||
定时执行:{new Date(scheduledAt).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* -- 错误 -- */}
|
||||
{/* ====== 错误提示 ====== */}
|
||||
{error && (
|
||||
<div className="mx-4 mt-3 flex items-center gap-2 text-sm text-red-400 bg-red-500/10 px-3 py-2 rounded-lg border border-red-500/20">
|
||||
<div className="flex items-center gap-2 text-sm text-red-400 bg-red-500/10 px-4 py-2.5 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* -- SQL 编辑器 -- */}
|
||||
<div className="flex-1 min-h-0 p-4">
|
||||
<div className="dg-card overflow-hidden h-full flex flex-col">
|
||||
{/* ====== 编辑器 + 浏览面板 ====== */}
|
||||
<div className="flex gap-4">
|
||||
{/* 浏览面板 */}
|
||||
{showBrowser && instanceId && database && (
|
||||
<div className="w-[260px] shrink-0 bg-[var(--dg-bg-secondary)] rounded-lg border border-[var(--dg-border)] overflow-hidden">
|
||||
<div className="max-h-[420px] overflow-y-auto">
|
||||
{renderBrowserPanel()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SQL 编辑器 */}
|
||||
<div className={`dg-card overflow-hidden flex flex-col ${showBrowser && instanceId && database ? 'flex-1' : 'flex-1'}`}>
|
||||
<SqlEditor value={sqlContent} onChange={handleSqlChange} language="sql"
|
||||
height="100%"
|
||||
height="320px"
|
||||
placeholder="请输入 SQL 变更语句,支持多条 SQL(以分号分隔)..." />
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] text-[var(--dg-text-muted)] shrink-0">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] text-[var(--dg-text-muted)]">
|
||||
<span>
|
||||
{sqlContent.trim() ? sqlContent.trim().split(/;\s*/).filter(Boolean).length + ' 条语句 · ' : ''}
|
||||
{sqlContent.length} 字符
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{validated && <span className="flex items-center gap-1 text-green-400"><Check className="w-3 h-3" />检测通过</span>}
|
||||
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Enter 格式化</span>
|
||||
<span>Ctrl+Enter 格式化</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* -- 变更原因 + 审核结果(底部面板) -- */}
|
||||
<div className="border-t border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] shrink-0">
|
||||
{/* 审核结果 */}
|
||||
{/* ====== 审核结果 ====== */}
|
||||
{auditResult && (
|
||||
<div className="px-4 py-2.5 space-y-2">
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="font-semibold flex items-center gap-1.5">
|
||||
<Shield className="w-3.5 h-3.5 text-blue-400" /> 审核结果
|
||||
@@ -824,25 +798,49 @@ export default function TicketCreatePage() {
|
||||
)}
|
||||
</div>
|
||||
{auditResult.findings.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="space-y-1.5">
|
||||
{auditResult.findings.map((f, idx) => (
|
||||
<span key={idx} className={'inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] ' + (LEVEL_COLOR[f.level] || '')}>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{LEVEL_LABEL[f.level] || f.level}: {f.message}
|
||||
</span>
|
||||
<div key={idx} className={'flex items-center gap-2 px-3 py-2 rounded-lg text-xs ' + (LEVEL_COLOR[f.level] || '')}>
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="font-medium">{LEVEL_LABEL[f.level] || f.level}</span>
|
||||
<span className="opacity-60">·</span>
|
||||
<span className="font-mono opacity-80">{f.rule}</span>
|
||||
<span className="opacity-60">—</span>
|
||||
<span className="flex-1">{f.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 变更原因 */}
|
||||
<div className="px-4 py-2 flex items-center gap-3">
|
||||
<span className="text-xs text-[var(--dg-text-muted)] shrink-0">变更原因 <span className="text-red-400">*</span></span>
|
||||
<input value={reason} onChange={(e) => setReason(e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm focus:border-blue-400 focus:outline-none"
|
||||
{/* ====== 变更原因 ====== */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">
|
||||
变更原因 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<textarea value={reason} onChange={(e) => setReason(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[72px] resize-y focus:outline-none focus:border-blue-400 transition-colors"
|
||||
placeholder="请描述变更原因、影响范围、回滚方案等" />
|
||||
</div>
|
||||
|
||||
{/* ====== 底部操作栏 ====== */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
{/* SQL 检测 */}
|
||||
<button onClick={handleValidate}
|
||||
disabled={validating || !sqlContent.trim() || !instanceId || !database}
|
||||
className="flex items-center gap-1.5 px-5 py-2.5 text-sm font-medium border rounded-lg transition-all disabled:opacity-30 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]">
|
||||
{validating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Shield className="w-4 h-4" />}
|
||||
SQL 检测
|
||||
</button>
|
||||
|
||||
{/* 提交审批 */}
|
||||
<button onClick={handleSubmit}
|
||||
disabled={submitting || !validated}
|
||||
className="flex items-center gap-1.5 px-6 py-2.5 text-sm font-medium bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-white hover:opacity-90 disabled:opacity-40 transition-all">
|
||||
{submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
提交审批
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user