更新若干功能
This commit is contained in:
225
frontend/src/views/PackTasks.vue
Normal file
225
frontend/src/views/PackTasks.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user