feat(BookDetail): 左侧目录 + 右侧三 tab(word/html/json)+ 顶部统计卡片
This commit is contained in:
@@ -1,26 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { onMounted, ref, computed, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getBook, type Book, retryFailed, softDeleteBook } from '@/api/book';
|
||||
import DocumentItem from '../components/DocumentItem.vue';
|
||||
import { getDocumentUrl } from '@/api/document';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
|
||||
type FileKind = 'docx' | 'html' | 'json' | 'error_log';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const bookId = Number(route.params.id);
|
||||
|
||||
const book = ref<Book & { documents: any[] } | null>(null);
|
||||
const loading = ref(false);
|
||||
const selectedDocId = ref<string | null>(null);
|
||||
const activeTab = ref<'word' | 'html' | 'json'>('html');
|
||||
|
||||
const selectedDoc = computed(() => {
|
||||
if (!book.value || !selectedDocId.value) return null;
|
||||
return book.value.documents.find((d) => d.doc_id === selectedDocId.value);
|
||||
});
|
||||
|
||||
// 当前 tab 对应的 file_type
|
||||
const fileType: Record<typeof activeTab.value, FileKind> = {
|
||||
word: 'docx',
|
||||
html: 'html',
|
||||
json: 'json',
|
||||
};
|
||||
|
||||
const fileUrl = ref<string>('');
|
||||
const fileLoading = ref(false);
|
||||
|
||||
const fetchFileUrl = async () => {
|
||||
if (!selectedDoc.value || !activeTab.value) {
|
||||
fileUrl.value = '';
|
||||
return;
|
||||
}
|
||||
fileLoading.value = true;
|
||||
try {
|
||||
const r = await getDocumentUrl(
|
||||
selectedDoc.value.doc_id,
|
||||
fileType[activeTab.value],
|
||||
);
|
||||
fileUrl.value = r.url;
|
||||
} catch (e) {
|
||||
fileUrl.value = '';
|
||||
} finally {
|
||||
fileLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBook = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
book.value = await getBook(bookId);
|
||||
if (book.value?.documents.length && !selectedDocId.value) {
|
||||
selectedDocId.value = book.value.documents[0].doc_id;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch([selectedDocId, activeTab], fetchFileUrl);
|
||||
|
||||
const onRetry = async () => {
|
||||
const r = await retryFailed(bookId);
|
||||
if (r.ok) {
|
||||
@@ -40,6 +83,7 @@ const onDelete = async () => {
|
||||
};
|
||||
|
||||
const formatBytes = (n: number): string => {
|
||||
if (!n) return '0 B';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||
@@ -48,6 +92,8 @@ const formatBytes = (n: number): string => {
|
||||
const statusColor: Record<string, string> = {
|
||||
pending: 'gray',
|
||||
processing: 'arcoblue',
|
||||
html_ready: 'green',
|
||||
json_ready: 'green',
|
||||
done: 'green',
|
||||
failed: 'red',
|
||||
partial: 'orange',
|
||||
@@ -56,30 +102,56 @@ const statusColor: Record<string, string> = {
|
||||
const statusText: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
html_ready: 'HTML 已就绪',
|
||||
json_ready: 'JSON 已就绪',
|
||||
done: '完成',
|
||||
failed: '失败',
|
||||
partial: '部分失败',
|
||||
};
|
||||
|
||||
const docTypeColor: Record<string, string> = {
|
||||
课时作业: 'arcoblue',
|
||||
周末提升: 'purple',
|
||||
阶段质量评估: 'orange',
|
||||
期末质量评估: 'red',
|
||||
answer: 'cyan',
|
||||
unknown: 'gray',
|
||||
};
|
||||
|
||||
const completionRate = computed(() => {
|
||||
if (!book.value || !book.value.total_count) return 0;
|
||||
return Math.round((book.value.success_count / book.value.total_count) * 100);
|
||||
});
|
||||
|
||||
const openInNewTab = (url: string) => {
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
onMounted(fetchBook);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="book-detail">
|
||||
<a-card :bordered="false">
|
||||
<a-skeleton v-if="loading" :rows="6" />
|
||||
<template v-else-if="book">
|
||||
<a-page-header
|
||||
:title="book.name"
|
||||
:subtitle="`ID: ${book.id} | ${book.subject || ''} ${book.grade || ''} ${book.version || ''}`"
|
||||
>
|
||||
<template #extra>
|
||||
<!-- 顶部:标题 + 操作 -->
|
||||
<a-page-header :title="book.name">
|
||||
<template #subtitle>
|
||||
<a-space>
|
||||
<a-tag :color="statusColor[book.status]" size="medium">
|
||||
{{ statusText[book.status] || book.status }}
|
||||
</a-tag>
|
||||
<span class="subtitle-id">ID: {{ book.id }}</span>
|
||||
<span v-if="book.subject">· {{ book.subject }}</span>
|
||||
<span v-if="book.grade">· {{ book.grade }}</span>
|
||||
<span v-if="book.version">· {{ book.version }}</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="book.failed_count > 0"
|
||||
size="small"
|
||||
@click="onRetry"
|
||||
>
|
||||
<icon-refresh /> 重试失败
|
||||
@@ -88,7 +160,7 @@ onMounted(fetchBook);
|
||||
content="确定软删该教辅?"
|
||||
@ok="onDelete"
|
||||
>
|
||||
<a-button status="danger">
|
||||
<a-button size="small" status="danger">
|
||||
<icon-delete /> 删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
@@ -96,11 +168,51 @@ onMounted(fetchBook);
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- 元信息 + 进度 -->
|
||||
<a-row :gutter="16" class="meta-row">
|
||||
<a-col :span="6">
|
||||
<a-card class="stat-card" :bordered="false">
|
||||
<a-statistic
|
||||
title="文档总数"
|
||||
:value="book.total_count"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card class="stat-card stat-success" :bordered="false">
|
||||
<a-statistic title="成功" :value="book.success_count">
|
||||
<template #suffix>
|
||||
<icon-check-circle />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card class="stat-card stat-fail" :bordered="false">
|
||||
<a-statistic title="失败" :value="book.failed_count">
|
||||
<template #suffix>
|
||||
<icon-close-circle v-if="book.failed_count" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card class="stat-card" :bordered="false">
|
||||
<a-progress
|
||||
:percent="completionRate"
|
||||
:stroke-width="10"
|
||||
:color="completionRate === 100 ? '#00b42a' : '#165dff'"
|
||||
/>
|
||||
<div class="stat-label">完成率</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-descriptions
|
||||
:data="[]"
|
||||
:column="3"
|
||||
class="meta"
|
||||
size="medium"
|
||||
:column="4"
|
||||
class="meta-desc"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>
|
||||
<a-descriptions-item label="zip sha256">
|
||||
<a-typography-text code :ellipsis="{ showTooltip: true }">
|
||||
@@ -110,58 +222,283 @@ onMounted(fetchBook);
|
||||
<a-descriptions-item label="zip 大小">
|
||||
{{ formatBytes(book.zip_size) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="类型">
|
||||
{{ book.type || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="文档数">
|
||||
<a-typography-text bold>
|
||||
{{ book.total_count }}
|
||||
</a-typography-text>
|
||||
(成功 {{ book.success_count }},失败 {{ book.failed_count }})
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建">
|
||||
{{ book.created_at?.slice(0, 19).replace('T', ' ') }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="更新">
|
||||
{{ book.updated_at?.slice(0, 19).replace('T', ' ') }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
v-if="book.error_msg"
|
||||
label="错误"
|
||||
:span="3"
|
||||
>
|
||||
<a-typography-text type="error">
|
||||
{{ book.error_msg }}
|
||||
</a-typography-text>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider orientation="left">
|
||||
文档({{ book.documents.length }})
|
||||
</a-divider>
|
||||
|
||||
<a-collapse v-if="book.documents.length">
|
||||
<DocumentItem
|
||||
v-for="doc in book.documents"
|
||||
:key="doc.doc_id"
|
||||
:doc="doc"
|
||||
<!-- 左侧目录 + 右侧 Tabs -->
|
||||
<div class="detail-body">
|
||||
<a-card
|
||||
class="doc-list-card"
|
||||
title="文档目录"
|
||||
:bordered="false"
|
||||
>
|
||||
<template #title>
|
||||
<a-space>
|
||||
<icon-list />
|
||||
<span>文档目录 ({{ book.documents.length }})</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-scrollbar style="height: 600px; overflow: auto;">
|
||||
<a-empty
|
||||
v-if="!book.documents.length"
|
||||
description="暂无文档"
|
||||
/>
|
||||
</a-collapse>
|
||||
<a-empty v-else description="教辅中暂无文档" />
|
||||
<a-list v-else size="small" :bordered="false">
|
||||
<a-list-item
|
||||
v-for="(doc, idx) in book.documents"
|
||||
:key="doc.doc_id"
|
||||
:class="['doc-list-item', { active: selectedDocId === doc.doc_id }]"
|
||||
@click="selectedDocId = doc.doc_id"
|
||||
>
|
||||
<template #actions>
|
||||
<a-tag
|
||||
size="small"
|
||||
:color="statusColor[doc.status]"
|
||||
>
|
||||
{{ statusText[doc.status] || doc.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #avatar>
|
||||
<a-avatar
|
||||
shape="square"
|
||||
:size="32"
|
||||
:style="{
|
||||
backgroundColor: 'var(--color-fill-light-1)',
|
||||
color: statusColor[doc.status] === 'red'
|
||||
? '#f53f3f'
|
||||
: '#165dff',
|
||||
}"
|
||||
>
|
||||
<icon-file />
|
||||
</a-avatar>
|
||||
</template>
|
||||
<template #title>
|
||||
<a-typography-text
|
||||
:ellipsis="{ rows: 1, showTooltip: true }"
|
||||
>
|
||||
<span class="doc-num">{{ idx + 1 }}.</span>
|
||||
{{ doc.original_filename.replace('.docx', '') }}
|
||||
</a-typography-text>
|
||||
</template>
|
||||
<template #description>
|
||||
<a-space size="mini">
|
||||
<a-tag
|
||||
v-if="doc.doc_type"
|
||||
size="mini"
|
||||
:color="docTypeColor[doc.doc_type] || 'gray'"
|
||||
>
|
||||
{{ doc.doc_type }}
|
||||
</a-tag>
|
||||
<span class="doc-id">{{ doc.doc_id.slice(0, 8) }}</span>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</a-scrollbar>
|
||||
</a-card>
|
||||
|
||||
<a-card class="preview-card" :bordered="false">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<icon-eye />
|
||||
<span class="preview-title">
|
||||
{{ selectedDoc?.original_filename || '请选择文档' }}
|
||||
</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-if="selectedDoc">
|
||||
<a-tabs v-model:active-key="activeTab" type="rounded-card">
|
||||
<a-tab-pane key="word">
|
||||
<template #title>
|
||||
<icon-file /> Word 源文件
|
||||
</template>
|
||||
<div class="preview-pane">
|
||||
<a-button
|
||||
v-if="fileUrl"
|
||||
type="primary"
|
||||
@click="openInNewTab(fileUrl)"
|
||||
>
|
||||
<icon-download /> 下载 .docx
|
||||
</a-button>
|
||||
<div v-if="fileUrl" class="preview-info">
|
||||
<a-typography-paragraph>
|
||||
Word 源文件不能直接预览,请下载后用 Word/WPS 打开。
|
||||
</a-typography-paragraph>
|
||||
</div>
|
||||
<a-empty v-else description="文件未上传" />
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="html">
|
||||
<template #title>
|
||||
<icon-file /> HTML 预览
|
||||
</template>
|
||||
<div class="preview-pane">
|
||||
<a-spin v-if="fileLoading" tip="加载中..." />
|
||||
<a-empty
|
||||
v-else-if="!fileUrl"
|
||||
description="HTML 未生成或文档未就绪"
|
||||
/>
|
||||
<iframe
|
||||
v-else
|
||||
:src="fileUrl"
|
||||
class="preview-iframe"
|
||||
:title="selectedDoc.original_filename"
|
||||
/>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="json">
|
||||
<template #title>
|
||||
<icon-code /> JSON 结构
|
||||
</template>
|
||||
<div class="preview-pane">
|
||||
<a-spin v-if="fileLoading" tip="加载中..." />
|
||||
<a-empty
|
||||
v-else-if="!fileUrl"
|
||||
description="JSON 未生成"
|
||||
/>
|
||||
<a-button
|
||||
v-else
|
||||
type="primary"
|
||||
@click="openInNewTab(fileUrl)"
|
||||
>
|
||||
<icon-download /> 下载 .json
|
||||
</a-button>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</template>
|
||||
<a-empty v-else description="请在左侧选择一份文档" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
<a-empty v-else description="教辅不存在或已删除" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.book-detail {
|
||||
padding: 16px;
|
||||
|
||||
.subtitle-id {
|
||||
color: var(--color-text-3);
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
.meta {
|
||||
|
||||
.meta-row {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
:deep(.arco-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
margin-top: 8px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&.stat-success {
|
||||
:deep(.arco-statistic-value) {
|
||||
color: #00b42a;
|
||||
}
|
||||
}
|
||||
|
||||
&.stat-fail {
|
||||
:deep(.arco-statistic-value) {
|
||||
color: #f53f3f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meta-desc {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
display: grid;
|
||||
grid-template-columns: 380px 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.doc-list-card {
|
||||
height: fit-content;
|
||||
|
||||
.doc-list-item {
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-primary-light-1);
|
||||
|
||||
:deep(.arco-list-item-meta-title) {
|
||||
color: rgb(var(--primary-6));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.doc-num {
|
||||
color: var(--color-text-3);
|
||||
margin-right: 6px;
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.doc-id {
|
||||
color: var(--color-text-4);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
min-height: 700px;
|
||||
|
||||
.preview-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.preview-pane {
|
||||
min-height: 600px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
margin-top: 16px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.book-detail .detail-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
:deep(.arco-descriptions-item-label) {
|
||||
width: 100px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user