492 lines
12 KiB
Vue
492 lines
12 KiB
Vue
<template>
|
||
<div class="detail-page">
|
||
<div class="detail-head">
|
||
<div>
|
||
<div class="detail-title">任务详情</div>
|
||
<div class="detail-desc">查看当前生成状态、已选附件,以及已完成段落的输出结果。</div>
|
||
</div>
|
||
<div class="detail-actions">
|
||
<a-button v-if="isRunning" danger @click="cancelTask">取消任务</a-button>
|
||
<a-button :disabled="!isCompleted" @click="exportDocx">导出 Word</a-button>
|
||
<a-button :disabled="!isCompleted" @click="exportPdf">导出 PDF</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-label">任务状态</div>
|
||
<div class="stat-value">{{ statusText(documentInfo.status) }}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">完成进度</div>
|
||
<div class="stat-value">{{ documentInfo.para_count_done || 0 }}/{{ documentInfo.para_count_total || 0 }}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">关联模板</div>
|
||
<div class="stat-value">{{ documentInfo.request_payload?.template_name || documentInfo.name || '-' }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<a-card class="status-card">
|
||
<a-badge :status="statusBadge(documentInfo.status)" :text="statusText(documentInfo.status)" />
|
||
<a-progress style="margin-top: 14px" :percent="progressPercent" />
|
||
<div class="status-message">{{ progressMessage || documentInfo.error || '任务已创建,等待执行。' }}</div>
|
||
</a-card>
|
||
|
||
<div class="detail-layout">
|
||
<aside class="detail-left">
|
||
<a-card class="left-card" title="段落列表">
|
||
<div v-if="paragraphMappings.length" class="mapping-list">
|
||
<div
|
||
v-for="item in paragraphMappings"
|
||
:key="item.paragraph_id"
|
||
:class="['mapping-item', { active: selectedParagraphId === item.paragraph_id }]"
|
||
@click="selectParagraph(item.paragraph_id)"
|
||
>
|
||
<div class="mapping-top">
|
||
<span class="mapping-index">{{ item.sort_index }}</span>
|
||
<div class="mapping-main">
|
||
<div class="mapping-title">{{ item.title }}</div>
|
||
<div v-if="item.file_note" class="mapping-note">{{ item.file_note }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="mapping-status">
|
||
<a-badge :status="statusBadge(logStatusMap[item.paragraph_id] || 'pending')" :text="statusText(logStatusMap[item.paragraph_id] || 'pending')" />
|
||
</div>
|
||
<div v-if="item.selected_files?.length" class="mapping-files">
|
||
<span v-for="file in item.selected_files" :key="file.file_path" class="mapping-file">{{ file.file_name }}</span>
|
||
</div>
|
||
<div v-else class="mapping-empty">未选择附件</div>
|
||
</div>
|
||
</div>
|
||
<a-empty v-else description="当前任务未记录段落信息" />
|
||
</a-card>
|
||
</aside>
|
||
|
||
<section class="detail-right">
|
||
<a-card class="preview-card" title="生成结果预览">
|
||
<template v-if="selectedParagraph">
|
||
<div class="preview-section">
|
||
<div class="preview-section-head">
|
||
<h3>{{ selectedParagraph.title }}</h3>
|
||
<a-badge :status="statusBadge(selectedParagraph.status)" :text="statusText(selectedParagraph.status)" />
|
||
</div>
|
||
<div v-if="selectedMapping?.file_note" class="preview-note">文件要求:{{ selectedMapping.file_note }}</div>
|
||
<div v-if="selectedMapping?.selected_files?.length" class="preview-files">
|
||
<span v-for="file in selectedMapping.selected_files" :key="file.file_path" class="mapping-file">{{ file.file_name }}</span>
|
||
</div>
|
||
<div class="preview-block" v-html="renderBlocks(selectedParagraph.content?.content || [])" />
|
||
</div>
|
||
</template>
|
||
<a-empty v-else :description="isRunning ? '任务进行中,当前段落结果尚未生成' : '当前没有可预览的段落结果'" />
|
||
</a-card>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { message } from 'ant-design-vue'
|
||
import { useDocumentStore } from '@/stores/document'
|
||
import { generateApi } from '@/api/generate'
|
||
|
||
interface ContentBlock {
|
||
type: string
|
||
text?: string
|
||
title?: string
|
||
headers?: string[]
|
||
rows?: string[][]
|
||
}
|
||
|
||
interface LogItem {
|
||
paragraph_id: number
|
||
title: string
|
||
sort_index: number
|
||
status: string
|
||
content: { content: ContentBlock[] }
|
||
}
|
||
|
||
const route = useRoute()
|
||
const docStore = useDocumentStore()
|
||
const documentInfo = ref<any>({})
|
||
const logs = ref<LogItem[]>([])
|
||
const progressPercent = ref(0)
|
||
const progressMessage = ref('')
|
||
const selectedParagraphId = ref<number | null>(null)
|
||
let progressSource: EventSource | null = null
|
||
|
||
const isRunning = computed(() => ['pending', 'generating'].includes(documentInfo.value.status))
|
||
const isCompleted = computed(() => documentInfo.value.status === 'completed')
|
||
const paragraphMappings = computed(() => documentInfo.value.request_payload?.paragraphs || [])
|
||
const logStatusMap = computed(() =>
|
||
Object.fromEntries(logs.value.map((item) => [item.paragraph_id, item.status]))
|
||
)
|
||
const selectedParagraph = computed(() =>
|
||
logs.value.find((item) => item.paragraph_id === selectedParagraphId.value) || null
|
||
)
|
||
const selectedMapping = computed(() =>
|
||
paragraphMappings.value.find((item: any) => item.paragraph_id === selectedParagraphId.value) || null
|
||
)
|
||
|
||
function statusText(status: string) {
|
||
const map: Record<string, string> = {
|
||
completed: '已完成',
|
||
failed: '失败',
|
||
cancelled: '已取消',
|
||
generating: '生成中',
|
||
pending: '等待中',
|
||
success: '成功',
|
||
}
|
||
return map[status] || status || '未知'
|
||
}
|
||
|
||
function statusBadge(status: string) {
|
||
if (status === 'completed' || status === 'success') return 'success'
|
||
if (status === 'failed') return 'error'
|
||
if (status === 'cancelled') return 'warning'
|
||
return 'processing'
|
||
}
|
||
|
||
function renderTable(block: ContentBlock) {
|
||
const headers = block.headers || []
|
||
const rows = block.rows || []
|
||
const thead = headers.length
|
||
? `<thead><tr>${headers.map((header) => `<th>${header}</th>`).join('')}</tr></thead>`
|
||
: ''
|
||
const tbody = `<tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody>`
|
||
return `<table class="result-table">${thead}${tbody}</table>`
|
||
}
|
||
|
||
function renderBlocks(blocks: ContentBlock[]) {
|
||
return blocks
|
||
.map((block) => {
|
||
if (block.type === 'table') return renderTable(block)
|
||
return `<p class="result-text">${block.text || ''}</p>`
|
||
})
|
||
.join('')
|
||
}
|
||
|
||
async function loadDocument() {
|
||
const id = Number(route.params.id)
|
||
const response: any = await generateApi.getDocument(id)
|
||
documentInfo.value = response.data || {}
|
||
logs.value = response.data?.logs || []
|
||
if (!selectedParagraphId.value) {
|
||
selectedParagraphId.value = logs.value[0]?.paragraph_id || paragraphMappings.value[0]?.paragraph_id || null
|
||
}
|
||
if (
|
||
selectedParagraphId.value &&
|
||
!paragraphMappings.value.some((item: any) => item.paragraph_id === selectedParagraphId.value) &&
|
||
!logs.value.some((item) => item.paragraph_id === selectedParagraphId.value)
|
||
) {
|
||
selectedParagraphId.value = logs.value[0]?.paragraph_id || paragraphMappings.value[0]?.paragraph_id || null
|
||
}
|
||
}
|
||
|
||
function selectParagraph(paragraphId: number) {
|
||
selectedParagraphId.value = paragraphId
|
||
}
|
||
|
||
function bindProgress() {
|
||
const id = Number(route.params.id)
|
||
if (progressSource) progressSource.close()
|
||
progressSource = new EventSource(generateApi.progress(id))
|
||
progressSource.addEventListener('progress', async (event: MessageEvent) => {
|
||
const payload = JSON.parse(event.data)
|
||
progressPercent.value = payload.percent || 0
|
||
progressMessage.value = payload.message || ''
|
||
await loadDocument()
|
||
if (['completed', 'failed', 'cancelled'].includes(payload.status)) {
|
||
progressSource?.close()
|
||
progressSource = null
|
||
}
|
||
})
|
||
progressSource.onerror = () => {
|
||
progressSource?.close()
|
||
progressSource = null
|
||
}
|
||
}
|
||
|
||
async function cancelTask() {
|
||
const id = Number(route.params.id)
|
||
await docStore.cancel(id)
|
||
message.info('已发起取消请求')
|
||
}
|
||
|
||
function exportDocx() {
|
||
window.open(generateApi.exportDocx(Number(route.params.id)))
|
||
}
|
||
|
||
function exportPdf() {
|
||
window.open(generateApi.exportPdf(Number(route.params.id)))
|
||
}
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
await loadDocument()
|
||
progressPercent.value = Math.floor(((documentInfo.value.para_count_done || 0) / Math.max(documentInfo.value.para_count_total || 1, 1)) * 100)
|
||
if (isRunning.value) bindProgress()
|
||
} catch (error: any) {
|
||
message.error(error.message || '加载任务详情失败')
|
||
}
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
progressSource?.close()
|
||
progressSource = null
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.detail-page {
|
||
padding: 24px;
|
||
height: calc(100vh - 52px);
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.detail-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 16px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.detail-title {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: #111827;
|
||
}
|
||
|
||
.detail-desc {
|
||
margin-top: 6px;
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.detail-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 16px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.stat-card {
|
||
padding: 18px;
|
||
border-radius: 16px;
|
||
border: 1px solid #e5e7eb;
|
||
background: linear-gradient(135deg, #ffffff 0%, #f7f8fc 100%);
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.stat-value {
|
||
margin-top: 8px;
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
color: #111827;
|
||
}
|
||
|
||
.status-card,
|
||
.preview-card {
|
||
margin-bottom: 16px;
|
||
border-radius: 18px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.detail-layout {
|
||
display: flex;
|
||
gap: 16px;
|
||
align-items: flex-start;
|
||
min-height: 0;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.detail-left {
|
||
width: 360px;
|
||
flex-shrink: 0;
|
||
height: 100%;
|
||
min-height: 0;
|
||
}
|
||
|
||
.detail-right {
|
||
min-width: 0;
|
||
flex: 1;
|
||
height: 100%;
|
||
min-height: 0;
|
||
}
|
||
|
||
.left-card {
|
||
border-radius: 18px;
|
||
height: 100%;
|
||
}
|
||
|
||
.status-message {
|
||
margin-top: 12px;
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.mapping-list {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.mapping-item {
|
||
padding: 14px;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 14px;
|
||
background: #fafbfc;
|
||
cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
}
|
||
|
||
.mapping-item:hover {
|
||
border-color: #c7d2fe;
|
||
background: #f8faff;
|
||
}
|
||
|
||
.mapping-item.active {
|
||
border-color: #4f46e5;
|
||
background: #eef2ff;
|
||
}
|
||
|
||
.mapping-top {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.mapping-index {
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 50%;
|
||
background: #eef2ff;
|
||
color: #4f46e5;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.mapping-main {
|
||
flex: 1;
|
||
}
|
||
|
||
.mapping-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #111827;
|
||
}
|
||
|
||
.mapping-note {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.mapping-status {
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.mapping-files {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.mapping-file {
|
||
padding: 4px 10px;
|
||
border-radius: 999px;
|
||
background: #eefbf2;
|
||
border: 1px solid #cdebd8;
|
||
color: #1a8c4a;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.mapping-empty {
|
||
margin-top: 10px;
|
||
font-size: 12px;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
.preview-section {
|
||
padding: 16px;
|
||
border-radius: 14px;
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
min-height: 100%;
|
||
}
|
||
|
||
.preview-section-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.preview-section-head h3 {
|
||
margin: 0;
|
||
}
|
||
|
||
.preview-note {
|
||
margin-bottom: 12px;
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.preview-files {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
:deep(.left-card .ant-card-body) {
|
||
height: calc(100% - 57px);
|
||
overflow-y: auto;
|
||
}
|
||
|
||
:deep(.preview-card) {
|
||
height: 100%;
|
||
}
|
||
|
||
:deep(.preview-card .ant-card-body) {
|
||
height: calc(100% - 57px);
|
||
overflow-y: auto;
|
||
}
|
||
|
||
:deep(.result-text) {
|
||
margin: 0 0 12px;
|
||
line-height: 1.8;
|
||
}
|
||
|
||
:deep(.result-table) {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
:deep(.result-table th),
|
||
:deep(.result-table td) {
|
||
border: 1px solid #d1d5db;
|
||
padding: 6px 8px;
|
||
font-size: 12px;
|
||
}
|
||
</style>
|