完善附件管理与生成任务跟踪

This commit is contained in:
zwt13703
2026-07-02 18:26:50 +08:00
parent d3530ab0b7
commit 401e8cf57b
16 changed files with 873 additions and 203 deletions
+302 -121
View File
@@ -1,70 +1,78 @@
<template>
<div style="padding:24px">
<a-card>
<template #title>
<span style="display:flex;align-items:center;gap:8px">
<file-text-outlined />
文档预览
</span>
</template>
<template #extra>
<a-button-group>
<a-button @click="undo"><undo-outlined />撤销</a-button>
<a-button @click="redo"><redo-outlined />重做</a-button>
</a-button-group>
<a-button type="primary" style="margin-left:12px" @click="exportDocx">导出 Word</a-button>
<a-button style="margin-left:8px" @click="exportPdf">导出 PDF</a-button>
</template>
<div style="display:flex;gap:16px">
<div style="flex:1;min-width:0">
<div style="display:flex;gap:4px;padding:8px;border:1px solid #d9d9d9;border-bottom:none;border-radius:6px 6px 0 0;flex-wrap:wrap">
<a-button size="small"><b>B</b></a-button>
<a-button size="small"><i>I</i></a-button>
<a-button size="small"><u>U</u></a-button>
<a-divider type="vertical" />
<a-button size="small"><font-size-outlined /></a-button>
<a-button size="small"><ordered-list-outlined /></a-button>
<a-button size="small"><table-outlined /></a-button>
</div>
<div
ref="editorRef"
contenteditable="true"
style="border:1px solid #d9d9d9;border-radius:0 0 6px 6px;padding:24px;min-height:500px;outline:none;font-size:14px;line-height:1.8;background:#fff"
v-html="editorHtml"
@input="onEdit"
/>
</div>
<div style="width:220px;flex-shrink:0">
<a-card title="文档结构" size="small">
<div
v-for="s in structure"
:key="s.id"
class="struct-item"
@click="scrollTo(s.anchor)"
>
<file-text-outlined />
{{ s.title }}
<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>
<a-card class="mapping-card" title="段落与附件映射">
<div v-if="paragraphMappings.length" class="mapping-list">
<div v-for="item in paragraphMappings" :key="item.paragraph_id" class="mapping-item">
<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>
</a-card>
</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>
<a-card class="preview-card" title="生成结果预览">
<div v-if="logs.length" class="preview-wrap">
<section v-for="item in logs" :key="item.paragraph_id" :id="`section-${item.paragraph_id}`" class="preview-section">
<div class="preview-section-head">
<h3>{{ item.title }}</h3>
<a-badge :status="statusBadge(item.status)" :text="statusText(item.status)" />
</div>
<div class="preview-block" v-html="renderBlocks(item.content?.content || [])" />
</section>
</div>
<a-empty v-else :description="isRunning ? '任务进行中,已完成段落会逐步出现在这里' : '暂无生成内容'" />
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { generateApi } from '@/api/generate'
import { message } from 'ant-design-vue'
import {
FileTextOutlined,
UndoOutlined,
RedoOutlined,
FontSizeOutlined,
OrderedListOutlined,
TableOutlined,
} from '@ant-design/icons-vue'
import { useDocumentStore } from '@/stores/document'
import { generateApi } from '@/api/generate'
interface ContentBlock {
type: string
@@ -78,23 +86,40 @@ interface LogItem {
paragraph_id: number
title: string
sort_index: number
content: {
content: ContentBlock[]
}
status: string
content: { content: ContentBlock[] }
}
const route = useRoute()
const editorRef = ref<HTMLElement | null>(null)
const docStore = useDocumentStore()
const documentInfo = ref<any>({})
const logs = ref<LogItem[]>([])
const editorHtml = ref('<p>加载中...</p>')
const progressPercent = ref(0)
const progressMessage = ref('')
let progressSource: EventSource | null = null
const structure = computed(() =>
logs.value.map((item) => ({
id: item.paragraph_id,
title: item.title || `段落 ${item.sort_index}`,
anchor: `section-${item.paragraph_id}`,
})),
)
const isRunning = computed(() => ['pending', 'generating'].includes(documentInfo.value.status))
const isCompleted = computed(() => documentInfo.value.status === 'completed')
const paragraphMappings = computed(() => documentInfo.value.request_payload?.paragraphs || [])
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 || []
@@ -102,19 +127,15 @@ function renderTable(block: ContentBlock) {
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 border="1" style="width:100%;border-collapse:collapse;margin:12px 0">${thead}${tbody}</table>`
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 style="margin:10px 0">${block.text || ''}</p>`
if (block.type === 'table') return renderTable(block)
return `<p class="result-text">${block.text || ''}</p>`
})
.join('')
}
@@ -122,75 +143,235 @@ function renderBlocks(blocks: ContentBlock[]) {
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 (!logs.value.length) {
editorHtml.value = '<p>暂无生成内容。</p>'
return
}
editorHtml.value = logs.value
.map(
(item) => `
<section id="section-${item.paragraph_id}" style="margin-bottom:24px">
<h3 style="margin-bottom:12px">${item.title}</h3>
<div style="background:#f8faff;padding:16px;border-left:3px solid #5b5bd6;border-radius:4px">
${renderBlocks(item.content?.content || [])}
</div>
</section>
`,
)
.join('')
}
function scrollTo(anchor: string) {
const element = document.getElementById(anchor)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
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
}
}
function onEdit() {}
function undo() {
document.execCommand('undo')
}
function redo() {
document.execCommand('redo')
async function cancelTask() {
const id = Number(route.params.id)
await docStore.cancel(id)
message.info('已发起取消请求')
}
function exportDocx() {
const id = Number(route.params.id)
window.open(generateApi.exportDocx(id))
window.open(generateApi.exportDocx(Number(route.params.id)))
}
function exportPdf() {
const id = Number(route.params.id)
window.open(generateApi.exportPdf(id))
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 || '加载文档失败')
editorHtml.value = '<p>文档加载失败。</p>'
message.error(error.message || '加载任务详情失败')
}
})
onBeforeUnmount(() => {
progressSource?.close()
progressSource = null
})
</script>
<style scoped>
.struct-item {
padding: 8px;
cursor: pointer;
border-radius: 4px;
font-size: 13px;
display: flex;
gap: 6px;
align-items: center;
.detail-page {
padding: 24px;
}
.struct-item:hover {
background: #f5f5f5;
.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,
.mapping-card,
.preview-card {
margin-bottom: 16px;
border-radius: 18px;
}
.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;
}
.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-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-wrap {
display: grid;
gap: 16px;
}
.preview-section {
padding: 16px;
border-radius: 14px;
background: #fff;
border: 1px solid #e5e7eb;
}
.preview-section-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.preview-section-head h3 {
margin: 0;
}
: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>