diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index 1a56a1b..e7f5768 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -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) diff --git a/backend/app/api/v1/endpoints/ticket.py b/backend/app/api/v1/endpoints/ticket.py index 57caa29..469f6b5 100644 --- a/backend/app/api/v1/endpoints/ticket.py +++ b/backend/app/api/v1/endpoints/ticket.py @@ -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, diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py index 8e57fa6..706eb15 100644 --- a/backend/app/schemas/dashboard.py +++ b/backend/app/schemas/dashboard.py @@ -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] = [] diff --git a/backend/app/services/notification_sender.py b/backend/app/services/notification_sender.py index c708472..03e20c4 100644 --- a/backend/app/services/notification_sender.py +++ b/backend/app/services/notification_sender.py @@ -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", "
") + body_text = template["body"].format(**data).replace("\n", "
") html_body = f""" diff --git a/backend/app/services/ticket_executor.py b/backend/app/services/ticket_executor.py index 41c3177..5c46a34 100644 --- a/backend/app/services/ticket_executor.py +++ b/backend/app/services/ticket_executor.py @@ -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: diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index 67638f5..21fa988 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -60,10 +60,14 @@ 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: - 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 @@ -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)) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index b43c683..cd6f592 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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' ? : } -
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c09b541..d281b56 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -43,24 +43,9 @@ const priorityStyle: Record = { urgent: '紧急', high: '重要', normal: '通知', low: '一般' }; const trendDatasets: Record = { - 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([]); const [tickets, setTickets] = useState([]); const [queryStats, setQueryStats] = useState(null); + const [trendData, setTrendData] = useState({ 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(); }; diff --git a/frontend/src/pages/TicketCreatePage.tsx b/frontend/src/pages/TicketCreatePage.tsx index 702f7fb..197e7da 100644 --- a/frontend/src/pages/TicketCreatePage.tsx +++ b/frontend/src/pages/TicketCreatePage.tsx @@ -657,48 +657,32 @@ export default function TicketCreatePage() { }; /* ================================================================== */ - /* MAIN RENDER — 全高布局,参考 QueriesPage */ + /* MAIN RENDER */ /* ================================================================== */ const [showBrowser, setShowBrowser] = useState(true); return ( -
- {/* ====== 左侧 Schema 浏览面板 ====== */} - {showBrowser && instanceId && database && ( -
-
- 库表浏览 - -
-
- {renderBrowserPanel()} -
-
- )} +
+
- {/* ====== 主区域 ====== */} -
- - {/* -- 顶栏 -- */} -
- {/* 返回 */} + {/* ====== 顶部:返回 + 工单标题 ====== */} +
- - {/* 工单标题 */} 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="输入工单标题" /> +
+ {/* ====== 工具栏:实例 + 数据库 + 操作 ====== */} +
{/* 实例 */} 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) => ) : } ) : databases.length > 0 ? ( ) : ( 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 ? '数据库名' : '先选实例'} /> )} -
- {/* 定时执行 */}