feat: 工单权限过滤+仪表盘优化+LDAP登录+审批流程+待处理工单

后端:
- ticket_statistics/list_tickets 按用户身份过滤(admin全部, dba/auditor审批相关, developer自己的)
- 新增 /dashboard/my-tickets 接口(按角色返回待处理工单)
- auth.py 本地优先+LDAP fallback 登录认证
- ldap_config.py LDAPS 证书验证修复
- instance_detail.py developer/auditor 权限修复

前端:
- Dashboard 仪表盘: 3核心大数字指标+公告+我的待处理工单
- 删除 ECharts 趋势图(无真实数据)
- TicketDetailPage 审批流程时间线展示
- TicketsPage 工单统计按用户过滤+关联列(我提交的/待我审批)
- types 新增 TicketApproval 等类型定义
This commit is contained in:
logan committed 2026-05-25 20:47:15 +08:00
1 parent be2d6f3894
commit 43d1f3a234
10 files changed
+587 -440

No files matched your search

+3
View File
@@ -22,6 +22,9 @@ export var ticketApi = {
timeline: function (id: number) {
return request.get('/tickets/' + id + '/timeline');
},
approvals: function (id: number) {
return request.get('/tickets/' + id + '/approvals');
},
cancel: function (id: number) {
return request.post('/tickets/' + id + '/cancel');
},
+118 -385
View File
@@ -1,53 +1,29 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ClipboardList, Clock, CheckCircle, AlertTriangle,
Database, ChevronRight, X, AlertCircle, Info, Bell,
Database, ChevronRight, X, Bell, Eye,
Activity, TrendingUp, Zap, Server, Search,
FileText, ArrowUpRight, ArrowDownRight, Minus,
Filter, Eye, BarChart3,
FileText, ArrowUpRight, ArrowDownRight,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/constants';
import type { DBInstance } from '@/types';
import { useTheme } from '@/hooks/useTheme';
import { useAuth } from '@/hooks/useAuth';
import { dashboardApi } from '@/api/dashboard';
import type { DashboardStats } from '@/types';
import * as echarts from 'echarts';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type PriorityKey = 'urgent' | 'high' | 'normal' | 'low';
type TrendRange = 7 | 30 | 90;
type AnnouncementFilter = 'all' | PriorityKey;
interface TrendDataset {
xAxis: string[];
submitted: number[];
success: number[];
failed: number[];
interface QuickAction {
label: string;
icon: any;
onClick: () => void;
color: string;
bg: string;
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const priorityStyle: Record<PriorityKey, { border: string; bg: string; icon: React.ElementType; iconColor: string; badge: string }> = {
urgent: { border: 'border-l-red-500', bg: 'bg-red-500/5', icon: AlertCircle, iconColor: 'text-red-400', badge: 'bg-red-500/15 text-red-400 border-red-500/30' },
high: { border: 'border-l-orange-500', bg: 'bg-orange-500/5', icon: AlertTriangle, iconColor: 'text-orange-400', badge: 'bg-orange-500/15 text-orange-400 border-orange-500/30' },
normal: { border: 'border-l-blue-500', bg: 'bg-blue-500/5', icon: Info, iconColor: 'text-blue-400', badge: 'bg-blue-500/15 text-blue-400 border-blue-500/30' },
low: { border: 'border-l-gray-500', bg: 'bg-gray-500/5', icon: Bell, iconColor: 'text-gray-400', badge: 'bg-gray-500/15 text-gray-400 border-gray-500/30' },
};
const priorityLabel: Record<PriorityKey, string> = { urgent: '紧急', high: '重要', normal: '通知', low: '一般' };
const trendDatasets: Record<TrendRange, TrendDataset> = {
7: { xAxis: [], submitted: [], success: [], failed: [] },
30: { xAxis: [], submitted: [], success: [], failed: [] },
90: { xAxis: [], submitted: [], success: [], failed: [] },
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
@@ -95,37 +71,23 @@ function StatCardSkeleton() {
);
}
function ChartSkeleton() {
return (
<div className="h-[300px] flex items-center justify-center">
<div className="w-full h-full animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
</div>
);
}
/* ------------------------------------------------------------------ */
/* Main Dashboard */
/* ------------------------------------------------------------------ */
export default function Dashboard() {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
const { theme } = useTheme();
const theme = useTheme();
const { user } = useAuth();
const navigate = useNavigate();
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
const [readIds, setReadIds] = useState<Set<string>>(new Set());
const [expandedId, setExpandedId] = useState<string | null>(null);
const [annFilter, setAnnFilter] = useState<AnnouncementFilter>('all');
const [trendRange, setTrendRange] = useState<TrendRange>(7);
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [chartReady, setChartReady] = useState(false);
const [announcements, setAnnouncements] = useState<any[]>([]);
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 => {
@@ -155,20 +117,7 @@ export default function Dashboard() {
.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(() => {
@@ -183,14 +132,9 @@ export default function Dashboard() {
qps: i.qps ?? 0, connections: i.connections ?? 0,
})));
}).catch(() => setDbInstances([]));
m.default.get('/tickets', { params: { page: 1, page_size: 50 } }).then((res: any) => {
const list = Array.isArray(res) ? res : (res?.items || res?.data || []);
setTickets(list.map((t: any) => ({
id: t.id, title: t.title || '', status: t.status || 'pending',
dbType: t.db_type || t.dbType || '',
applicant: t.created_by || t.applicant || '',
createTime: t.created_at || t.createTime || '',
})));
m.default.get('/dashboard/my-tickets').then((res: any) => {
const list = Array.isArray(res) ? res : [];
setTickets(list);
}).catch(() => setTickets([]));
});
}, []);
@@ -198,148 +142,39 @@ export default function Dashboard() {
/* ---- Announcements ---- */
const visibleAnnouncements = useMemo(() => {
return announcements
.filter(a => a.enabled && a.targetRoles.includes(user.role) && !dismissedIds.has(a.id))
.filter(a => annFilter === 'all' || a.priority === annFilter)
.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
const po: Record<PriorityKey, number> = { urgent: 0, high: 1, normal: 2, low: 3 };
if (po[a.priority as PriorityKey] !== po[b.priority as PriorityKey]) return po[a.priority as PriorityKey] - po[b.priority as PriorityKey];
return new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime();
});
}, [dismissedIds, annFilter, user.role]);
.filter(a => a.enabled && !dismissedIds.has(a.id))
.sort((a, b) => new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime());
}, [dismissedIds, announcements]);
const dismiss = useCallback((id: string) => setDismissedIds(prev => new Set(prev).add(id)), []);
const toggleRead = useCallback((id: string) => {
setReadIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const toggleExpand = useCallback((id: string) => setExpandedId(prev => prev === id ? null : id), []);
/* ---- Chart ---- */
useEffect(() => {
if (!chartRef.current) return;
if (!chartInstanceRef.current) {
chartInstanceRef.current = echarts.init(chartRef.current);
}
const chart = chartInstanceRef.current;
const isLight = theme === 'light';
const colors = isLight
? { 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 = trendData;
const option: echarts.EChartsOption = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: colors.bg,
borderColor: colors.border,
textStyle: { color: colors.text },
},
legend: {
data: ['提交工单', '执行成功', '执行失败'],
textStyle: { color: colors.muted },
bottom: 0,
itemWidth: 14,
itemHeight: 8,
},
grid: { left: '2%', right: '3%', bottom: '18%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: dataset.xAxis,
axisLine: { lineStyle: { color: colors.border } },
axisLabel: { color: colors.muted, fontSize: 11 },
},
yAxis: {
type: 'value',
axisLine: { show: false },
splitLine: { lineStyle: { color: colors.grid } },
axisLabel: { color: colors.muted, fontSize: 11 },
},
series: [
{
name: '提交工单', type: 'line', smooth: true,
data: dataset.submitted,
itemStyle: { color: colors.blue },
lineStyle: { width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: isLight ? 'rgba(37, 99, 235, 0.15)' : 'rgba(88, 166, 255, 0.3)' },
{ offset: 1, color: 'transparent' },
]),
},
symbol: 'none',
},
{
name: '执行成功', type: 'line', smooth: true,
data: dataset.success,
itemStyle: { color: colors.green },
lineStyle: { width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: isLight ? 'rgba(22, 163, 74, 0.15)' : 'rgba(63, 185, 80, 0.3)' },
{ offset: 1, color: 'transparent' },
]),
},
symbol: 'none',
},
{
name: '执行失败', type: 'line', smooth: true,
data: dataset.failed,
itemStyle: { color: colors.red },
lineStyle: { width: 2 },
symbol: 'none',
},
],
};
chart.setOption(option, true);
setChartReady(true);
const handleResize = () => chart.resize();
window.addEventListener('resize', handleResize);
return () => { window.removeEventListener('resize', handleResize); };
}, [theme, trendRange, trendData]);
useEffect(() => {
return () => { chartInstanceRef.current?.dispose(); };
}, []);
/* ---- Computed Data ---- */
const onlineInstances = dbInstances.filter(i => i.status === 'online').length;
const warningInstances = dbInstances.filter(i => i.status === 'warning').length;
const offlineInstances = dbInstances.filter(i => i.status === 'offline').length;
const pendingTickets = tickets.filter(t => t.status === 'pending' || t.status === 'reviewing').slice(0, 5);
const pendingTickets = tickets;
const statsCards = useMemo(() => {
if (!stats) return [];
const queryTotal = stats.query_stats?.total ?? 0;
return [
{
title: '工单总数', value: stats.ticket_total, icon: ClipboardList,
color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20',
change: '+8.2%', trend: 'up' as const,
subtitle: `${stats.ticket_pending} 个待审批`,
title: '工单总数', value: stats.ticket_total.toLocaleString(), icon: ClipboardList,
color: 'text-blue-400', bg: 'bg-blue-400/10',
subtitle: `${stats.ticket_pending} 个待审批 · ${stats.ticket_executing} 个执行中`,
},
{
title: '待审批', value: stats.ticket_pending, icon: Clock,
color: 'text-orange-400', bg: 'bg-orange-400/10', border: 'border-orange-400/20',
change: '+2', trend: 'up' as const,
subtitle: `本月已处理 ${stats.ticket_month}`,
title: '查询总数', value: queryTotal.toLocaleString(), icon: Search,
color: 'text-green-400', bg: 'bg-green-400/10',
subtitle: `今日 ${stats.queries_today} 次 · 平均 ${stats.query_stats?.avg_time_ms ?? 0}ms`,
},
{
title: '本月工单', value: stats.ticket_month, icon: CheckCircle,
color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20',
change: '-3.1%', trend: 'down' as const,
subtitle: `${stats.ticket_executing} 个执行中`,
},
{
title: '今日查询', value: stats.queries_today.toLocaleString(), icon: Zap,
color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20',
change: '+12%', trend: 'up' as const,
subtitle: `${stats.instance_online}/${stats.instance_total} 实例在线`,
title: '数据源总数', value: stats.instance_total, icon: Database,
color: 'text-purple-400', bg: 'bg-purple-400/10',
subtitle: `${stats.instance_online} 个在线 · ${stats.user_total} 个用户`,
},
];
}, [stats]);
@@ -350,25 +185,7 @@ export default function Dashboard() {
{ label: '添加实例', icon: Database, onClick: () => navigate('/instances'), color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' },
];
const TrendIcon = ({ trend }: { trend: 'up' | 'down' | 'neutral' }) => {
if (trend === 'up') return <ArrowUpRight className="w-3.5 h-3.5" />;
if (trend === 'down') return <ArrowDownRight className="w-3.5 h-3.5" />;
return <Minus className="w-3.5 h-3.5" />;
};
const trendRangeOptions: { label: string; value: TrendRange }[] = [
{ label: '近7天', value: 7 },
{ label: '近30天', value: 30 },
{ label: '近90天', value: 90 },
];
const annFilterOptions: { label: string; value: AnnouncementFilter }[] = [
{ label: '全部', value: 'all' },
{ label: '紧急', value: 'urgent' },
{ label: '重要', value: 'high' },
{ label: '通知', value: 'normal' },
{ label: '一般', value: 'low' },
];
/* ================================================================ */
/* RENDER */
@@ -392,53 +209,22 @@ export default function Dashboard() {
{/* ---- Announcements ---- */}
{visibleAnnouncements.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="w-4 h-4 text-[var(--dg-text-secondary)]" />
<span className="text-sm font-medium text-[var(--dg-text-secondary)]">公告通知</span>
<span className="text-xs text-[var(--dg-text-muted)]">({visibleAnnouncements.length})</span>
</div>
<div className="flex items-center gap-1">
<Filter className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
{annFilterOptions.map(opt => (
<button
key={opt.value}
onClick={() => setAnnFilter(opt.value)}
className={`px-2.5 py-1 text-[11px] rounded-md transition-colors ${
annFilter === opt.value
? 'bg-blue-500/15 text-blue-400 font-medium'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="space-y-2">
{visibleAnnouncements.slice(0, 3).map(ann => {
const style = priorityStyle[ann.priority as PriorityKey] || priorityStyle.normal;
const PIcon = style.icon;
const isExpanded = expandedId === ann.id;
const isRead = readIds.has(ann.id);
return (
<div
key={ann.id}
className={`dg-card border-l-[3px] ${style.border} ${style.bg} overflow-hidden transition-all duration-300 ${isRead ? 'opacity-60' : ''}`}
className="dg-card overflow-hidden"
>
<div className="px-5 py-3.5 flex items-start gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${style.badge.split(' ').slice(1).join(' ')}`}>
<PIcon className="w-4 h-4" />
<div className="px-5 py-3 flex items-start gap-3">
<div className="w-7 h-7 rounded-md bg-blue-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<Bell className="w-3.5 h-3.5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${style.badge}`}>
{priorityLabel[ann.priority as PriorityKey]}
</span>
<span className="text-sm font-semibold truncate">{ann.title}</span>
{ann.pinned && <span className="text-[10px] text-purple-400 flex-shrink-0 font-medium">置顶</span>}
{isRead && <span className="text-[10px] text-[var(--dg-text-muted)] flex-shrink-0">已读</span>}
<div className="flex items-center gap-2 mb-0.5">
<span className="text-sm font-medium truncate">{ann.title}</span>
<span className="text-[10px] text-[var(--dg-text-muted)] flex-shrink-0">{ann.publishTime.slice(0, 10)}</span>
</div>
<div className={`text-xs text-[var(--dg-text-secondary)] leading-relaxed ${isExpanded ? '' : 'line-clamp-1'}`}>
{ann.content}
@@ -446,102 +232,123 @@ export default function Dashboard() {
{ann.content.includes('\n') && (
<button
onClick={() => toggleExpand(ann.id)}
className="text-[11px] text-blue-400 hover:text-blue-300 mt-1 flex items-center gap-0.5 transition-colors"
className="text-[11px] text-blue-400 hover:text-blue-300 mt-1 transition-colors"
>
{isExpanded ? '收起' : '展开全文'}
<ChevronRight className={`w-3 h-3 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => toggleRead(ann.id)}
className="w-6 h-6 rounded-md flex items-center justify-center text-[var(--dg-text-muted)] hover:text-blue-400 hover:bg-[var(--dg-bg-hover)] transition-colors"
title={isRead ? '标记未读' : '标记已读'}
>
<Eye className="w-3.5 h-3.5" />
</button>
<span className="text-[11px] text-[var(--dg-text-muted)]">{ann.publishTime.slice(0, 10)}</span>
<button
onClick={() => dismiss(ann.id)}
className="w-6 h-6 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-colors"
title="关闭"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<button
onClick={() => dismiss(ann.id)}
className="w-6 h-6 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-colors flex-shrink-0"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
{visibleAnnouncements.length > 3 && (
<div className="text-center">
<span className="text-xs text-[var(--dg-text-muted)]">
还有 {visibleAnnouncements.length - 3} 条公告,可在「公告管理」中查看
</span>
</div>
)}
</div>
)}
{/* ---- Stats Cards ---- */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5">
<div className="grid grid-cols-3 gap-5">
{loading ? (
Array.from({ length: 4 }).map((_, i) => <StatCardSkeleton key={i} />)
Array.from({ length: 3 }).map((_, i) => <StatCardSkeleton key={i} />)
) : (
statsCards.map((s, i) => (
<Card key={i} className="dg-card dg-card-hover relative overflow-hidden">
<div className={`absolute top-0 right-0 w-20 h-20 ${s.bg} rounded-bl-[40px] opacity-30`} />
<CardContent className="p-5 relative z-10">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-[var(--dg-text-secondary)] font-medium">{s.title}</span>
<div className={`w-10 h-10 rounded-xl ${s.bg} flex items-center justify-center border ${s.border}`}>
<s.icon className={`w-5 h-5 ${s.color}`} />
<div className={`w-9 h-9 rounded-lg ${s.bg} flex items-center justify-center`}>
<s.icon className={`w-4.5 h-4.5 ${s.color}`} />
</div>
</div>
<div className="text-3xl font-bold text-[var(--dg-text-primary)] tracking-tight">{s.value}</div>
<div className="flex items-center gap-3 mt-2.5">
<span className={`flex items-center gap-1 text-xs font-medium ${s.trend === 'up' ? 'text-green-400' : s.trend === 'down' ? 'text-red-400' : 'text-[var(--dg-text-muted)]'}`}>
<TrendIcon trend={s.trend} />
{s.change}
</span>
<span className="text-xs text-[var(--dg-text-muted)]">{s.subtitle}</span>
</div>
<div className="text-4xl font-bold text-[var(--dg-text-primary)] tracking-tight">{s.value}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-2">{s.subtitle}</div>
</CardContent>
</Card>
))
)}
</div>
{/* ---- Charts + Instance Status ---- */}
{/* ---- My Pending Tickets + Instance Status ---- */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Trend Chart */}
{/* My Pending Tickets */}
<Card className="dg-card col-span-1 lg:col-span-2">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-blue-400" />
工单趋势
<Clock className="w-4 h-4 text-orange-400" />
我的待处理
<span className="text-xs text-[var(--dg-text-muted)] font-normal">({pendingTickets.length})</span>
</CardTitle>
<div className="flex gap-1.5">
{trendRangeOptions.map(opt => (
<button
key={opt.value}
onClick={() => setTrendRange(opt.value)}
className={`px-3 py-1.5 text-xs rounded-md transition-all font-medium ${
trendRange === opt.value
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]'
}`}
>
{opt.label}
</button>
))}
</div>
<a href="/tickets" className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 transition-colors">
查看全部 <ChevronRight className="w-3 h-3" />
</a>
</div>
</CardHeader>
<CardContent className="p-5">
{!chartReady ? <ChartSkeleton /> : <div ref={chartRef} style={{ height: 300 }} />}
<CardContent className="p-0">
{pendingTickets.length === 0 ? (
<div className="p-8 text-center text-[var(--dg-text-muted)] text-sm">
<CheckCircle className="w-8 h-8 mx-auto mb-2 text-green-400" />
暂无待处理工单
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">工单号</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">标题</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">数据库</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">状态</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">待处理</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">关联</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">时间</th>
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">操作</th>
</tr>
</thead>
<tbody>
{pendingTickets.map((ticket: any) => (
<tr key={ticket.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-5 py-3 font-mono text-xs text-[var(--dg-text-secondary)]">TK-{ticket.id}</td>
<td className="px-5 py-3 max-w-[200px] truncate font-medium">{ticket.title}</td>
<td className="px-5 py-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.db_type] || ''}`}>
{(ticket.db_type || '').toUpperCase()}
</span>
</td>
<td className="px-5 py-3">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' ? 'bg-blue-400 animate-pulse' : ticket.status === 'pending' ? 'bg-yellow-400' : 'bg-green-400'}`} />
{statusMap[ticket.status] || ticket.status}
</span>
</td>
<td className="px-5 py-3 text-xs text-[var(--dg-text-secondary)] max-w-[180px] truncate">{ticket.pending_info || '-'}</td>
<td className="px-5 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${ticket.relation === '我提交的' ? 'bg-blue-500/10 text-blue-400' : 'bg-orange-500/10 text-orange-400'}`}>
{ticket.relation}
</span>
</td>
<td className="px-5 py-3 text-xs text-[var(--dg-text-secondary)]">{ticket.create_time}</td>
<td className="px-5 py-3">
<button
onClick={() => navigate(`/tickets/${ticket.id}`)}
className="flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors"
>
<Eye className="w-3.5 h-3.5" />
查看
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
@@ -751,80 +558,6 @@ export default function Dashboard() {
</CardContent>
</Card>
</div>
{/* ---- Pending Tickets ---- */}
<Card className="dg-card">
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Clock className="w-4 h-4 text-orange-400" />
待处理工单
<span className="text-xs text-[var(--dg-text-muted)] font-normal">({pendingTickets.length})</span>
</CardTitle>
<a href="/tickets" className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 transition-colors">
查看全部
<ChevronRight className="w-3 h-3" />
</a>
</div>
</CardHeader>
<CardContent className="p-0">
{pendingTickets.length === 0 ? (
<div className="p-8 text-center text-[var(--dg-text-muted)] text-sm">
<CheckCircle className="w-8 h-8 mx-auto mb-2 text-green-400" />
所有工单已处理完毕
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">工单号</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">标题</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">数据库</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">申请人</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">状态</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">创建时间</th>
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">操作</th>
</tr>
</thead>
<tbody>
{pendingTickets.map(ticket => (
<tr key={ticket.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-5 py-3.5 font-mono text-xs text-[var(--dg-text-secondary)]">{ticket.id}</td>
<td className="px-5 py-3.5 max-w-xs truncate font-medium">{ticket.title}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>
{ticket.dbType.toUpperCase()}
</span>
</td>
<td className="px-5 py-3.5">{ticket.applicant}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' ? 'bg-blue-400 animate-pulse' : ticket.status === 'pending' ? 'bg-yellow-400' : ''}`} />
{statusMap[ticket.status]}
</span>
</td>
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.createTime}</td>
<td className="px-5 py-3.5">
<div className="flex gap-2">
<button className="flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors">
<Eye className="w-3.5 h-3.5" />
查看
</button>
<button className="flex items-center gap-1 text-green-400 hover:text-green-300 text-xs font-medium transition-colors">
<CheckCircle className="w-3.5 h-3.5" />
审批
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
+107 -5
View File
@@ -88,6 +88,7 @@ export default function TicketDetailPage() {
// -- Data --
const [ticket, setTicket] = useState<TicketData | null>(null);
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
const [approvalSteps, setApprovalSteps] = useState<any[]>([]);
const [relatedTickets, setRelatedTickets] = useState<RelatedTicket[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -129,6 +130,15 @@ export default function TicketDetailPage() {
}
}, [ticketId]);
const fetchApprovals = useCallback(async () => {
try {
const res = await ticketApi.approvals(ticketId);
setApprovalSteps(Array.isArray(res) ? res : []);
} catch {
setApprovalSteps([]);
}
}, [ticketId]);
// Fetch related tickets from API
const fetchRelated = useCallback(async () => {
try {
@@ -154,6 +164,7 @@ export default function TicketDetailPage() {
setLoading(true);
fetchTicket();
fetchTimeline();
fetchApprovals();
fetchRelated();
}
}, [ticketId, fetchTicket, fetchTimeline, fetchRelated]);
@@ -196,6 +207,7 @@ export default function TicketDetailPage() {
}
fetchTicket();
fetchTimeline();
fetchApprovals();
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } };
setError(err?.response?.data?.detail || `操作失败: ${action}`);
@@ -379,6 +391,98 @@ export default function TicketDetailPage() {
);
};
const renderApprovalFlow = () => {
if (approvalSteps.length === 0) return null;
const actionLabel: Record<string, string> = {
approved: '已通过',
rejected: '已拒绝',
pending: '待处理',
skipped: '已跳过',
};
const actionStyle: Record<string, { color: string; bg: string }> = {
approved: { color: 'text-green-400', bg: 'bg-green-500/15' },
rejected: { color: 'text-red-400', bg: 'bg-red-500/15' },
pending: { color: 'text-[var(--dg-text-muted)]', bg: 'bg-[var(--dg-bg-tertiary)]' },
};
const stepAccent: Record<string, string> = {
'提交': 'text-blue-400',
'审核': 'text-amber-400',
'执行': 'text-purple-400',
};
const getAccent = (name: string) => {
for (const key of Object.keys(stepAccent)) {
if (name.includes(key)) return stepAccent[key];
}
return 'text-[var(--dg-text-secondary)]';
};
return (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-4">
<Shield className="w-4 h-4 text-blue-400" /> 审批流程
</h3>
<div className="space-y-2">
{approvalSteps.map((step, idx) => {
const actCfg = actionStyle[step.action] || actionStyle.pending;
const isCurrent = step.action === 'pending';
const isDone = step.action === 'approved' || step.action === 'rejected';
return (
<div
key={step.id || idx}
className={`flex items-start gap-3 p-3 rounded-lg ${
isCurrent
? 'bg-blue-500/5 border border-blue-500/20'
: 'bg-[var(--dg-bg-primary)]'
}`}
>
<div className={`w-6 h-6 rounded-full ${actCfg.bg} flex items-center justify-center shrink-0 mt-0.5`}>
{isDone && step.action === 'approved' ? (
<CheckCircle className={`w-3.5 h-3.5 ${actCfg.color}`} />
) : isDone ? (
<XCircle className={`w-3.5 h-3.5 ${actCfg.color}`} />
) : (
<div className={`w-1.5 h-1.5 rounded-full ${isCurrent ? 'bg-blue-400 animate-pulse' : 'bg-[var(--dg-text-muted)]'}`} />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${getAccent(step.step_name)}`}>
{step.step_name}
</span>
{step.approver_name && (
<span className="text-xs text-[var(--dg-text-muted)]">
{step.approver_name}
</span>
)}
</div>
<span className={`text-xs px-1.5 py-0.5 rounded ${actCfg.bg} ${actCfg.color} shrink-0`}>
{actionLabel[step.action] || step.action}
</span>
</div>
{step.comment && step.comment !== '提交工单' && (
<p className="text-xs text-[var(--dg-text-secondary)] mt-1">{step.comment}</p>
)}
{step.updated_at && isDone && (
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5">
{new Date(step.updated_at).toLocaleString()}
</p>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
const renderTimeline = () => {
if (timeline.length === 0) return null;
@@ -512,9 +616,6 @@ export default function TicketDetailPage() {
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
{statusMap[ticket.status] || ticket.status}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${priorityInfo.color}`}>
{priorityInfo.label}优先级
</span>
</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
TK-{ticket.id} · 由 {ticket.applicant_name || '-'} 创建于 {new Date(ticket.create_time).toLocaleString()}
@@ -537,8 +638,6 @@ export default function TicketDetailPage() {
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
{ label: '目标数据库', value: ticket.target_database, icon: Database },
{ label: '申请人', value: ticket.applicant_name || '-' },
{ label: '审批人', value: ticket.approver_name || '-' },
{ label: '优先级', value: priorityInfo.label },
{
label: '创建时间',
value: new Date(ticket.create_time).toLocaleString(),
@@ -798,6 +897,9 @@ export default function TicketDetailPage() {
);
})()}
{/* Approval Flow */}
{renderApprovalFlow()}
{/* Timeline */}
{renderTimeline()}
+25 -6
View File
@@ -15,6 +15,13 @@ import { ticketApi } from '@/api/ticket';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface PendingStep {
step_number: number;
step_name: string;
step_type: string;
approver_names: string[];
}
interface TicketItem {
id: number;
ticket_no: string | null;
@@ -31,6 +38,7 @@ interface TicketItem {
approver_id: number | null;
approver_name: string | null;
current_approver_name: string | null;
pending_steps: PendingStep[];
reason: string | null;
audit_result: string | null;
execute_result: string | null;
@@ -285,7 +293,7 @@ export default function TicketsPage() {
<th className="text-left px-4 py-3 font-semibold">工单编号</th>
<th className="text-left px-4 py-3 font-semibold">实例名称</th>
<th className="text-left px-4 py-3 font-semibold">提交人</th>
<th className="text-left px-4 py-3 font-semibold">当前审核人</th>
<th className="text-left px-4 py-3 font-semibold">待处理</th>
<th className="text-left px-4 py-3 font-semibold">状态</th>
<th className="text-left px-4 py-3 font-semibold">
<button onClick={() => setSortAsc(!sortAsc)} className="flex items-center gap-1 hover:text-blue-400 transition-colors">
@@ -310,7 +318,7 @@ export default function TicketsPage() {
<th className="text-left px-4 py-3 font-semibold">工单编号</th>
<th className="text-left px-4 py-3 font-semibold">实例名称</th>
<th className="text-left px-4 py-3 font-semibold">提交人</th>
<th className="text-left px-4 py-3 font-semibold">当前审核人</th>
<th className="text-left px-4 py-3 font-semibold">待处理</th>
<th className="text-left px-4 py-3 font-semibold">状态</th>
<th className="text-left px-4 py-3 font-semibold">
<button onClick={() => setSortAsc(!sortAsc)} className="flex items-center gap-1 hover:text-blue-400 transition-colors">
@@ -352,11 +360,22 @@ export default function TicketsPage() {
</div>
</td>
{/* 当前审核人 */}
{/* 待处理 */}
<td className="px-4 py-3.5">
<span className="text-sm text-[var(--dg-text-secondary)]">
{ticket.current_approver_name || (ticket.status === 'approved' ? '待执行' : '-')}
</span>
{ticket.pending_steps?.length > 0 ? (
<div className="flex flex-col gap-1">
{ticket.pending_steps.map((ps) => (
<div key={ps.step_number} className="flex items-center gap-1.5 text-xs">
<span className="text-[var(--dg-text-muted)] shrink-0">{ps.step_name}:</span>
<span className="text-[var(--dg-text-primary)] font-medium">{ps.approver_names.join(', ')}</span>
</div>
))}
</div>
) : (
<span className="text-sm text-[var(--dg-text-muted)]">
{['success', 'failed', 'rejected', 'rollback'].includes(ticket.status) ? '-' : '-'}
</span>
)}
</td>
{/* 状态 */}
+8
View File
@@ -158,6 +158,13 @@ export interface PermissionNode {
children?: PermissionNode[];
}
export interface QueryStatsData {
total: number;
avg_time_ms: number;
slow_count: number;
type_distribution: { label: string; count: number }[];
}
export interface DashboardStats {
ticket_total: number;
ticket_pending: number;
@@ -167,6 +174,7 @@ export interface DashboardStats {
instance_online: number;
user_total: number;
queries_today: number;
query_stats?: QueryStatsData;
}
export interface MenuItem {