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

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
+5 -1
View File
@@ -20,6 +20,10 @@
<thunderbolt-outlined />
执行生成
</button>
<button :class="['topbar-tab', { active: isActive('/attachments') }]" @click="router.push('/attachments')">
<paper-clip-outlined />
附件历史
</button>
<button :class="['topbar-tab', { active: isActive('/history') }]" @click="router.push('/history')">
<clock-circle-outlined />
生成记录
@@ -44,7 +48,7 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined } from '@ant-design/icons-vue'
import { FolderOutlined, ApiOutlined, ThunderboltOutlined, ClockCircleOutlined, PaperClipOutlined } from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
+3
View File
@@ -5,6 +5,9 @@ export const generateApi = {
testStream: () => '/api/v1/generate/test-stream',
upload: (formData: FormData) => http.post('/generate/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }),
referenceFiles: (params?: any) => http.get('/generate/reference-files', { params }),
updateReferenceFile: (id: number, data: any) => http.put(`/generate/reference-files/${id}`, data),
deleteReferenceFile: (id: number) => http.delete(`/generate/reference-files/${id}`),
downloadReferenceFile: (id: number) => http.get(`/generate/reference-files/${id}/download`),
full: (data: any) => http.post('/generate/full', data),
progress: (id: number) => `/api/v1/generate/progress/${id}`,
cancel: (id: number) => http.post(`/generate/cancel/${id}`),
+1
View File
@@ -6,6 +6,7 @@ const routes = [
{ path: '/templates/:id/edit', name: 'TemplateEditor', component: () => import('@/views/TemplateEditor.vue') },
{ path: '/models', name: 'ModelManage', component: () => import('@/views/ModelManage.vue') },
{ path: '/generate', name: 'GeneratePage', component: () => import('@/views/GeneratePage.vue') },
{ path: '/attachments', name: 'AttachmentHistoryPage', component: () => import('@/views/AttachmentHistoryPage.vue') },
{ path: '/history', name: 'HistoryPage', component: () => import('@/views/HistoryPage.vue') },
{ path: '/preview/:id', name: 'PreviewEdit', component: () => import('@/views/PreviewEdit.vue') },
]
+12
View File
@@ -21,6 +21,18 @@ export interface Document {
para_count_done: number; para_count_total: number
status: 'pending' | 'generating' | 'completed' | 'failed' | 'cancelled'
file_path: string; error: string
request_payload?: {
template_name?: string
file_map?: Record<string, string[]>
paragraphs?: Array<{
paragraph_id: number
title: string
sort_index: number
need_file: boolean
file_note: string
selected_files: ReferenceFile[]
}>
}
}
export interface ReferenceFile {
+256
View File
@@ -0,0 +1,256 @@
<template>
<div class="attachment-page">
<div class="page-header">
<div>
<div class="page-title">附件历史</div>
<div class="page-desc">查看系统内已上传的参考文件便于后续模板测试时直接复用</div>
</div>
<a-button @click="fetchList">刷新列表</a-button>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">历史附件总数</div>
<div class="stat-value">{{ total }}</div>
</div>
<div class="stat-card">
<div class="stat-label">当前页文件数</div>
<div class="stat-value">{{ files.length }}</div>
</div>
</div>
<div class="toolbar">
<a-input-search
v-model:value="keyword"
placeholder="按文件名搜索附件"
allow-clear
@search="handleSearch"
/>
</div>
<a-card :bordered="false" class="table-card">
<a-table
:columns="columns"
:data-source="files"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'file_name'">
<div class="file-name">{{ record.file_name }}</div>
<div class="file-path">{{ record.file_path }}</div>
</template>
<template v-else-if="column.key === 'file_size'">
{{ formatFileSize(record.file_size) }}
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button size="small" @click="downloadFile(record)">下载</a-button>
<a-button size="small" @click="renameFile(record)">改名</a-button>
<a-button size="small" danger @click="removeFile(record)">删除</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { generateApi } from '@/api/generate'
import type { ReferenceFile } from '@/types'
const loading = ref(false)
const keyword = ref('')
const files = ref<ReferenceFile[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const columns = [
{ title: '文件名', key: 'file_name', dataIndex: 'file_name' },
{ title: '文件类型', key: 'content_type', dataIndex: 'content_type', width: 180 },
{ title: '文件大小', key: 'file_size', dataIndex: 'file_size', width: 120 },
{ title: '上传时间', key: 'created_at', dataIndex: 'created_at', width: 180 },
{ title: '操作', key: 'actions', width: 220 },
]
const pagination = ref({
current: page.value,
pageSize: pageSize.value,
total: total.value,
showSizeChanger: true,
showTotal: (count: number) => `${count} 个附件`,
})
function formatFileSize(fileSize: number) {
if (fileSize < 1024) return `${fileSize} B`
if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB`
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
}
function formatDateTime(value: string) {
return value ? value.replace('T', ' ').slice(0, 19) : ''
}
async function fetchList() {
loading.value = true
try {
const response: any = await generateApi.referenceFiles({
page: page.value,
page_size: pageSize.value,
keyword: keyword.value.trim(),
})
files.value = response.data?.items || []
total.value = response.data?.total || 0
pagination.value = {
...pagination.value,
current: response.data?.page || page.value,
pageSize: response.data?.page_size || pageSize.value,
total: response.data?.total || 0,
}
} catch (error: any) {
message.error(error.message || '加载附件历史失败')
} finally {
loading.value = false
}
}
async function downloadFile(record: ReferenceFile) {
try {
const response: any = await generateApi.downloadReferenceFile(record.id)
window.open(response.data?.url, '_blank')
} catch (error: any) {
message.error(error.message || '下载附件失败')
}
}
function renameFile(record: ReferenceFile) {
const nextName = window.prompt('请输入新的附件名称', record.file_name)
if (nextName === null) return
if (!nextName.trim()) {
message.warning('文件名不能为空')
return
}
generateApi.updateReferenceFile(record.id, { file_name: nextName.trim() }).then(async () => {
message.success('附件名称已更新')
await fetchList()
}).catch((error: any) => {
message.error(error.message || '附件改名失败')
})
}
function removeFile(record: ReferenceFile) {
Modal.confirm({
title: '删除附件',
content: `确认删除附件“${record.file_name}”吗?`,
okText: '删除',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
await generateApi.deleteReferenceFile(record.id)
message.success('附件已删除')
await fetchList()
},
})
}
function handleSearch() {
page.value = 1
fetchList()
}
function handleTableChange(nextPagination: any) {
page.value = nextPagination.current || 1
pageSize.value = nextPagination.pageSize || 10
fetchList()
}
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.attachment-page {
padding: 24px;
background: #f5f6f8;
min-height: 100%;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.page-title {
font-size: 24px;
font-weight: 700;
color: #111827;
}
.page-desc {
margin-top: 6px;
font-size: 13px;
color: #6b7280;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
padding: 18px 20px;
border-radius: 16px;
background: linear-gradient(135deg, #ffffff 0%, #f5f7fb 100%);
border: 1px solid #e5e7eb;
}
.stat-label {
font-size: 13px;
color: #6b7280;
}
.stat-value {
margin-top: 8px;
font-size: 28px;
font-weight: 700;
color: #111827;
}
.toolbar {
margin-bottom: 16px;
max-width: 360px;
}
.table-card {
border-radius: 18px;
}
.file-name {
font-size: 14px;
font-weight: 600;
color: #111827;
word-break: break-all;
}
.file-path {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
word-break: break-all;
}
</style>
+75 -70
View File
@@ -39,15 +39,11 @@
</div>
</div>
<div v-if="generating" class="status-block">
<div class="status-block">
<div class="gen-progress">
<div class="spinner" />
<div class="gen-text">{{ progressText }}</div>
</div>
<div class="progress-card">
<div class="progress-title">生成进度</div>
<a-progress :percent="progress" />
<a-button danger block @click="cancelGen">取消生成</a-button>
<div class="gen-text">
这里仅负责提交生成任务任务创建后会在后台继续执行你可以离开当前页面稍后在生成记录或任务详情中查看进度
</div>
</div>
</div>
</div>
@@ -61,14 +57,22 @@
<div v-for="paragraph in paragraphs" :key="paragraph.id" :class="['para-row', { needFile: paragraph.need_file, noFile: !paragraph.need_file }]">
<div class="para-info">
<span class="idx">{{ paragraph.sort_index }}</span>
<span>{{ paragraph.title }}</span>
<div class="para-main">
<span>{{ paragraph.title }}</span>
<span v-if="paragraph.need_file && paragraph.file_note" class="file-note">{{ paragraph.file_note }}</span>
</div>
<a-tag color="blue">{{ paragraph.modelName || '默认' }}</a-tag>
</div>
<div v-if="paragraph.need_file" class="file-info">
<a-upload :beforeUpload="(file: File) => handleFileUpload(paragraph.id, file)" :showUploadList="false">
<a-button size="small" :loading="uploadingMap[paragraph.id]">{{ uploadedFiles[paragraph.id] ? '上传' : '上传文件' }}</a-button>
<a-upload :multiple="true" :beforeUpload="(file: File) => handleFileUpload(paragraph.id, file)" :showUploadList="false">
<a-button size="small" :loading="uploadingMap[paragraph.id]">{{ uploadedFiles[paragraph.id]?.length ? '继续上传' : '上传文件' }}</a-button>
</a-upload>
<span v-if="uploadedFiles[paragraph.id]" class="uploaded-name">{{ uploadedFiles[paragraph.id] }}</span>
<div v-if="uploadedFiles[paragraph.id]?.length" class="uploaded-list">
<span v-for="item in uploadedFiles[paragraph.id]" :key="item.file_path" class="uploaded-name">
{{ item.file_name }}
<button class="remove-file-btn" @click="removeUploadedFile(paragraph.id, item.file_path)">x</button>
</span>
</div>
</div>
<span v-else class="no-file-tag">无需上传</span>
</div>
@@ -85,7 +89,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { useTemplateStore } from '@/stores/template'
@@ -100,19 +104,15 @@ const docStore = useDocumentStore()
const templates = ref<any[]>([])
const paragraphs = ref<any[]>([])
const selectedTplId = ref<number | undefined>(undefined)
const uploadedFiles = ref<Record<number, string>>({})
const uploadedFilePaths = ref<Record<number, string>>({})
const uploadedFiles = ref<Record<number, Array<{ file_name: string; file_path: string }>>>({})
const uploadedFilePaths = ref<Record<number, string[]>>({})
const uploadingMap = ref<Record<number, boolean>>({})
const generating = ref(false)
const progress = ref(0)
const progressText = ref('')
const tplInfo = ref<any>({})
const currentDocumentId = ref<number | null>(null)
let progressSource: EventSource | null = null
const needFileCount = computed(() => paragraphs.value.filter((item) => item.need_file).length)
const autoCount = computed(() => paragraphs.value.filter((item) => item.edit_mode === 'ai').length)
const fileCount = computed(() => Object.keys(uploadedFiles.value).length)
const fileCount = computed(() => Object.values(uploadedFiles.value).filter((item) => item?.length).length)
const currentTemplateName = computed(() => templates.value.find((item) => item.id === selectedTplId.value)?.name || '')
async function refreshTemplates() {
@@ -120,37 +120,6 @@ async function refreshTemplates() {
templates.value = tplStore.templates as any
}
function closeProgressSource() {
if (progressSource) {
progressSource.close()
progressSource = null
}
}
function bindProgress(documentId: number) {
closeProgressSource()
progressSource = new EventSource(generateApi.progress(documentId))
progressSource.addEventListener('progress', (event: MessageEvent) => {
const payload = JSON.parse(event.data)
progress.value = payload.percent || 0
progressText.value = payload.message || '正在生成...'
if (payload.status === 'completed' || payload.status === 'failed') {
generating.value = false
closeProgressSource()
message.success(payload.status === 'completed' ? '生成完成' : '生成结束,部分段落已回退为模拟结果')
router.push(`/preview/${documentId}`)
} else if (payload.status === 'cancelled') {
generating.value = false
closeProgressSource()
message.info('已取消生成')
}
})
progressSource.onerror = () => {
closeProgressSource()
}
}
onMounted(async () => {
await refreshTemplates()
const queryId = Number(route.query.templateId)
@@ -160,10 +129,6 @@ onMounted(async () => {
}
})
onBeforeUnmount(() => {
closeProgressSource()
})
async function onTplChange(id: number) {
const template = await tplStore.fetchOne(id)
paragraphs.value = tplStore.paragraphs as any
@@ -181,8 +146,9 @@ async function handleFileUpload(paragraphId: number, file: File) {
const fd = new FormData()
fd.append('file', file)
const res: any = await generateApi.upload(fd)
uploadedFiles.value[paragraphId] = res.data.file_name
uploadedFilePaths.value[paragraphId] = res.data.file_path
const nextFile = { file_name: res.data.file_name, file_path: res.data.file_path }
uploadedFiles.value[paragraphId] = [...(uploadedFiles.value[paragraphId] || []), nextFile]
uploadedFilePaths.value[paragraphId] = [...(uploadedFilePaths.value[paragraphId] || []), res.data.file_path]
message.success('文件上传成功')
} catch (error: any) {
message.error(error.message || '文件上传失败')
@@ -192,6 +158,11 @@ async function handleFileUpload(paragraphId: number, file: File) {
return false
}
function removeUploadedFile(paragraphId: number, filePath: string) {
uploadedFiles.value[paragraphId] = (uploadedFiles.value[paragraphId] || []).filter((item) => item.file_path !== filePath)
uploadedFilePaths.value[paragraphId] = (uploadedFilePaths.value[paragraphId] || []).filter((item) => item !== filePath)
}
async function startGen() {
if (!selectedTplId.value) {
message.warning('请先选择模板')
@@ -204,24 +175,18 @@ async function startGen() {
}
generating.value = true
progress.value = 0
progressText.value = '任务创建中...'
const fileMap = Object.fromEntries(Object.entries(uploadedFilePaths.value).map(([key, value]) => [String(key), value]))
const fileMap = Object.fromEntries(Object.entries(uploadedFilePaths.value).map(([key, value]) => [String(key), value || []]))
try {
const document: any = await docStore.generateFull({ template_id: selectedTplId.value, file_map: fileMap })
currentDocumentId.value = document.id
bindProgress(document.id)
message.success('生成任务已提交,已转到任务详情页继续查看进度')
router.push(`/preview/${document.id}`)
} catch (error: any) {
generating.value = false
message.error(error.message || '生成失败')
return
}
}
async function cancelGen() {
if (!currentDocumentId.value) return
await docStore.cancel(currentDocumentId.value)
progressText.value = '正在取消...'
generating.value = false
}
</script>
@@ -400,7 +365,32 @@ async function cancelGen() {
.para-info {
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
}
.para-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.file-note {
font-size: 12px;
color: #6b7280;
}
.file-info {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.uploaded-list {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
@@ -419,7 +409,22 @@ async function cancelGen() {
.uploaded-name {
color: #1a8c4a;
margin-left: 8px;
border: 1px solid #cdebd8;
background: #eefbf2;
padding: 4px 8px;
border-radius: 999px;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.remove-file-btn {
border: none;
background: transparent;
color: #999;
cursor: pointer;
padding: 0;
}
.no-file-tag {
+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>