perf(frontend): split admin bundles and tighten data fetching

Add next/dynamic lazy loading for database, logs, and settings heavy
modules; migrate access-logs and task-executions to React Query; parallelize
login auth-sources with public config; consolidate users table tooltips;
enable lazy image decoding; and replace barrel @/lib/services imports with
direct module paths.
This commit is contained in:
ryan
2026-06-17 12:12:47 +08:00
parent 9550fa6ff3
commit a44043262e
43 changed files with 752 additions and 695 deletions
@@ -20,7 +20,7 @@ import {useCallback, useEffect, useState} from "react"
import {BarChart3, Globe, RefreshCw, TrendingUp, Users, XCircle} from "lucide-react"
import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from "recharts"
import {AdminService} from "@/lib/services"
import {AdminService} from "@/lib/services/admin"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
@@ -16,12 +16,13 @@ limitations under the License.
"use client"
import {useCallback, useEffect, useState} from "react"
import {useMemo, useState} from "react"
import {useQuery} from "@tanstack/react-query"
import {toast} from "sonner"
import {format} from "date-fns"
import {Activity, ChevronLeft, ChevronRight, Copy, Eye, RotateCcw, Search, XCircle} from "lucide-react"
import {AdminService} from "@/lib/services"
import {AdminService} from "@/lib/services/admin"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
@@ -47,6 +48,8 @@ interface AccessLog {
created_at: string
}
const PAGE_SIZE = 15
function formatDateTime(value?: string | null) {
if (!value) return "-"
const date = new Date(value)
@@ -66,15 +69,13 @@ function statusVariant(status: number) {
return "secondary"
}
export function AccessLogs() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [clickhouseDisabled, setClickhouseDisabled] = useState(false)
function isClickhouseDisabledError(error: Error) {
const errMsg = error.message || ""
return errMsg.includes("ClickHouse") || errMsg.includes("未启用")
}
const [logs, setLogs] = useState<AccessLog[]>([])
const [total, setTotal] = useState(0)
export function AccessLogs() {
const [page, setPage] = useState(1)
const pageSize = 15
// 搜索过滤条件
const [usernameFilter, setUsernameFilter] = useState("")
@@ -93,12 +94,9 @@ export function AccessLogs() {
const [selectedLog, setSelectedLog] = useState<AccessLog | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const fetchAccessLogs = useCallback(async (currentPage: number = page) => {
try {
setLoading(true)
setError(null)
// 转换起止时间格式
const logsQuery = useQuery({
queryKey: ["admin", "access-logs", page, searchParams],
queryFn: async () => {
let startISO = ""
if (searchParams.start_time) {
startISO = new Date(searchParams.start_time).toISOString()
@@ -108,34 +106,25 @@ export function AccessLogs() {
endISO = new Date(searchParams.end_time).toISOString()
}
const data = await AdminService.getAccessLogs({
page: currentPage,
page_size: pageSize,
return AdminService.getAccessLogs({
page,
page_size: PAGE_SIZE,
username: searchParams.username || undefined,
path: searchParams.path || undefined,
start_time: startISO || undefined,
end_time: endISO || undefined,
})
},
retry: false,
})
setLogs(data.list || [])
setTotal(data.total || 0)
setClickhouseDisabled(false)
} catch (err) {
const errorInstance = err instanceof Error ? err : new Error("获取访问日志失败")
const errMsg = errorInstance.message || ""
if (errMsg.includes("ClickHouse") || errMsg.includes("未启用")) {
setClickhouseDisabled(true)
} else {
setError(errorInstance)
}
} finally {
setLoading(false)
}
}, [page, searchParams])
const logs = logsQuery.data?.list ?? []
const total = logsQuery.data?.total ?? 0
const loading = logsQuery.isPending || logsQuery.isFetching
const clickhouseDisabled = logsQuery.isError && isClickhouseDisabledError(logsQuery.error)
const error = logsQuery.isError && !clickhouseDisabled ? logsQuery.error : null
useEffect(() => {
fetchAccessLogs()
}, [fetchAccessLogs])
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / PAGE_SIZE)), [total])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
@@ -164,7 +153,6 @@ export function AccessLogs() {
const handlePageChange = (newPage: number) => {
setPage(newPage)
fetchAccessLogs(newPage)
}
const copyToClipboard = (text: string, subject: string) => {
@@ -172,8 +160,6 @@ export function AccessLogs() {
toast.success(`${subject}已复制到剪贴板`)
}
const totalPages = Math.max(1, Math.ceil(total / pageSize))
// Prettify Headers JSON string
const getPrettyHeaders = (headersRaw?: string) => {
if (!headersRaw) return "暂无头部数据"
@@ -198,7 +184,7 @@ export function AccessLogs() {
}
if (error) {
return <ErrorInline error={error} onRetry={() => fetchAccessLogs(1)} />
return <ErrorInline error={error} onRetry={() => void logsQuery.refetch()} />
}
return (
@@ -465,4 +451,4 @@ export function AccessLogs() {
</Sheet>
</div>
)
}
}
@@ -21,7 +21,7 @@ import {useVirtualizer} from "@tanstack/react-virtual"
import {toast} from "sonner"
import {ArrowDown, ChevronUp, Loader2, Pause, Play} from "lucide-react"
import {AdminService} from "@/lib/services"
import {AdminService} from "@/lib/services/admin"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {Badge} from "@/components/ui/badge"
@@ -19,8 +19,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import type {CacheStatus} from "@/lib/services"
import {AdminService} from "@/lib/services"
import type {CacheStatus} from "@/lib/services/admin"
import {AdminService} from "@/lib/services/admin"
/**
* 格式化字节大小
@@ -11,8 +11,8 @@ import {ArrowLeft, Play, RefreshCw, Terminal, Trash2,} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import type {ExecuteSQLResponse} from "@/lib/services"
import services from "@/lib/services"
import type {ExecuteSQLResponse} from "@/lib/services/db-manage"
import {DbManageService} from "@/lib/services/db-manage"
/**
* 格式化表格单元格内容
@@ -81,7 +81,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
setSqlResult(null)
setSqlError(null)
try {
const result = await services.dbManage.executeSQL(sqlQuery)
const result = await DbManageService.executeSQL(sqlQuery)
setSqlResult(result)
toast.success("SQL 执行成功")
} catch (err) {
@@ -10,8 +10,8 @@ import {Skeleton} from "@/components/ui/skeleton"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Button} from "@/components/ui/button"
import type {TableDataResponse} from "@/lib/services"
import services from "@/lib/services"
import type {TableDataResponse} from "@/lib/services/db-manage"
import {DbManageService} from "@/lib/services/db-manage"
/**
* 格式化数字,每3位加逗号
@@ -55,7 +55,7 @@ export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBro
if (!tableName) return
setLoadingData(true)
try {
const data = await services.dbManage.getTableData({
const data = await DbManageService.getTableData({
table: tableName,
page: targetPage,
pageSize: size,
@@ -292,6 +292,8 @@ export function FileList() {
<img
src={getFileUrl(file.id, "low") ?? undefined}
alt={file.file_name}
loading="lazy"
decoding="async"
className="size-full object-cover"
onError={(e) => {
;(e.currentTarget as HTMLImageElement).style.display = "none"
@@ -408,6 +410,8 @@ export function FileList() {
<img
src={getFileUrl(detailTarget.id, "low") ?? undefined}
alt={detailTarget.file_name}
loading="lazy"
decoding="async"
className="max-h-full max-w-full object-contain rounded-lg shadow-sm"
/>
) : (
@@ -33,7 +33,8 @@ import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
import services from "@/lib/services"
import {AdminService} from "@/lib/services/admin"
import {PushService} from "@/lib/services/push"
import type {CreatePushEventRequest, PushEvent, UpdatePushEventRequest,} from "@/lib/services/push"
export function EventsTab() {
@@ -42,7 +43,7 @@ export function EventsTab() {
// --- 获取所有自定义消息通道 ---
const channelsQuery = useQuery({
queryKey: ["admin", "push-channels"],
queryFn: () => services.push.listChannels(),
queryFn: () => PushService.listChannels(),
})
const availableChannels = React.useMemo(() => {
@@ -55,23 +56,23 @@ export function EventsTab() {
// --- 获取通知事件 ---
const eventsQuery = useQuery({
queryKey: ["admin", "push-events"],
queryFn: () => services.push.listEvents(),
queryFn: () => PushService.listEvents(),
})
const builtInEventsQuery = useQuery({
queryKey: ["admin", "push-builtin-events"],
queryFn: () => services.push.listBuiltInEvents(),
queryFn: () => PushService.listBuiltInEvents(),
})
// --- 获取所有系统可调度任务类型 ---
const taskTypesQuery = useQuery({
queryKey: ["admin", "task-types"],
queryFn: () => services.admin.getTaskTypes(),
queryFn: () => AdminService.getTaskTypes(),
})
// --- 修改保存事件 Mutation ---
const updateEventMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => services.push.updateEvent(id, data),
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => PushService.updateEvent(id, data),
onSuccess: () => {
toast.success("事件更新成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
@@ -83,7 +84,7 @@ export function EventsTab() {
})
const toggleEventMutation = useMutation({
mutationFn: (id: number) => services.push.toggleEvent(id),
mutationFn: (id: number) => PushService.toggleEvent(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
},
@@ -93,7 +94,7 @@ export function EventsTab() {
})
const createEventMutation = useMutation({
mutationFn: (data: CreatePushEventRequest) => services.push.createEvent(data),
mutationFn: (data: CreatePushEventRequest) => PushService.createEvent(data),
onSuccess: () => {
toast.success("事件创建成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
@@ -110,7 +111,7 @@ export function EventsTab() {
})
const deleteEventMutation = useMutation({
mutationFn: (id: number) => services.push.deleteEvent(id),
mutationFn: (id: number) => PushService.deleteEvent(id),
onSuccess: () => {
toast.success("配置删除成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
@@ -26,7 +26,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import services from "@/lib/services"
import {PushService} from "@/lib/services/push"
import type {PushHistory} from "@/lib/services/push"
function getLevelBadgeVariant(level: string): "outline" | "secondary" | "destructive" | "default" {
@@ -56,7 +56,7 @@ export function HistoriesTab() {
const historiesQuery = useQuery({
queryKey: ["admin", "push-histories", historyPage, historySearch, historyStatus],
queryFn: () =>
services.push.listHistories({
PushService.listHistories({
page: historyPage,
page_size: 10,
event_key: historySearch || undefined,
@@ -29,7 +29,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import services from "@/lib/services"
import {PushService} from "@/lib/services/push"
import type {ChannelDefinition, CreateChannelRequest, PushChannel, UpdateChannelRequest} from "@/lib/services/push"
export function SettingsTab() {
@@ -38,19 +38,19 @@ export function SettingsTab() {
// --- 获取所有自定义消息通道 ---
const channelsQuery = useQuery({
queryKey: ["admin", "push-channels"],
queryFn: () => services.push.listChannels(),
queryFn: () => PushService.listChannels(),
})
// --- 获取动态通道表单字段定义 ---
const definitionsQuery = useQuery({
queryKey: ["admin", "push-channels-definitions"],
queryFn: () => services.push.listChannelDefinitions(),
queryFn: () => PushService.listChannelDefinitions(),
})
// --- 消息通道 CRUD Mutations ---
const createChannelMutation = useMutation({
mutationFn: (data: CreateChannelRequest) => services.push.createChannel(data),
mutationFn: (data: CreateChannelRequest) => PushService.createChannel(data),
onSuccess: () => {
toast.success("通道创建成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
@@ -63,7 +63,7 @@ export function SettingsTab() {
const updateChannelMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) =>
services.push.updateChannel(id, data),
PushService.updateChannel(id, data),
onSuccess: () => {
toast.success("通道更新成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
@@ -75,7 +75,7 @@ export function SettingsTab() {
})
const deleteChannelMutation = useMutation({
mutationFn: (id: number) => services.push.deleteChannel(id),
mutationFn: (id: number) => PushService.deleteChannel(id),
onSuccess: () => {
toast.success("通道删除成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-channels"] })
@@ -237,7 +237,7 @@ export function SettingsTab() {
try {
setIsTestingChannel(true)
toast.info("正在发送测试推送...")
await services.push.testChannel({
await PushService.testChannel({
name: testChannelName,
target: testChannelTarget || undefined,
})
+3 -3
View File
@@ -11,8 +11,8 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/
import {Badge} from "@/components/ui/badge"
import {Progress} from "@/components/ui/progress"
import {Skeleton} from "@/components/ui/skeleton"
import type {SystemStatus} from "@/lib/services"
import services from "@/lib/services"
import type {SystemStatus} from "@/lib/services/admin"
import {AdminService} from "@/lib/services/admin"
/**
* 格式化数字,每3位加逗号
@@ -40,7 +40,7 @@ export function SystemStatusManager() {
const fetchStatus = useCallback(async (isSilent = false) => {
if (!isSilent) setWavelet(true)
try {
const data = await services.admin.getSystemStatus()
const data = await AdminService.getSystemStatus()
// 检测变化的字段用于微动画效果
if (prevStatusRef.current) {
@@ -1,11 +1,13 @@
"use client"
import {useCallback, useEffect, useState} from "react"
import {useMemo, useState} from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {toast} from "sonner"
import {format} from "date-fns"
import {Activity, ChevronLeft, ChevronRight, RefreshCw, RotateCcw} from "lucide-react"
import {AdminService, TaskExecution, TaskExecutionStatus, TaskMeta} from "@/lib/services"
import {AdminService} from "@/lib/services/admin"
import type {TaskExecution, TaskExecutionStatus, TaskMeta} from "@/lib/services/admin"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {EmptyStateWithBorder} from "@/components/layout/empty"
@@ -31,6 +33,8 @@ const TRIGGER_LABELS: Record<string, string> = {
schedule: "定时",
}
const PAGE_SIZE = 10
function formatDateTime(value?: string | null) {
if (!value) return "-"
const date = new Date(value)
@@ -51,108 +55,85 @@ function statusVariant(status: TaskExecutionStatus) {
}
export function TaskExecutionsManager() {
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
const [executionsLoading, setExecutionsLoading] = useState(false)
const [executionsError, setExecutionsError] = useState<Error | null>(null)
const [executions, setExecutions] = useState<TaskExecution[]>([])
const [executionsTotal, setExecutionsTotal] = useState(0)
const queryClient = useQueryClient()
const [executionsPage, setExecutionsPage] = useState(1)
const [executionStatus, setExecutionStatus] = useState<TaskExecutionStatus | "all">("all")
const [executionTaskType, setExecutionTaskType] = useState<string>("all")
const [selectedExecution, setSelectedExecution] = useState<TaskExecution | null>(null)
const [selectedExecutionId, setSelectedExecutionId] = useState<string | null>(null)
const [executionPreview, setExecutionPreview] = useState<TaskExecution | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [retrying, setRetrying] = useState(false)
const fetchTaskTypes = useCallback(async () => {
try {
const data = await AdminService.getTaskTypes()
setTaskTypes(data)
} catch {
setTaskTypes([])
}
}, [])
const taskTypesQuery = useQuery({
queryKey: ["admin", "task-types"],
queryFn: () => AdminService.getTaskTypes(),
})
const fetchTaskExecutions = useCallback(async (overrides?: {
page?: number
status?: TaskExecutionStatus | "all"
taskType?: string
}) => {
try {
setExecutionsLoading(true)
setExecutionsError(null)
const page = overrides?.page ?? executionsPage
const status = overrides?.status ?? executionStatus
const taskType = overrides?.taskType ?? executionTaskType
const data = await AdminService.listTaskExecutions({
page,
page_size: 10,
status: status === "all" ? undefined : status,
task_type: taskType === "all" ? undefined : taskType,
})
setExecutions(data.items || [])
setExecutionsTotal(data.total || 0)
} catch (err) {
setExecutionsError(err instanceof Error ? err : new Error("加载任务执行记录失败"))
} finally {
setExecutionsLoading(false)
}
}, [executionStatus, executionTaskType, executionsPage])
const executionsQuery = useQuery({
queryKey: ["admin", "task-executions", executionsPage, executionStatus, executionTaskType],
queryFn: () => AdminService.listTaskExecutions({
page: executionsPage,
page_size: PAGE_SIZE,
status: executionStatus === "all" ? undefined : executionStatus,
task_type: executionTaskType === "all" ? undefined : executionTaskType,
}),
})
useEffect(() => {
fetchTaskTypes()
}, [fetchTaskTypes])
const detailQuery = useQuery({
queryKey: ["admin", "task-execution", selectedExecutionId],
queryFn: () => AdminService.getTaskExecution(selectedExecutionId!),
enabled: detailOpen && !!selectedExecutionId,
})
useEffect(() => {
fetchTaskExecutions()
}, [fetchTaskExecutions])
useEffect(() => {
setExecutionsPage(1)
}, [executionStatus, executionTaskType])
const openExecutionDetail = async (execution: TaskExecution) => {
setSelectedExecution(execution)
setDetailOpen(true)
try {
setDetailLoading(true)
const detail = await AdminService.getTaskExecution(execution.id)
setSelectedExecution(detail)
} catch (err) {
toast.error("任务详情加载失败", {
description: err instanceof Error ? err.message : "未知错误",
})
} finally {
setDetailLoading(false)
}
}
const handleRetryExecution = async () => {
if (!selectedExecution) return
try {
setRetrying(true)
const taskID = await AdminService.retryTaskExecution(selectedExecution.id)
const retryMutation = useMutation({
mutationFn: (id: string) => AdminService.retryTaskExecution(id),
onSuccess: (taskID) => {
toast.success("任务已重新下发", {
description: `新任务 ID:${ taskID }`,
})
await fetchTaskExecutions()
} catch (err) {
void queryClient.invalidateQueries({ queryKey: ["admin", "task-executions"] })
},
onError: (err: Error) => {
toast.error("任务重试失败", {
description: err instanceof Error ? err.message : "未知错误",
description: err.message || "未知错误",
})
} finally {
setRetrying(false)
}
},
})
const taskTypes: TaskMeta[] = taskTypesQuery.data ?? []
const executions = executionsQuery.data?.items ?? []
const executionsTotal = executionsQuery.data?.total ?? 0
const executionsLoading = executionsQuery.isPending || executionsQuery.isFetching
const executionsError = executionsQuery.isError ? executionsQuery.error : null
const selectedExecution = detailQuery.data ?? executionPreview
const detailLoading = detailQuery.isFetching
const totalPages = useMemo(
() => Math.max(1, Math.ceil(executionsTotal / PAGE_SIZE)),
[executionsTotal],
)
const openExecutionDetail = (execution: TaskExecution) => {
setExecutionPreview(execution)
setSelectedExecutionId(execution.id)
setDetailOpen(true)
}
const totalPages = Math.max(1, Math.ceil(executionsTotal / 10))
const handleStatusChange = (value: TaskExecutionStatus | "all") => {
setExecutionStatus(value)
setExecutionsPage(1)
}
const handleTaskTypeChange = (value: string) => {
setExecutionTaskType(value)
setExecutionsPage(1)
}
return (
<div className="space-y-6">
<br/>
<div className="flex flex-col gap-3 border-b border-border pb-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Select value={executionStatus} onValueChange={(value) => setExecutionStatus(value as TaskExecutionStatus | "all")}>
<Select value={executionStatus} onValueChange={(value) => handleStatusChange(value as TaskExecutionStatus | "all")}>
<SelectTrigger size="sm" className="w-[120px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
@@ -164,7 +145,7 @@ export function TaskExecutionsManager() {
<SelectItem value="failed">失败</SelectItem>
</SelectContent>
</Select>
<Select value={executionTaskType} onValueChange={setExecutionTaskType}>
<Select value={executionTaskType} onValueChange={handleTaskTypeChange}>
<SelectTrigger size="sm" className="w-[180px]">
<SelectValue placeholder="任务类型" />
</SelectTrigger>
@@ -175,7 +156,7 @@ export function TaskExecutionsManager() {
))}
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={() => fetchTaskExecutions()} disabled={executionsLoading}>
<Button variant="outline" size="sm" onClick={() => void executionsQuery.refetch()} disabled={executionsLoading}>
{executionsLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
刷新
</Button>
@@ -184,7 +165,7 @@ export function TaskExecutionsManager() {
{executionsError ? (
<div className="p-8 border border-dashed rounded-lg bg-card">
<ErrorInline error={executionsError} onRetry={fetchTaskExecutions} className="justify-center" />
<ErrorInline error={executionsError} onRetry={() => void executionsQuery.refetch()} className="justify-center" />
</div>
) : executionsLoading && executions.length === 0 ? (
<LoadingStateWithBorder icon={Activity} description="加载任务执行记录中..." />
@@ -375,13 +356,13 @@ export function TaskExecutionsManager() {
</div>
<SheetFooter className="border-t">
<Button variant="outline" onClick={() => selectedExecution && openExecutionDetail(selectedExecution)} disabled={!selectedExecution || detailLoading}>
<Button variant="outline" onClick={() => void detailQuery.refetch()} disabled={!selectedExecutionId || detailLoading}>
{detailLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
刷新详情
</Button>
{selectedExecution && selectedExecution.status === "failed" && selectedExecution.retryable && (
<Button onClick={handleRetryExecution} disabled={retrying}>
{retrying ? <Spinner className="size-4" /> : <RotateCcw className="size-4" />}
<Button onClick={() => retryMutation.mutate(selectedExecution.id)} disabled={retryMutation.isPending}>
{retryMutation.isPending ? <Spinner className="size-4" /> : <RotateCcw className="size-4" />}
重试任务
</Button>
)}
@@ -390,4 +371,4 @@ export function TaskExecutionsManager() {
</Sheet>
</div>
)
}
}
@@ -11,7 +11,7 @@ import {Spinner} from "@/components/ui/spinner"
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
import {Calendar as CalendarIcon, Clock, Info, Layers, Play} from "lucide-react"
import {AdminService, DispatchTaskRequest, TaskMeta} from "@/lib/services"
import {AdminService, DispatchTaskRequest, TaskMeta} from "@/lib/services/admin"
import {buildTaskPayload} from "@/lib/task-param-utils"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
@@ -12,7 +12,7 @@ import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, Di
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
import {Clock, Edit2, Info, Plus, RefreshCw, Trash2} from "lucide-react"
import {AdminService, CreateScheduleRequest, Schedule, TaskMeta, UpdateScheduleRequest} from "@/lib/services"
import {AdminService, CreateScheduleRequest, Schedule, TaskMeta, UpdateScheduleRequest} from "@/lib/services/admin"
import {buildTaskPayload} from "@/lib/task-param-utils"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
@@ -7,7 +7,7 @@ import {Button} from "@/components/ui/button"
import {Badge} from "@/components/ui/badge"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {Sheet, SheetContent, SheetTitle} from "@/components/ui/sheet"
import type {AdminUser} from "@/lib/services"
import type {AdminUser} from "@/lib/services/admin"
import {cn, formatDateTime} from "@/lib/utils"
interface UserDetailSheetProps {