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

+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>
);
}