Files
doc-forge/web/src/views/PreviewEdit.vue
T
2026-07-03 11:14:24 +08:00

492 lines
12 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.
<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>