完善附件管理与生成任务跟踪
This commit is contained in:
+302
-121
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user