226 lines
7.7 KiB
Vue
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>
|