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

+15 -2
View File
@@ -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">
+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 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(); };
+102 -104
View File
@@ -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,112 +719,128 @@ 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="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" /> 审核结果
</span>
{errorCount > 0 && <span className="text-red-400 font-medium">{errorCount} 错误</span>}
{warnCount > 0 && <span className="text-amber-400 font-medium">{warnCount} 警告</span>}
{infoCount > 0 && <span className="text-blue-400 font-medium">{infoCount} 提示</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>
{/* ====== 审核结果 ====== */}
{auditResult && (
<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" /> 审核结果
</span>
{errorCount > 0 && <span className="text-red-400 font-medium">{errorCount} 错误</span>}
{warnCount > 0 && <span className="text-amber-400 font-medium">{warnCount} 警告</span>}
{infoCount > 0 && <span className="text-blue-400 font-medium">{infoCount} 提示</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>
)}
{/* 变更原因 */}
<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"
placeholder="请描述变更原因、影响范围、回滚方案等" />
{auditResult.findings.length > 0 && (
<div className="space-y-1.5">
{auditResult.findings.map((f, idx) => (
<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>
<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>