3c9b65d982
- Nuxt 4 + Nuxt UI 4 + AI SDK 7 + Drizzle/SQLite - 主编规划大纲与章节简报、选派作家;作家流式写入章节文件并实时推送前端 - 逐章确认模式(默认)与自动模式;摘要与故事记忆 - 酒馆角色卡(PNG/JSON,V1-V3)与世界书导入 - 自定义最高优先级系统提示词、审稿/修改/多版本、任务日志与提示词检视 - 模拟 LLM 服务与端到端测试脚本 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
125 lines
5.7 KiB
Vue
125 lines
5.7 KiB
Vue
<script setup lang="ts">
|
||
import { JOB_TYPE_LABEL, type JobInfo } from '#shared/types'
|
||
|
||
const store = useProjectStore()
|
||
const api = useApi()
|
||
const { jobs, chapters } = store
|
||
|
||
onMounted(() => store.refreshJobs())
|
||
|
||
const STATUS: Record<string, { label: string, color: 'warning' | 'success' | 'error' | 'neutral', icon: string, cls: string }> = {
|
||
running: { label: '进行中', color: 'warning', icon: 'i-lucide-loader-circle', cls: 'text-warning animate-spin' },
|
||
done: { label: '完成', color: 'success', icon: 'i-lucide-circle-check', cls: 'text-success' },
|
||
error: { label: '出错', color: 'error', icon: 'i-lucide-circle-x', cls: 'text-error' },
|
||
cancelled: { label: '已取消', color: 'neutral', icon: 'i-lucide-circle-slash', cls: 'text-muted' }
|
||
}
|
||
|
||
const totals = computed(() => jobs.value.reduce((s, j) => ({ input: s.input + j.inputTokens, output: s.output + j.outputTokens }), { input: 0, output: 0 }))
|
||
|
||
function chapterLabel(id: string | null) {
|
||
if (!id) return ''
|
||
const c = chapters.value.find(x => x.id === id)
|
||
return c ? `第${c.index}章` : ''
|
||
}
|
||
function duration(j: JobInfo) {
|
||
if (!j.finishedAt) return ''
|
||
const s = Math.round((new Date(j.finishedAt).getTime() - new Date(j.createdAt).getTime()) / 1000)
|
||
return s >= 60 ? `${Math.floor(s / 60)}分${s % 60}秒` : `${s}秒`
|
||
}
|
||
|
||
const detail = ref<JobDetail | null>(null)
|
||
async function openDetail(j: JobInfo) {
|
||
detail.value = await api.get<JobDetail>(`/api/jobs/${j.id}`)
|
||
}
|
||
// 正在查看的任务有更新时刷新
|
||
watch(jobs, async () => {
|
||
if (!detail.value) return
|
||
const j = jobs.value.find(x => x.id === detail.value!.id)
|
||
if (j && (j.status !== detail.value.status || j.log !== detail.value.log)) detail.value = await api.get<JobDetail>(`/api/jobs/${j.id}`)
|
||
}, { deep: true })
|
||
|
||
async function copy(text: string) {
|
||
await navigator.clipboard.writeText(text)
|
||
api.toast.add({ title: '已复制', color: 'success' })
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<UDashboardPanel id="project-jobs">
|
||
<template #header>
|
||
<UDashboardNavbar title="任务日志">
|
||
<template #right>
|
||
<span class="text-xs text-muted">最近 {{ jobs.length }} 个任务 · 输入 {{ totals.input.toLocaleString() }} / 输出 {{ totals.output.toLocaleString() }} tokens</span>
|
||
<UButton icon="i-lucide-refresh-cw" size="sm" color="neutral" variant="ghost" @click="store.refreshJobs()" />
|
||
</template>
|
||
</UDashboardNavbar>
|
||
</template>
|
||
<template #body>
|
||
<div v-if="!jobs.length" class="text-center text-muted py-20">
|
||
暂无任务
|
||
</div>
|
||
<div class="space-y-1.5">
|
||
<button
|
||
v-for="j in jobs"
|
||
:key="j.id"
|
||
class="w-full text-left flex flex-wrap items-center gap-3 rounded-md border border-default px-3 py-2 hover:bg-elevated/50"
|
||
@click="openDetail(j)"
|
||
>
|
||
<UIcon :name="STATUS[j.status]!.icon" class="size-4 shrink-0" :class="STATUS[j.status]!.cls" />
|
||
<span class="text-xs text-muted tabular-nums w-28 shrink-0">{{ formatTime(j.createdAt) }}</span>
|
||
<UBadge variant="subtle" size="sm" color="neutral">
|
||
{{ JOB_TYPE_LABEL[j.type] }}
|
||
</UBadge>
|
||
<span class="text-sm">{{ chapterLabel(j.chapterId) }}</span>
|
||
<span class="text-sm font-medium">{{ j.agentName }}</span>
|
||
<span class="text-xs text-muted truncate max-w-64">{{ j.modelLabel }}</span>
|
||
<span class="ms-auto text-xs text-muted tabular-nums">
|
||
<template v-if="j.inputTokens || j.outputTokens">{{ j.inputTokens.toLocaleString() }} → {{ j.outputTokens.toLocaleString() }} tokens · </template>{{ duration(j) }}
|
||
</span>
|
||
<p v-if="j.error" class="w-full text-xs text-error line-clamp-2 ps-7">
|
||
{{ j.error }}
|
||
</p>
|
||
<p v-else-if="j.log" class="w-full text-xs text-muted truncate ps-7">
|
||
{{ j.log.split('\n').at(-1) }}
|
||
</p>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</UDashboardPanel>
|
||
|
||
<USlideover :open="!!detail" :title="detail ? `${JOB_TYPE_LABEL[detail.type]} · ${detail.agentName}` : ''" :description="detail?.modelLabel" :ui="{ content: 'max-w-3xl' }" @update:open="v => !v && (detail = null)">
|
||
<template #body>
|
||
<div v-if="detail" class="space-y-5">
|
||
<UAlert v-if="detail.error" color="error" variant="subtle" icon="i-lucide-circle-x" title="错误" :description="detail.error" :ui="{ description: 'whitespace-pre-wrap break-all' }" />
|
||
<div>
|
||
<h3 class="font-medium mb-1">
|
||
日志
|
||
</h3>
|
||
<pre class="text-xs whitespace-pre-wrap rounded-md bg-elevated p-3">{{ detail.log || '(无)' }}</pre>
|
||
</div>
|
||
<template v-if="detail.prompt">
|
||
<div>
|
||
<div class="flex items-center mb-1">
|
||
<h3 class="font-medium">
|
||
系统提示词(System)
|
||
</h3>
|
||
<UButton icon="i-lucide-copy" size="xs" color="neutral" variant="ghost" class="ms-auto" @click="copy(detail.prompt.system)" />
|
||
</div>
|
||
<pre class="text-xs whitespace-pre-wrap rounded-md bg-elevated p-3 max-h-96 overflow-y-auto">{{ detail.prompt.system }}</pre>
|
||
</div>
|
||
<div>
|
||
<div class="flex items-center mb-1">
|
||
<h3 class="font-medium">
|
||
用户提示词(Prompt)
|
||
</h3>
|
||
<span class="text-xs text-muted ms-2">{{ detail.prompt.prompt.length.toLocaleString() }} 字符</span>
|
||
<UButton icon="i-lucide-copy" size="xs" color="neutral" variant="ghost" class="ms-auto" @click="copy(detail.prompt.prompt)" />
|
||
</div>
|
||
<pre class="text-xs whitespace-pre-wrap rounded-md bg-elevated p-3 max-h-[32rem] overflow-y-auto">{{ detail.prompt.prompt }}</pre>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</USlideover>
|
||
</template>
|