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:
1 parent
be2d6f3894
commit
43d1f3a234
10 files changed
+587
-440
No files matched your search
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 状态 */}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user