Files
code-utils/frontend/src/views/PackTasks.vue
2026-08-15 17:18:00 +08:00

226 lines
7.7 KiB
Vue

<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
Package, RefreshCw, Trash2, LoaderCircle, Check, CircleX, Terminal,
FolderOpen, Copy, Eraser
} from 'lucide-vue-next'
import { call, on, copyText } from '../api'
import { useAppStore } from '../store'
const { t } = useI18n()
const store = useAppStore()
const route = useRoute()
const router = useRouter()
const tasks = ref([])
const selectedId = ref('')
const detail = ref(null)
const loading = ref(false)
const logEl = ref(null)
const stickBottom = ref(true)
let offTask = null
let offLog = null
const selected = computed(() => tasks.value.find(x => x.id === selectedId.value) || null)
const logLines = computed(() => detail.value?.logs || [])
function statusLabel(s) {
if (s === 'running') return t('packTaskRunning')
if (s === 'done') return t('packTaskDone')
return t('packTaskFailed')
}
function fmtTime(s) {
if (!s) return ''
return String(s).replace('T', ' ').slice(5, 19)
}
function lineClass(line) {
const s = String(line || '')
if (s.startsWith('✗') || /error|failed|失败/i.test(s)) return 'err'
if (s.startsWith('✓') || /success|完成|done/i.test(s)) return 'ok'
if (s.startsWith('▶') || s.startsWith('$') || s.startsWith('cwd:') || s.startsWith('pid=')) return 'meta'
return ''
}
async function loadList() {
loading.value = true
try { tasks.value = await call('ListLocalPackTasks') || [] } catch { tasks.value = [] }
loading.value = false
if (!selectedId.value && tasks.value.length) {
selectedId.value = tasks.value[0].id
} else if (selectedId.value && !tasks.value.some(x => x.id === selectedId.value)) {
selectedId.value = tasks.value[0]?.id || ''
}
}
async function loadDetail(id) {
if (!id) { detail.value = null; return }
try {
detail.value = await call('GetLocalPackTask', id)
await scrollLog(true)
} catch {
detail.value = null
}
}
function selectTask(id) {
selectedId.value = id
router.replace({ path: '/pack-tasks', query: id ? { id } : {} })
}
async function clearDone() {
try {
tasks.value = await call('ClearFinishedLocalPackTasks') || []
if (selectedId.value && !tasks.value.some(x => x.id === selectedId.value)) {
selectedId.value = tasks.value[0]?.id || ''
router.replace({ path: '/pack-tasks', query: selectedId.value ? { id: selectedId.value } : {} })
}
} catch { /* ignore */ }
}
async function dismiss(id) {
try {
tasks.value = await call('DismissLocalPackTask', id) || []
if (selectedId.value === id) {
selectedId.value = tasks.value[0]?.id || ''
router.replace({ path: '/pack-tasks', query: selectedId.value ? { id: selectedId.value } : {} })
}
} catch { /* ignore */ }
}
async function copyLogs() {
const text = (detail.value?.logs || []).join('\n')
try {
await copyText(text)
store.showToast({ type: 'success', text: t('packLogCopied') })
} catch {
store.showToast({ type: 'error', text: t('copyFailed') })
}
}
function onLogScroll() {
const el = logEl.value
if (!el) return
stickBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < 48
}
async function scrollLog(force) {
await nextTick()
const el = logEl.value
if (!el) return
if (force || stickBottom.value) el.scrollTop = el.scrollHeight
}
watch(selectedId, id => { loadDetail(id) })
watch(() => route.query.id, id => {
const v = String(id || '')
if (v && v !== selectedId.value) selectedId.value = v
})
onMounted(async () => {
if (route.query.id) selectedId.value = String(route.query.id)
await loadList()
if (selectedId.value) await loadDetail(selectedId.value)
offTask = on('pack:task', async ev => {
await loadList()
const d = Array.isArray(ev) ? ev[0] : ev
if (d?.id && d.id === selectedId.value && detail.value?.id === d.id) {
detail.value = {
...detail.value,
status: d.status || detail.value.status,
pid: d.pid || detail.value.pid,
error: d.error ?? detail.value.error,
endedAt: d.endedAt || detail.value.endedAt
}
}
})
offLog = on('pack:log', ev => {
const d = Array.isArray(ev) ? ev[0] : ev
if (!d?.taskId || d.taskId !== selectedId.value) return
if (!detail.value || detail.value.id !== d.taskId) return
detail.value = { ...detail.value, logs: [...(detail.value.logs || []), d.line] }
scrollLog(false)
})
})
onUnmounted(() => {
offTask?.()
offLog?.()
})
</script>
<template>
<div class="page pack-tasks-page">
<header class="page-head sticky-head lp-head">
<div class="lp-head-top">
<div>
<h1>{{ t('packTasksPage') }}</h1>
<p>{{ t('packTasksPageSub') }}</p>
</div>
<div class="lp-tools">
<button class="btn secondary" :disabled="loading" @click="loadList"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
<button class="btn secondary" :disabled="!tasks.some(x => x.status !== 'running')" @click="clearDone"><Trash2 />{{ t('packTasksClear') }}</button>
</div>
</div>
</header>
<div class="pack-tasks-layout">
<aside class="pack-tasks-side">
<div v-if="!tasks.length" class="lp-empty">{{ t('packTasksEmpty') }}</div>
<button
v-for="x in tasks"
:key="x.id"
type="button"
class="pack-task-row"
:class="[x.status, { on: x.id === selectedId }]"
@click="selectTask(x.id)"
>
<span class="pack-task-st">
<LoaderCircle v-if="x.status === 'running'" class="spin" />
<Check v-else-if="x.status === 'done'" />
<CircleX v-else />
</span>
<div class="pack-task-main">
<b>{{ x.title || x.cmd }}</b>
<small>{{ statusLabel(x.status) }} · {{ fmtTime(x.startedAt) }}</small>
<code v-if="x.cmd !== x.title">{{ x.cmd }}</code>
</div>
<button v-if="x.status !== 'running'" type="button" class="pack-task-rm" :title="t('delete')" @click.stop="dismiss(x.id)"><Trash2 /></button>
</button>
</aside>
<section class="pack-tasks-console card">
<template v-if="selected || detail">
<header class="pack-console-head">
<div>
<h2><Terminal />{{ detail?.title || selected?.title || t('packTasks') }}</h2>
<p>
<em :class="detail?.status || selected?.status">{{ statusLabel(detail?.status || selected?.status) }}</em>
<span v-if="detail?.pid || selected?.pid">PID {{ detail?.pid || selected?.pid }}</span>
<span v-if="detail?.startedAt">{{ fmtTime(detail.startedAt) }}</span>
</p>
</div>
<div class="pack-console-acts">
<button type="button" class="btn secondary" :disabled="!logLines.length" @click="copyLogs"><Copy />{{ t('packLogCopy') }}</button>
</div>
</header>
<div v-if="detail?.dir || detail?.cmd" class="pack-console-meta">
<span v-if="detail.dir" :title="detail.dir"><FolderOpen />{{ detail.dir }}</span>
<code v-if="detail.cmd">$ {{ detail.cmd }}</code>
</div>
<div ref="logEl" class="pack-console-body" @scroll="onLogScroll">
<pre v-for="(line, i) in logLines" :key="i" class="pack-console-line" :class="lineClass(line)">{{ line }}</pre>
<div v-if="!logLines.length" class="pack-console-empty">{{ t('packLogEmpty') }}</div>
</div>
<p v-if="detail?.error" class="pack-console-err">{{ detail.error }}</p>
</template>
<div v-else class="pack-console-placeholder">
<Eraser />
<p>{{ t('packTasksPick') }}</p>
</div>
</section>
</div>
</div>
</template>