Files
ai-novel-studio/app/pages/projects/[id]/jobs.vue
T
jocay 3c9b65d982 AI 小说工坊:主编 + 作家多模型协作写作应用
- 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>
2026-10-18 02:04:40 +08:00

125 lines
5.7 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 { 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>