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:
logan committed 2026-05-25 01:29:24 +08:00
1 parent 7109031f71
commit 880dbfefa4
9 files changed
+423 -209

No files matched your search

+77 -6
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func from sqlalchemy import select, func, case, extract
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db 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.ticket import Ticket
from app.models.instance import Instance from app.models.instance import Instance
from app.models.audit_log import AuditLog 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=["仪表盘"]) router = APIRouter(prefix="/dashboard", tags=["仪表盘"])
@@ -44,12 +45,41 @@ async def get_stats(
)).scalar() or 0 )).scalar() or 0
queries_today = (await db.execute( queries_today = (await db.execute(
select(func.count(AuditLog.id)).where( select(func.count(QueryHistory.id)).where(
AuditLog.action == "QUERY", QueryHistory.created_at >= today_start,
AuditLog.created_at >= today_start,
) )
)).scalar() or 0 )).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( return DashboardStats(
ticket_total=ticket_total, ticket_total=ticket_total,
ticket_pending=ticket_pending, ticket_pending=ticket_pending,
@@ -59,4 +89,45 @@ async def get_stats(
instance_online=instance_online, instance_online=instance_online,
user_total=user_total, user_total=user_total,
queries_today=queries_today, 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)
+108 -34
View File
@@ -447,6 +447,12 @@ async def create_ticket(
await db.commit() await db.commit()
await db.refresh(ticket) 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) result = await _enrich_ticket(ticket, db)
if v2_report: if v2_report:
result["audit_report"] = v2_report.to_dict() result["audit_report"] = v2_report.to_dict()
@@ -663,6 +669,17 @@ async def review_ticket(
await db.commit() await db.commit()
await db.refresh(ticket) 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) return await _enrich_ticket(ticket, db)
@@ -1027,65 +1044,122 @@ async def execute_ticket(
return ticket_data return ticket_data
async def _send_execution_notification( async def _get_user_name(db: AsyncSession, user_id: Optional[int]) -> str:
db: AsyncSession, ticket: Ticket, report: "ExecutionReport" """获取用户显示名称。"""
) -> None: 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: Args:
db: 数据库会话 db: 数据库会话
ticket: 工单对象 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 from app.services.notification_sender import NotificationSender
result = await db.execute( channels = await _get_enabled_channels(db)
select(NotificationChannel).where(NotificationChannel.is_enabled == True)
)
channels = result.scalars().all()
if not channels: if not channels:
return return
sender = NotificationSender() # 获取关联信息
applicant_name = await _get_user_name(db, ticket.applicant_id)
# 确定事件类型 instance_name = await _get_instance_name(db, ticket.instance_id)
if report.status == "success":
event_type = "ticket_executed"
else:
event_type = "ticket_failed"
# 构建 SQL 预览(前 100 字符)
sql_preview = ticket.sql_content[:100] + "..." if len(ticket.sql_content) > 100 else ticket.sql_content 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 = { notification_data = {
"title": ticket.title, "title": ticket.title,
"ticket_id": ticket.id, "ticket_id": ticket.id,
"ticket_no": ticket.ticket_no or str(ticket.id),
"db_type": ticket.db_type, "db_type": ticket.db_type,
"target_database": ticket.target_database, "target_database": ticket.target_database or "",
"instance_name": instance_name,
"sql_preview": sql_preview, "sql_preview": sql_preview,
"priority": ticket.priority,
"applicant": applicant_name, "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, "result": report.status,
"duration": report.duration_ms, "duration": report.duration_ms,
"affected_rows": report.affected_rows, "affected_rows": report.affected_rows,
"error": "; ".join(report.errors[:3]) if report.errors else "", "error": "; ".join(report.errors[:3]) if report.errors else "",
"operator": "dba", "approver": operator_name,
"comment": "",
} }
sender = NotificationSender()
await sender.notify_ticket_event( await sender.notify_ticket_event(
ticket_id=ticket.id, ticket_id=ticket.id,
event_type=event_type, event_type=event_type,
+20 -1
View File
@@ -1,5 +1,12 @@
from pydantic import BaseModel 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): class DashboardStats(BaseModel):
@@ -11,3 +18,15 @@ class DashboardStats(BaseModel):
instance_online: int = 0 instance_online: int = 0
user_total: int = 0 user_total: int = 0
queries_today: 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] = []
+49 -21
View File
@@ -18,43 +18,69 @@ class NotificationSender:
# 内置消息模板 # 内置消息模板
TEMPLATES = { TEMPLATES = {
"ticket_created": { "ticket_created": {
"title": "新工单待审批: {title}", "title": "新工单待审批",
"body": "**申请人**: {applicant}\n**数据库**: {db_type}@{target_database}\n**SQL摘要**: {sql_preview}", "body": "**申请人**: {applicant}\n**目标实例**: {instance_name} ({db_type})\n**目标库**: {target_database}\n**工单标题**: {title}\n**SQL摘要**: {sql_preview}",
}, },
"ticket_approved": { "ticket_approved": {
"title": "工单已审批通过: {title}", "title": "工单已审批通过",
"body": "**审批人**: {approver}\n**工单ID**: {ticket_id}\n**状态**: 已批准,可执行", "body": "**工单标题**: {title}\n**审批人**: {approver}\n**目标实例**: {instance_name} ({db_type})\n**状态**: 已批准,可执行",
}, },
"ticket_rejected": { "ticket_rejected": {
"title": "工单已被拒绝: {title}", "title": "工单已被拒绝",
"body": "**审批人**: {approver}\n**拒绝原因**: {comment}\n**工单ID**: {ticket_id}", "body": "**工单标题**: {title}\n**审批人**: {approver}\n**拒绝原因**: {comment}\n**目标实例**: {instance_name} ({db_type})",
}, },
"ticket_executed": { "ticket_executed": {
"title": "工单执行完成: {title}", "title": "工单执行完成",
"body": "**执行结果**: {result}\n**耗时**: {duration}ms\n**影响行数**: {affected_rows}", "body": "**工单标题**: {title}\n**执行人**: {operator}\n**目标实例**: {instance_name} ({db_type})\n**目标库**: {target_database}\n**执行结果**: 成功\n**耗时**: {duration}ms\n**影响行数**: {affected_rows}",
}, },
"ticket_failed": { "ticket_failed": {
"title": "工单执行失败: {title}", "title": "工单执行失败",
"body": "**错误信息**: {error}\n**工单ID**: {ticket_id}\n请及时处理", "body": "**工单标题**: {title}\n**执行人**: {operator}\n**目标实例**: {instance_name} ({db_type})\n**目标库**: {target_database}\n**错误信息**: {error}",
}, },
"ticket_rollback": { "ticket_rollback": {
"title": "工单已回滚: {title}", "title": "工单已回滚",
"body": "**回滚人**: {operator}\n**工单ID**: {ticket_id}", "body": "**回滚人**: {operator}\n**工单ID**: {ticket_id}",
}, },
"backup_completed": { "backup_completed": {
"title": "备份完成: {title}", "title": "备份完成",
"body": "**数据库**: {database}\n**文件大小**: {size}\n**耗时**: {duration}s", "body": "**数据库**: {database}\n**文件大小**: {size}\n**耗时**: {duration}s",
}, },
"backup_failed": { "backup_failed": {
"title": "备份失败: {title}", "title": "备份失败",
"body": "**错误信息**: {error}\n请检查数据库连接和磁盘空间", "body": "**错误信息**: {error}\n请检查数据库连接和磁盘空间",
}, },
"schema_drift_detected": { "schema_drift_detected": {
"title": "检测到 Schema 漂移: {instance_name}", "title": "检测到 Schema 漂移",
"body": "**数据库**: {database}\n**发现 {drift_count} 处变更**:\n{drift_details}", "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], async def send(self, channel_type: str, config: Dict[str, Any],
event_type: str, data: Dict[str, Any]) -> Dict[str, Any]: event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""发送通知。 """发送通知。
@@ -95,11 +121,12 @@ class NotificationSender:
if "?" in webhook_url: if "?" in webhook_url:
webhook_url = f"{webhook_url}&timestamp={timestamp}&sign={sign}" webhook_url = f"{webhook_url}&timestamp={timestamp}&sign={sign}"
else: else:
webhook_url = f"{webhook_url}&timestamp={timestamp}&sign={sign}" webhook_url = f"{webhook_url}?timestamp={timestamp}&sign={sign}"
template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"]) template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"])
title = template["title"].format(**data) title = template["title"].format(**data)
body = template["body"].format(**data) body = template["body"].format(**data)
tag = self.DINGTALK_TAGS.get(event_type, "📌")
# 钉钉 markdown 单个 \n 视为空格,需要 \n\n 才能换行 # 钉钉 markdown 单个 \n 视为空格,需要 \n\n 才能换行
body = body.replace("\n", "\n\n") body = body.replace("\n", "\n\n")
@@ -107,8 +134,8 @@ class NotificationSender:
payload = { payload = {
"msgtype": "markdown", "msgtype": "markdown",
"markdown": { "markdown": {
"title": title, "title": f"{tag} {title}",
"text": f"## {title}\n\n{body}\n\n---\n*来自 SQL 审核平台*", "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, async def _send_feishu(self, config: Dict[str, Any], event_type: str,
data: Dict[str, Any]) -> Dict[str, Any]: data: Dict[str, Any]) -> Dict[str, Any]:
"""发送飞书消息。""" """发送飞书消息 — 使用 Interactive Card。"""
import httpx import httpx
webhook_url = config.get("webhook_url", "") webhook_url = config.get("webhook_url", "")
@@ -132,13 +159,14 @@ class NotificationSender:
template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"]) template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"])
title = template["title"].format(**data) title = template["title"].format(**data)
body = template["body"].format(**data) body = template["body"].format(**data)
color = self.FEISHU_COLORS.get(event_type, "blue")
payload = { payload = {
"msg_type": "interactive", "msg_type": "interactive",
"card": { "card": {
"header": { "header": {
"title": {"tag": "plain_text", "content": title}, "title": {"tag": "plain_text", "content": title},
"template": "blue" if "approved" in event_type or "completed" in event_type else "red", "template": color,
}, },
"elements": [ "elements": [
{"tag": "div", "text": {"tag": "lark_md", "content": body}}, {"tag": "div", "text": {"tag": "lark_md", "content": body}},
@@ -179,7 +207,7 @@ class NotificationSender:
template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"]) template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"])
subject = template["title"].format(**data) 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_body = f"""
<html> <html>
+7 -5
View File
@@ -253,8 +253,6 @@ class TicketExecutor:
db=ticket.target_database, db=ticket.target_database,
autocommit=False, autocommit=False,
connect_timeout=10, connect_timeout=10,
read_timeout=timeout,
write_timeout=timeout,
) )
try: try:
@@ -1287,11 +1285,15 @@ class TicketExecutor:
if not parsed or not parsed[0].tokens: if not parsed or not parsed[0].tokens:
raise SyntaxValidationError(f"无法解析 SQL: {stmt[:100]}") raise SyntaxValidationError(f"无法解析 SQL: {stmt[:100]}")
# 检查是否为 DML 语句 # 检查是否为合法的 SQL 语句(DML + DDL)
sql_type = self._analyzer._detect_sql_type(stmt) 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( 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: def _validate_postgres_syntax(self, sql_content: str) -> None:
+19 -15
View File
@@ -60,10 +60,14 @@ async def test_stats_fields_structure(client: AsyncClient, admin_token: str):
"instance_online", "instance_online",
"user_total", "user_total",
"queries_today", "queries_today",
"query_stats",
} }
assert set(data.keys()) == expected_keys assert set(data.keys()) == expected_keys
for key in expected_keys: for key in expected_keys:
assert isinstance(data[key], int), f"{key} should be int" 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"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -166,23 +170,23 @@ async def test_stats_excludes_inactive_users(
async def test_stats_reflect_queries_today( async def test_stats_reflect_queries_today(
client: AsyncClient, db: AsyncSession, admin_token: str 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): for i in range(5):
log = AuditLog( qh = QueryHistory(
user_id=1, user_id=1,
action="QUERY", username="admin",
target_type="query", instance_id=1,
detail="test query", 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) db.add(qh)
# 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)
await db.commit() await db.commit()
resp = await client.get(BASE, headers=auth_header(admin_token)) resp = await client.get(BASE, headers=auth_header(admin_token))
+15 -2
View File
@@ -1,3 +1,4 @@
import { useState, useEffect } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { import {
LayoutDashboard, Database, Server, Zap, Leaf, LayoutDashboard, Database, Server, Zap, Leaf,
@@ -20,6 +21,13 @@ export default function Layout() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const currentPath = location.pathname.split('/')[1] || 'dashboard'; 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 getPageTitle = () => {
const item = visibleMenuItems.find(m => m.id === currentPath); 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" />} {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button> </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" /> <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> </button>
<div className="w-px h-5 bg-[var(--dg-border)] mx-1" /> <div className="w-px h-5 bg-[var(--dg-border)] mx-1" />
<div className="flex items-center gap-2 pl-1"> <div className="flex items-center gap-2 pl-1">
+26 -21
View File
@@ -43,24 +43,9 @@ const priorityStyle: Record<PriorityKey, { border: string; bg: string; icon: Rea
const priorityLabel: Record<PriorityKey, string> = { urgent: '紧急', high: '重要', normal: '通知', low: '一般' }; const priorityLabel: Record<PriorityKey, string> = { urgent: '紧急', high: '重要', normal: '通知', low: '一般' };
const trendDatasets: Record<TrendRange, TrendDataset> = { const trendDatasets: Record<TrendRange, TrendDataset> = {
7: { 7: { xAxis: [], submitted: [], success: [], failed: [] },
xAxis: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'], 30: { xAxis: [], submitted: [], success: [], failed: [] },
submitted: [120, 132, 101, 134, 90, 230, 210], 90: { xAxis: [], submitted: [], success: [], failed: [] },
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],
},
}; };
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -140,6 +125,7 @@ export default function Dashboard() {
const [dbInstances, setDbInstances] = useState<any[]>([]); const [dbInstances, setDbInstances] = useState<any[]>([]);
const [tickets, setTickets] = useState<any[]>([]); const [tickets, setTickets] = useState<any[]>([]);
const [queryStats, setQueryStats] = useState<any>(null); const [queryStats, setQueryStats] = useState<any>(null);
const [trendData, setTrendData] = useState<TrendDataset>({ xAxis: [], submitted: [], success: [], failed: [] });
useEffect(() => { useEffect(() => {
import('@/api/request').then(m => m.default.get('/announcements')).then(res => { import('@/api/request').then(m => m.default.get('/announcements')).then(res => {
@@ -160,11 +146,30 @@ export default function Dashboard() {
/* ---- Data Fetch ---- */ /* ---- Data Fetch ---- */
useEffect(() => { useEffect(() => {
dashboardApi.getStats() dashboardApi.getStats()
.then(data => setStats(data)) .then(data => {
setStats(data);
// 关联查询统计
if (data.query_stats) setQueryStats(data.query_stats);
})
.catch(() => setStats(null)) .catch(() => setStats(null))
.finally(() => setLoading(false)); .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 ---- */ /* ---- Instances & Tickets ---- */
useEffect(() => { useEffect(() => {
import('@/api/request').then(m => { 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(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' }; : { 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 = { const option: echarts.EChartsOption = {
backgroundColor: 'transparent', backgroundColor: 'transparent',
@@ -297,7 +302,7 @@ export default function Dashboard() {
const handleResize = () => chart.resize(); const handleResize = () => chart.resize();
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
return () => { window.removeEventListener('resize', handleResize); }; return () => { window.removeEventListener('resize', handleResize); };
}, [theme, trendRange]); }, [theme, trendRange, trendData]);
useEffect(() => { useEffect(() => {
return () => { chartInstanceRef.current?.dispose(); }; return () => { chartInstanceRef.current?.dispose(); };
+102 -104
View File
@@ -657,48 +657,32 @@ export default function TicketCreatePage() {
}; };
/* ================================================================== */ /* ================================================================== */
/* MAIN RENDER — 全高布局,参考 QueriesPage */ /* MAIN RENDER */
/* ================================================================== */ /* ================================================================== */
const [showBrowser, setShowBrowser] = useState(true); const [showBrowser, setShowBrowser] = useState(true);
return ( return (
<div className="flex h-[calc(100vh-3rem)]"> <div className="h-[calc(100vh-3rem)] overflow-y-auto">
{/* ====== 左侧 Schema 浏览面板 ====== */} <div className="max-w-5xl mx-auto px-6 py-5 space-y-4">
{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="flex-1 flex flex-col min-w-0 overflow-y-auto"> <div className="flex items-center gap-3">
{/* -- 顶栏 -- */}
<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">
{/* 返回 */}
<button onClick={() => navigate('/tickets')} <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="返回工单列表"> title="返回工单列表">
<ChevronRight className="w-4 h-4 rotate-180" /> <ChevronRight className="w-4 h-4 rotate-180" />
</button> </button>
{/* 工单标题 */}
<input value={title} onChange={(e) => setTitle(e.target.value)} <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]" 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="工单标题" /> placeholder="输入工单标题" />
</div>
{/* ====== 工具栏:实例 + 数据库 + 操作 ====== */}
<div className="flex items-center gap-3 flex-wrap">
{/* 实例 */} {/* 实例 */}
<select value={instanceId} onChange={(e) => setInstanceId(e.target.value)} disabled={loadingInstances} <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> <option value="">{loadingInstances ? '加载中...' : '选择实例'}</option>
{instances.map((inst) => ( {instances.map((inst) => (
<option key={inst.id} value={String(inst.id)}> <option key={inst.id} value={String(inst.id)}>
@@ -710,23 +694,21 @@ export default function TicketCreatePage() {
{/* 数据库 */} {/* 数据库 */}
{isRedis ? ( {isRedis ? (
<select value={database} onChange={(e) => setDatabase(e.target.value)} <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>} {databases.length > 0 ? databases.map((db) => <option key={db} value={db}>{db}</option>) : <option value="db0">db0</option>}
</select> </select>
) : databases.length > 0 ? ( ) : databases.length > 0 ? (
<select value={database} onChange={(e) => setDatabase(e.target.value)} disabled={loadingDatabases} <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> <option value="">{loadingDatabases ? '加载中...' : '选择数据库'}</option>
{databases.map((db) => <option key={db} value={db}>{db}</option>)} {databases.map((db) => <option key={db} value={db}>{db}</option>)}
</select> </select>
) : ( ) : (
<input value={database} onChange={(e) => setDatabase(e.target.value)} disabled={!instanceId} <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" 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 ? '数据库名' : '选择实例'} /> 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)]"> <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)} <input type="checkbox" checked={enableSchedule} onChange={(e) => setEnableSchedule(e.target.checked)}
@@ -737,112 +719,128 @@ export default function TicketCreatePage() {
{enableSchedule && ( {enableSchedule && (
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} <input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)}
step="1" min={new Date().toISOString().slice(0, 16)} 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 && ( {instanceId && database && (
<button onClick={() => setShowBrowser(!showBrowser)} <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)]' 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" /> <Database className="w-3.5 h-3.5" />
{showBrowser ? '隐藏' : '浏览'} {showBrowser ? '隐藏浏览' : '库表浏览'}
</button> </button>
)} )}
{/* 格式化 */} {/* 格式化 */}
<button onClick={handleFormatSQL} disabled={!sqlContent.trim()} <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"> 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" /> <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" />}
提交审批
</button> </button>
</div> </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 && ( {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} <AlertTriangle className="w-4 h-4 shrink-0" />{error}
</div> </div>
)} )}
{/* -- SQL 编辑器 -- */} {/* ====== 编辑器 + 浏览面板 ====== */}
<div className="flex-1 min-h-0 p-4"> <div className="flex gap-4">
<div className="dg-card overflow-hidden h-full flex flex-col"> {/* 浏览面板 */}
{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" <SqlEditor value={sqlContent} onChange={handleSqlChange} language="sql"
height="100%" height="320px"
placeholder="请输入 SQL 变更语句,支持多条 SQL(以分号分隔)..." /> 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> <span>
{sqlContent.trim() ? sqlContent.trim().split(/;\s*/).filter(Boolean).length + ' 条语句 · ' : ''} {sqlContent.trim() ? sqlContent.trim().split(/;\s*/).filter(Boolean).length + ' 条语句 · ' : ''}
{sqlContent.length} 字符 {sqlContent.length} 字符
</span> </span>
<div className="flex items-center gap-2"> <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>} {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> </div>
</div> </div>
{/* -- 变更原因 + 审核结果(底部面板) -- */} {/* ====== 审核结果 ====== */}
<div className="border-t border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] shrink-0"> {auditResult && (
{/* 审核结果 */} <div className="bg-[var(--dg-bg-secondary)] rounded-lg p-4 space-y-3">
{auditResult && ( <div className="flex items-center gap-3 text-xs">
<div className="px-4 py-2.5 space-y-2"> <span className="font-semibold flex items-center gap-1.5">
<div className="flex items-center gap-3 text-xs"> <Shield className="w-3.5 h-3.5 text-blue-400" /> 审核结果
<span className="font-semibold flex items-center gap-1.5"> </span>
<Shield className="w-3.5 h-3.5 text-blue-400" /> 审核结果 {errorCount > 0 && <span className="text-red-400 font-medium">{errorCount} 错误</span>}
</span> {warnCount > 0 && <span className="text-amber-400 font-medium">{warnCount} 警告</span>}
{errorCount > 0 && <span className="text-red-400 font-medium">{errorCount} 错误</span>} {infoCount > 0 && <span className="text-blue-400 font-medium">{infoCount} 提示</span>}
{warnCount > 0 && <span className="text-amber-400 font-medium">{warnCount} 警告</span>} {errorCount === 0 && warnCount === 0 && infoCount === 0 && (
{infoCount > 0 && <span className="text-blue-400 font-medium">{infoCount} 提示</span>} <span className="text-green-400 font-medium flex items-center gap-1"><Check className="w-3.5 h-3.5" /> 通过</span>
{errorCount === 0 && warnCount === 0 && infoCount === 0 && (
<span className="text-green-400 font-medium flex items-center gap-1"><Check className="w-3.5 h-3.5" /> 通过</span>
)}
</div>
{auditResult.findings.length > 0 && (
<div className="flex flex-wrap gap-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>
)} )}
</div> </div>
)} {auditResult.findings.length > 0 && (
<div className="space-y-1.5">
{/* 变更原因 */} {auditResult.findings.map((f, idx) => (
<div className="px-4 py-2 flex items-center gap-3"> <div key={idx} className={'flex items-center gap-2 px-3 py-2 rounded-lg text-xs ' + (LEVEL_COLOR[f.level] || '')}>
<span className="text-xs text-[var(--dg-text-muted)] shrink-0">变更原因 <span className="text-red-400">*</span></span> <AlertTriangle className="w-3.5 h-3.5 shrink-0" />
<input value={reason} onChange={(e) => setReason(e.target.value)} <span className="font-medium">{LEVEL_LABEL[f.level] || f.level}</span>
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" <span className="opacity-60">·</span>
placeholder="请描述变更原因、影响范围、回滚方案等" /> <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>
)}
{/* ====== 变更原因 ====== */}
<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> </div>
</div> </div>