Files
TaskPool/web/src/views/history/History.vue
T

503 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, computed, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { TASK_STATUS, TASK_TYPE, TASK_STATUS_TEXT, TASK_EVENTS } from '@/constants'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import Pagination from '@/components/Pagination.vue'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import LogViewer from './LogViewer.vue'
import {
RefreshCw, Search, GitBranch, Terminal, Trash2
} from 'lucide-vue-next'
import { api, type TaskLog } from '@/api'
import LogDetailCard from '@/components/LogDetailCard.vue'
import BaihuDialog from '@/components/ui/BaihuDialog.vue'
import { toast } from 'vue-sonner'
import { useSiteSettings } from '@/composables/useSiteSettings'
import TextOverflow from '@/components/TextOverflow.vue'
import StatusDot from '@/components/StatusDot.vue'
import { useEventBus } from '@/composables/useEventBus'
const route = useRoute()
const { pageSize } = useSiteSettings()
const logs = ref<TaskLog[]>([])
const selectedLog = ref<TaskLog | null>(null)
const filterKeyword = ref('')
const filterTaskId = ref<string | undefined>(undefined)
const filterStatus = ref<string | undefined>(undefined)
const currentPage = ref(1)
const total = ref(0)
let searchTimer: ReturnType<typeof setTimeout> | null = null
let durationTimer: ReturnType<typeof setInterval> | null = null
const isRefreshing = ref(false)
// 监听实时任务事件
useEventBus(Object.values(TASK_EVENTS), (payload, type) => {
const { log_id, status, duration, end_time } = payload
// 查找列表中的记录
const logItem = logs.value.find(l => l.id === log_id)
if (logItem) {
// 更新已有记录状态
logItem.status = status
if (duration !== undefined) logItem.duration = duration
if (end_time !== undefined) logItem.end_time = end_time
// 如果详情页打开的是这个记录,也同步更新
if (selectedLog.value && selectedLog.value.id === log_id) {
selectedLog.value.status = status
if (duration !== undefined) selectedLog.value.duration = duration
if (end_time !== undefined) selectedLog.value.end_time = end_time
// 如果任务完成了,停止详情页的轮询定时器
if (status !== TASK_STATUS.RUNNING && durationTimer) {
clearInterval(durationTimer)
durationTimer = null
}
}
} else if (type === TASK_EVENTS.RUNNING) {
// 新任务开始执行,如果在第一页且没有特定筛选条件,则刷新列表
if (currentPage.value === 1 && !filterTaskId.value && !filterKeyword.value && (!filterStatus.value || filterStatus.value === 'all')) {
loadLogs()
}
}
})
// 全屏查看
const showFullscreen = ref(false)
// 清除所有日志弹窗
const showClearDialog = ref(false)
// 删除单条日志弹窗
const showDeleteDialog = ref(false)
const deleteLogId = ref<string | null>(null)
const wsContent = ref('')
const isWsLoading = ref(false)
let logSource: EventSource | null = null
import { decompressFromBase64 } from '@/utils/decompress'
const decompressedOutput = computed(() => {
if (!wsContent.value) return ''
const decoded = decompressFromBase64(wsContent.value)
return decoded
})
async function loadLogs() {
isRefreshing.value = true
try {
const params: { page: number; page_size: number; task_id?: string; task_name?: string; status?: string } = {
page: currentPage.value,
page_size: pageSize.value
}
if (filterTaskId.value) {
params.task_id = filterTaskId.value
}
if (filterKeyword.value.trim()) {
params.task_name = filterKeyword.value.trim()
}
if (filterStatus.value && filterStatus.value !== 'all') {
params.status = filterStatus.value
}
const response = await api.logs.list(params)
logs.value = response.data
total.value = response.total
} catch {
toast.error('加载日志失败')
} finally {
isRefreshing.value = false
}
}
function handleSearch() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
currentPage.value = 1
loadLogs()
}, 300)
}
function handleStatusChange() {
currentPage.value = 1
loadLogs()
}
function handlePageChange(page: number) {
currentPage.value = page
loadLogs()
}
async function selectLog(log: TaskLog) {
if (logSource) {
logSource.close()
logSource = null
}
// 清理旧定时器
if (durationTimer) {
clearInterval(durationTimer)
durationTimer = null
}
selectedLog.value = log
// 如果是运行中状态,启动定时器轮询最新日志信息(主要是更新耗时)
if (log.status === TASK_STATUS.RUNNING) {
const updateLog = async () => {
try {
const res = await api.logs.get(log.id)
if (res && selectedLog.value && selectedLog.value.id === log.id) {
// 只更新需要变动的字段
selectedLog.value.duration = res.duration
// 同步更新列表中的数据
const listItem = logs.value.find(l => l.id === log.id)
if (listItem) {
listItem.duration = res.duration
}
// 如果状态变了,更新状态并停止轮询
if (res.status !== TASK_STATUS.RUNNING) {
selectedLog.value.status = res.status
selectedLog.value.end_time = res.end_time
if (listItem) {
listItem.status = res.status
listItem.end_time = res.end_time
}
if (durationTimer) {
clearInterval(durationTimer)
durationTimer = null
}
}
}
} catch { /* ignore */ }
}
durationTimer = setInterval(updateLog, 3000)
}
wsContent.value = ''
isWsLoading.value = true
if (log.status !== TASK_STATUS.RUNNING) {
try {
const res = await api.logs.get(log.id)
wsContent.value = res.output
} catch {
toast.error('加载详情失败')
} finally {
isWsLoading.value = false
}
return
}
const protocol = window.location.protocol
const host = window.location.host
const baseUrl = (window as any).__BASE_URL__ || ''
const apiVersion = (window as any).__API_VERSION__ || '/api/v1'
const sseUrl = `${protocol}//${host}${baseUrl}${apiVersion}/logs/sse?log_id=${log.id}`
logSource = new EventSource(sseUrl)
logSource.onopen = () => {
isWsLoading.value = false
console.log('[LogSSE] Connection opened')
}
logSource.onmessage = (event) => {
isWsLoading.value = false
try {
const data = JSON.parse(event.data)
wsContent.value += data.text || ''
} catch {
wsContent.value += event.data
}
// 自动滚动到底部
nextTick(() => {
const pre = document.querySelector('.log-pre')
if (pre) pre.scrollTop = pre.scrollHeight
})
}
logSource.onerror = (e) => {
isWsLoading.value = false
console.error('[LogSSE] Connection error/closed', e)
if (logSource) {
logSource.close()
logSource = null
}
}
}
function closeDetail() {
if (durationTimer) {
clearInterval(durationTimer)
durationTimer = null
}
if (logSource) {
logSource.close()
logSource = null
}
selectedLog.value = null
wsContent.value = ''
}
const isStopping = ref(false)
async function stopTask() {
if (!selectedLog.value || isStopping.value) return
try {
isStopping.value = true
await api.tasks.stop(selectedLog.value.id)
toast.success('停止请求已发送')
} catch (err: any) {
toast.error(err.message || '停止失败')
} finally {
isStopping.value = false
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}毫秒`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}秒`
return `${(ms / 60000).toFixed(1)}分钟`
}
async function handleClearLogs() {
try {
await api.logs.clear(filterTaskId.value)
toast.success('相关日志已清空')
showClearDialog.value = false
loadLogs()
} catch (error: any) {
toast.error(error.message || '清空失败')
}
}
function confirmDeleteLog(id: string) {
deleteLogId.value = id
showDeleteDialog.value = true
}
async function handleDeleteLog() {
if (!deleteLogId.value) return
try {
await api.logs.delete(deleteLogId.value)
toast.success('该日志已删除')
// 如果当前选中的是这条日志,关闭详情页
if (selectedLog.value?.id === deleteLogId.value) {
closeDetail()
}
showDeleteDialog.value = false
loadLogs()
} catch (err: any) {
toast.error(err.message || '删除失败')
}
}
function getTaskTypeTitle(type: string) {
return type === TASK_TYPE.REPO ? '仓库同步' : '普通任务'
}
onMounted(() => {
// 从 URL 读取参数
const taskIdParam = route.query.task_id
if (taskIdParam) {
filterTaskId.value = String(taskIdParam)
}
const statusParam = route.query.status
if (statusParam) {
filterStatus.value = String(statusParam)
}
loadLogs()
})
// 监听路由变化
watch(() => route.query, (newQuery) => {
filterTaskId.value = newQuery.task_id ? String(newQuery.task_id) : undefined
filterStatus.value = newQuery.status ? String(newQuery.status) : undefined
currentPage.value = 1
loadLogs()
}, { deep: true })
</script>
<template>
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 class="text-xl sm:text-2xl font-bold tracking-tight">执行历史</h2>
<p class="text-muted-foreground text-sm">查看任务执行记录和日志</p>
</div>
<div class="flex items-center gap-2">
<div class="relative flex-1 sm:flex-none">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input v-model="filterKeyword" placeholder="搜索任务..." class="h-9 pl-9 w-full sm:w-40 md:w-56 text-sm"
@input="handleSearch" />
</div>
<div class="relative flex-1 sm:flex-none">
<Select v-model="filterStatus" @update:model-value="handleStatusChange">
<SelectTrigger class="h-9 w-full sm:w-28 text-sm">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">所有状态</SelectItem>
<SelectItem :value="TASK_STATUS.RUNNING">{{ TASK_STATUS_TEXT[TASK_STATUS.RUNNING] }}</SelectItem>
<SelectItem :value="TASK_STATUS.SUCCESS">{{ TASK_STATUS_TEXT[TASK_STATUS.SUCCESS] }}</SelectItem>
<SelectItem :value="TASK_STATUS.FAILED">{{ TASK_STATUS_TEXT[TASK_STATUS.FAILED] }}</SelectItem>
<SelectItem :value="TASK_STATUS.TIMEOUT">{{ TASK_STATUS_TEXT[TASK_STATUS.TIMEOUT] }}</SelectItem>
<SelectItem :value="TASK_STATUS.CANCELLED">{{ TASK_STATUS_TEXT[TASK_STATUS.CANCELLED] }}</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="loadLogs" title="刷新" :disabled="isRefreshing">
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': isRefreshing }" />
</Button>
<Button variant="outline"
class="h-9 px-4 shrink-0 text-sm text-destructive hover:bg-destructive/10 hover:text-destructive border-destructive/20"
@click="showClearDialog = true">
<Trash2 class="h-4 w-4 sm:mr-2" /> <span class="hidden sm:inline" style="padding-left: 2px;">清空日志</span>
</Button>
</div>
</div>
<div class="flex flex-col lg:flex-row gap-4 lg:h-[calc(100vh-190px)] lg:min-h-[480px]">
<!-- 日志列表 -->
<div class="flex-1 min-w-0 rounded-lg border bg-card overflow-hidden flex-col"
:class="selectedLog ? 'hidden lg:flex' : 'flex'">
<!-- 小屏表头 -->
<div
class="flex sm:hidden items-center gap-2 px-3 h-[28px] border-b bg-muted/20 text-xs text-muted-foreground font-medium">
<span class="w-14 shrink-0 pl-1">序号</span>
<span class="w-8 shrink-0 text-center">类型</span>
<span class="flex-1 min-w-0">任务名称</span>
<span class="w-16 text-right shrink-0">耗时</span>
<span class="w-8 text-center shrink-0"></span>
</div>
<!-- 大屏表头 -->
<div
class="hidden sm:flex items-center gap-4 px-4 h-[28px] border-b bg-muted/20 text-xs text-muted-foreground font-medium">
<span class="w-16 shrink-0 pl-1">序号</span>
<span class="w-12 shrink-0 text-center">类型</span>
<span class="w-36 shrink-0">任务名称</span>
<span class="flex-1 min-w-0">命令</span>
<span class="w-16 text-right shrink-0">耗时</span>
<span v-if="!selectedLog" class="w-40 text-right shrink-0 hidden md:block">执行时间</span>
<span class="w-10 shrink-0 text-center"></span>
</div>
<!-- 列表 -->
<div class="divide-y flex-1 overflow-y-auto custom-scrollbar">
<div v-if="logs.length === 0" class="text-sm text-muted-foreground text-center py-8">
暂无日志
</div>
<div v-for="(log, index) in logs" :key="log.id" :class="[
'cursor-pointer hover:bg-muted/30 transition-colors group',
selectedLog?.id === log.id && 'bg-accent/50'
]" @click="selectLog(log)">
<!-- 小屏行 -->
<div class="flex sm:hidden items-center gap-2 px-3 py-2">
<StatusDot :state="log.status" />
<span class="w-14 shrink-0 text-muted-foreground text-[10px] tabular-nums">#{{ total - (currentPage - 1) * pageSize - index }}</span>
<span class="w-8 shrink-0 flex justify-center" :title="getTaskTypeTitle(log.task_type || 'task')">
<GitBranch v-if="log.task_type === TASK_TYPE.REPO" class="h-3.5 w-3.5 text-primary" />
<Terminal v-else class="h-3.5 w-3.5 text-primary" />
</span>
<span class="flex-1 min-w-0 font-medium truncate text-xs">{{ log.task_name }}</span>
<span class="w-16 text-right shrink-0 text-muted-foreground text-xs whitespace-nowrap">{{ formatDuration(log.duration)
}}</span>
<span class="w-8 shrink-0 flex justify-center opacity-100">
<Button variant="ghost" size="icon"
class="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0"
@click.stop="confirmDeleteLog(log.id)" title="删除该日志">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</span>
</div>
<!-- 大屏行 -->
<div class="hidden sm:flex items-center gap-2 px-4 py-2">
<StatusDot :state="log.status" />
<span class="w-16 shrink-0 text-muted-foreground text-[11px] tabular-nums">#{{ total - (currentPage - 1) * pageSize - index }}</span>
<span class="w-10 shrink-0 flex justify-center" :title="getTaskTypeTitle(log.task_type || 'task')">
<GitBranch v-if="log.task_type === TASK_TYPE.REPO" class="h-4 w-4 text-primary" />
<Terminal v-else class="h-4 w-4 text-primary" />
</span>
<span class="w-36 shrink-0 font-medium truncate text-sm">{{ log.task_name }}</span>
<code class="flex-1 min-w-0 text-muted-foreground truncate text-xs bg-muted/40 px-2 py-1 rounded">
<TextOverflow :text="log.command" title="执行命令" disable-dialog />
</code>
<span class="w-16 text-right shrink-0 text-muted-foreground text-xs">{{ formatDuration(log.duration)
}}</span>
<span v-if="!selectedLog"
class="w-40 text-right shrink-0 text-muted-foreground text-xs hidden md:block">{{ log.start_time ||
log.created_at }}</span>
<span class="w-10 shrink-0 flex justify-center opacity-100">
<Button variant="ghost" size="icon"
class="h-6 w-6 text-muted-foreground hover:text-destructive shrink-0"
@click.stop="confirmDeleteLog(log.id)" title="删除该日志">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</span>
</div>
</div>
</div>
<!-- 分页 -->
<Pagination :total="total" :page="currentPage" @update:page="handlePageChange" />
</div>
<!-- 日志详情侧边栏 -->
<div v-if="selectedLog"
class="w-full lg:w-[480px] rounded-lg border bg-card flex flex-col overflow-hidden shrink-0">
<LogDetailCard
:log="selectedLog"
:content="decompressedOutput"
:loading="isWsLoading"
:is-stopping="isStopping"
@close="closeDetail"
@stop="stopTask"
@delete="confirmDeleteLog"
@maximize="showFullscreen = true"
/>
</div>
</div>
<!-- 全屏查看日志 -->
<LogViewer v-model:open="showFullscreen"
:log="selectedLog"
:is-stopping="isStopping"
:content="decompressedOutput"
@stop="stopTask" />
<!-- 清空日志确认弹窗 -->
<BaihuDialog v-model:open="showClearDialog" title="确认清空日志?">
<div class="text-sm text-muted-foreground leading-relaxed">
此操作将永久删除{{ filterTaskId ? '当前任务的' : '所有' }}任务历史记录包括控制台输出并且无法撤销
<p class="mt-2 text-destructive font-medium">⚠️ 风险该操作不可撤销</p>
</div>
<template #footer>
<Button variant="ghost" @click="showClearDialog = false">取消</Button>
<Button variant="destructive" class="shadow-lg shadow-destructive/20" @click="handleClearLogs">立即清空</Button>
</template>
</BaihuDialog>
<!-- 单条删除确认弹窗 -->
<BaihuDialog v-model:open="showDeleteDialog" title="确认删除这条日志?">
<div class="text-sm text-muted-foreground leading-relaxed">
此操作将永久删除该次运行记录和日志文件且不可恢复
</div>
<template #footer>
<Button variant="ghost" @click="showDeleteDialog = false">取消</Button>
<Button variant="destructive" class="shadow-lg shadow-destructive/20" @click="handleDeleteLog">确认删除</Button>
</template>
</BaihuDialog>
</div>
</template>