refactor(BookDetail): 移除不稳的 Arco 子组件 + 自实现简洁布局

- 移除 a-page-header/a-statistic/a-descriptions/a-list-item-meta
  (Arco 2.x 这些组件在 BookDetail 里有渲染问题)
- 顶部改用 a-card + flex 自定义布局
- 4 张统计卡用 div + 大数字
- 左侧目录改用 ul/li 自定义(避开 a-list slot 顺序坑)
- 右侧三 tab 保留 a-tabs,word/json 内用 div + a-button
This commit is contained in:
2026-10-11 19:09:51 +08:00
parent e69bc67823
commit 5b37e40db3

View File

@@ -5,8 +5,6 @@ import { getBook, type Book, retryFailed, softDeleteBook } from '@/api/book';
import { getDocumentUrl } from '@/api/document'; import { getDocumentUrl } from '@/api/document';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
type FileKind = 'docx' | 'html' | 'json' | 'error_log';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const bookId = Number(route.params.id); const bookId = Number(route.params.id);
@@ -16,23 +14,23 @@ const loading = ref(false);
const selectedDocId = ref<string | null>(null); const selectedDocId = ref<string | null>(null);
const activeTab = ref<'word' | 'html' | 'json'>('html'); const activeTab = ref<'word' | 'html' | 'json'>('html');
const selectedDoc = computed(() => { type FileKind = 'docx' | 'html' | 'json' | 'error_log';
if (!book.value || !selectedDocId.value) return null; const fileTypeMap: Record<typeof activeTab.value, FileKind> = {
return book.value.documents.find((d) => d.doc_id === selectedDocId.value);
});
// 当前 tab 对应的 file_type
const fileType: Record<typeof activeTab.value, FileKind> = {
word: 'docx', word: 'docx',
html: 'html', html: 'html',
json: 'json', json: 'json',
}; };
const selectedDoc = computed(() => {
if (!book.value || !selectedDocId.value) return null;
return book.value.documents.find((d) => d.doc_id === selectedDocId.value);
});
const fileUrl = ref<string>(''); const fileUrl = ref<string>('');
const fileLoading = ref(false); const fileLoading = ref(false);
const fetchFileUrl = async () => { const fetchFileUrl = async () => {
if (!selectedDoc.value || !activeTab.value) { if (!selectedDoc.value) {
fileUrl.value = ''; fileUrl.value = '';
return; return;
} }
@@ -40,9 +38,9 @@ const fetchFileUrl = async () => {
try { try {
const r = await getDocumentUrl( const r = await getDocumentUrl(
selectedDoc.value.doc_id, selectedDoc.value.doc_id,
fileType[activeTab.value], fileTypeMap[activeTab.value],
); );
fileUrl.value = r.url; fileUrl.value = r?.url ?? '';
} catch (e) { } catch (e) {
fileUrl.value = ''; fileUrl.value = '';
} finally { } finally {
@@ -62,7 +60,7 @@ const fetchBook = async () => {
} }
}; };
watch([selectedDocId, activeTab], fetchFileUrl); watch([selectedDocId, activeTab], fetchFileUrl, { immediate: false });
const onRetry = async () => { const onRetry = async () => {
const r = await retryFailed(bookId); const r = await retryFailed(bookId);
@@ -82,21 +80,8 @@ const onDelete = async () => {
} }
}; };
const formatBytes = (n: number): string => { const openInNewTab = (url: string) => {
if (!n) return '0 B'; window.open(url, '_blank');
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
};
const statusColor: Record<string, string> = {
pending: 'gray',
processing: 'arcoblue',
html_ready: 'green',
json_ready: 'green',
done: 'green',
failed: 'red',
partial: 'orange',
}; };
const statusText: Record<string, string> = { const statusText: Record<string, string> = {
@@ -109,13 +94,14 @@ const statusText: Record<string, string> = {
partial: '部分失败', partial: '部分失败',
}; };
const docTypeColor: Record<string, string> = { const statusColor: Record<string, string> = {
课时作业: 'arcoblue', pending: 'gray',
周末提升: 'purple', processing: 'arcoblue',
阶段质量评估: 'orange', html_ready: 'green',
期末质量评估: 'red', json_ready: 'green',
answer: 'cyan', failed: 'red',
unknown: 'gray', partial: 'orange',
done: 'green',
}; };
const completionRate = computed(() => { const completionRate = computed(() => {
@@ -123,31 +109,31 @@ const completionRate = computed(() => {
return Math.round((book.value.success_count / book.value.total_count) * 100); return Math.round((book.value.success_count / book.value.total_count) * 100);
}); });
const openInNewTab = (url: string) => { const truncate = (s: string, len = 8) => (s ? `${s.slice(0, len)}…` : '');
window.open(url, '_blank');
};
onMounted(fetchBook); onMounted(fetchBook);
</script> </script>
<template> <template>
<div class="book-detail"> <div class="book-detail">
<a-skeleton v-if="loading" :rows="6" /> <a-skeleton v-if="loading" :rows="8" />
<template v-else-if="book"> <template v-else-if="book">
<!-- 顶部:标题 + 操作 --> <!-- 顶部:标题 + 操作 -->
<a-page-header :title="book.name"> <a-card :bordered="false" class="header-card">
<template #subtitle> <div class="header-row">
<a-space> <div class="header-info">
<a-tag :color="statusColor[book.status]" size="medium"> <div class="book-name">{{ book.name }}</div>
{{ statusText[book.status] || book.status }} <div class="book-meta">
</a-tag> <a-tag :color="statusColor[book.status]" size="small">
<span class="subtitle-id">ID: {{ book.id }}</span> {{ statusText[book.status] || book.status }}
<span v-if="book.subject">· {{ book.subject }}</span> </a-tag>
<span v-if="book.grade">· {{ book.grade }}</span> <span class="meta-text">ID: {{ book.id }}</span>
<span v-if="book.version">· {{ book.version }}</span> <span v-if="book.subject" class="meta-text">{{ book.subject }}</span>
</a-space> <span v-if="book.grade" class="meta-text">{{ book.grade }}</span>
</template> <span v-if="book.version" class="meta-text">{{ book.version }}</span>
<template #extra> </div>
</div>
<a-space> <a-space>
<a-button <a-button
v-if="book.failed_count > 0" v-if="book.failed_count > 0"
@@ -156,162 +142,106 @@ onMounted(fetchBook);
> >
<icon-refresh /> 重试失败 <icon-refresh /> 重试失败
</a-button> </a-button>
<a-popconfirm <a-popconfirm content="确定软删该教辅?" @ok="onDelete">
content="确定软删该教辅?"
@ok="onDelete"
>
<a-button size="small" status="danger"> <a-button size="small" status="danger">
<icon-delete /> 删除 <icon-delete /> 删除
</a-button> </a-button>
</a-popconfirm> </a-popconfirm>
</a-space> </a-space>
</template> </div>
</a-page-header> </a-card>
<!-- 元信息 + 进度 --> <!-- 4 张统计卡 -->
<a-row :gutter="16" class="meta-row"> <a-row :gutter="16" class="stat-row">
<a-col :span="6"> <a-col :span="6">
<a-card class="stat-card" :bordered="false"> <a-card :bordered="false" class="stat-card">
<a-statistic <div class="stat-label">文档总数</div>
title="文档总数" <div class="stat-value">{{ book.total_count }}</div>
:value="book.total_count"
/>
</a-card> </a-card>
</a-col> </a-col>
<a-col :span="6"> <a-col :span="6">
<a-card class="stat-card stat-success" :bordered="false"> <a-card :bordered="false" class="stat-card stat-ok">
<a-statistic title="成功" :value="book.success_count"> <div class="stat-label">成功</div>
<template #suffix> <div class="stat-value">{{ book.success_count }}</div>
<icon-check-circle />
</template>
</a-statistic>
</a-card> </a-card>
</a-col> </a-col>
<a-col :span="6"> <a-col :span="6">
<a-card class="stat-card stat-fail" :bordered="false"> <a-card :bordered="false" class="stat-card stat-fail">
<a-statistic title="失败" :value="book.failed_count"> <div class="stat-label">失败</div>
<template #suffix> <div class="stat-value">{{ book.failed_count }}</div>
<icon-close-circle v-if="book.failed_count" />
</template>
</a-statistic>
</a-card> </a-card>
</a-col> </a-col>
<a-col :span="6"> <a-col :span="6">
<a-card class="stat-card" :bordered="false"> <a-card :bordered="false" class="stat-card">
<a-progress
:percent="completionRate"
:stroke-width="10"
:color="completionRate === 100 ? '#00b42a' : '#165dff'"
/>
<div class="stat-label">完成率</div> <div class="stat-label">完成率</div>
<div class="stat-value">{{ completionRate }}%</div>
<div class="progress-bar">
<div
class="progress-fill"
:style="{
width: completionRate + '%',
background: completionRate === 100 ? '#00b42a' : '#165dff',
}"
/>
</div>
</a-card> </a-card>
</a-col> </a-col>
</a-row> </a-row>
<a-descriptions
:column="4"
class="meta-desc"
size="small"
:bordered="false"
>
<a-descriptions-item label="zip sha256">
<a-typography-text code :ellipsis="{ showTooltip: true }">
{{ book.zip_sha256 }}
</a-typography-text>
</a-descriptions-item>
<a-descriptions-item label="zip 大小">
{{ formatBytes(book.zip_size) }}
</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>
<!-- 左侧目录 + 右侧 Tabs --> <!-- 左侧目录 + 右侧 Tabs -->
<div class="detail-body"> <div class="detail-body">
<a-card <a-card
class="doc-list-card"
title="文档目录"
:bordered="false" :bordered="false"
class="doc-list-card"
> >
<template #title> <template #title>
<a-space> <div class="card-title">
<icon-list /> <icon-list />
<span>文档目录 ({{ book.documents.length }})</span> <span>文档目录({{ book.documents.length }})</span>
</a-space> </div>
</template> </template>
<a-scrollbar style="height: 600px; overflow: auto;"> <a-scrollbar style="height: 640px">
<a-empty <a-empty
v-if="!book.documents.length" v-if="!book.documents.length"
description="暂无文档" description="暂无文档"
/> />
<a-list v-else size="small" :bordered="false"> <ul v-else class="doc-list">
<a-list-item <li
v-for="(doc, idx) in book.documents" v-for="(doc, idx) in book.documents"
:key="doc.doc_id" :key="doc.doc_id"
:class="['doc-list-item', { active: selectedDocId === doc.doc_id }]" :class="['doc-item', { active: selectedDocId === doc.doc_id }]"
@click="selectedDocId = doc.doc_id" @click="selectedDocId = doc.doc_id"
> >
<template #actions> <div class="doc-item-main">
<a-tag <div class="doc-item-title">
size="small" <span class="doc-num">{{ idx + 1 }}.</span>
:color="statusColor[doc.status]" <span class="doc-name">{{ doc.original_filename.replace('.docx', '') }}</span>
> </div>
{{ statusText[doc.status] || doc.status }} <div class="doc-item-meta">
</a-tag> <span class="doc-id">{{ truncate(doc.doc_id, 10) }}</span>
</template> </div>
<a-list-item-meta> </div>
<template #avatar> <a-tag
<a-avatar size="small"
shape="square" :color="statusColor[doc.status] || 'gray'"
:size="32" >
:style="{ {{ statusText[doc.status] || doc.status }}
backgroundColor: 'var(--color-fill-light-1)', </a-tag>
color: statusColor[doc.status] === 'red' </li>
? '#f53f3f' </ul>
: '#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-scrollbar>
</a-card> </a-card>
<a-card class="preview-card" :bordered="false"> <a-card :bordered="false" class="preview-card">
<template #title> <template #title>
<a-space> <div class="card-title" v-if="selectedDoc">
<icon-eye /> <icon-eye />
<span class="preview-title"> <span>{{ selectedDoc.original_filename }}</span>
{{ selectedDoc?.original_filename || '请选择文档' }} </div>
</span> <div class="card-title" v-else>
</a-space> <icon-eye />
<span>请选择文档</span>
</div>
</template> </template>
<template v-if="selectedDoc"> <template v-if="selectedDoc">
<a-tabs v-model:active-key="activeTab" type="rounded-card"> <a-tabs v-model:active-key="activeTab" type="rounded-card">
@@ -320,25 +250,30 @@ onMounted(fetchBook);
<icon-file /> Word 源文件 <icon-file /> Word 源文件
</template> </template>
<div class="preview-pane"> <div class="preview-pane">
<a-button <a-empty
v-if="fileUrl" v-if="!fileUrl"
type="primary" description="文件未上传"
@click="openInNewTab(fileUrl)" />
> <div v-else class="file-info">
<icon-download /> 下载 .docx <icon-file :size="48" style="color: #165dff" />
</a-button> <div class="file-text">
<div v-if="fileUrl" class="preview-info"> <div class="file-title">Word 源文件</div>
<a-typography-paragraph> <div class="file-hint">docx 格式不能直接在浏览器中预览</div>
Word 源文件不能直接预览,请下载后用 Word/WPS 打开。 </div>
</a-typography-paragraph> <a-button
type="primary"
size="large"
@click="openInNewTab(fileUrl)"
>
<icon-download /> 下载 .docx
</a-button>
</div> </div>
<a-empty v-else description="文件未上传" />
</div> </div>
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="html"> <a-tab-pane key="html">
<template #title> <template #title>
<icon-file /> HTML 预览 <icon-code /> HTML 预览
</template> </template>
<div class="preview-pane"> <div class="preview-pane">
<a-spin v-if="fileLoading" tip="加载中..." /> <a-spin v-if="fileLoading" tip="加载中..." />
@@ -357,7 +292,7 @@ onMounted(fetchBook);
<a-tab-pane key="json"> <a-tab-pane key="json">
<template #title> <template #title>
<icon-code /> JSON 结构 <icon-storage /> JSON 结构
</template> </template>
<div class="preview-pane"> <div class="preview-pane">
<a-spin v-if="fileLoading" tip="加载中..." /> <a-spin v-if="fileLoading" tip="加载中..." />
@@ -365,13 +300,20 @@ onMounted(fetchBook);
v-else-if="!fileUrl" v-else-if="!fileUrl"
description="JSON 未生成" description="JSON 未生成"
/> />
<a-button <div v-else class="file-info">
v-else <icon-code :size="48" style="color: #165dff" />
type="primary" <div class="file-text">
@click="openInNewTab(fileUrl)" <div class="file-title">JSON 结构数据</div>
> <div class="file-hint">点击下载查看完整的题目结构</div>
<icon-download /> 下载 .json </div>
</a-button> <a-button
type="primary"
size="large"
@click="openInNewTab(fileUrl)"
>
<icon-download /> 下载 .json
</a-button>
</div>
</div> </div>
</a-tab-pane> </a-tab-pane>
</a-tabs> </a-tabs>
@@ -380,6 +322,7 @@ onMounted(fetchBook);
</a-card> </a-card>
</div> </div>
</template> </template>
<a-empty v-else description="教辅不存在或已删除" /> <a-empty v-else description="教辅不存在或已删除" />
</div> </div>
</template> </template>
@@ -387,45 +330,86 @@ onMounted(fetchBook);
<style scoped lang="less"> <style scoped lang="less">
.book-detail { .book-detail {
padding: 16px; padding: 16px;
background: var(--color-fill-1);
min-height: 100%;
.subtitle-id { .header-card {
color: var(--color-text-3); margin-bottom: 16px;
font-family: var(--font-family-mono);
} }
.meta-row { .header-row {
margin-bottom: 12px; display: flex;
justify-content: space-between;
align-items: center;
}
.header-info {
flex: 1;
min-width: 0;
.book-name {
font-size: 20px;
font-weight: 600;
color: var(--color-text-1);
margin-bottom: 8px;
word-break: break-all;
}
.book-meta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.meta-text {
color: var(--color-text-3);
font-size: 13px;
}
}
.stat-row {
margin-bottom: 16px;
} }
.stat-card { .stat-card {
:deep(.arco-card-body) { :deep(.arco-card-body) {
padding: 16px; padding: 16px 20px;
} }
.stat-label { .stat-label {
margin-top: 8px;
color: var(--color-text-3);
font-size: 13px; font-size: 13px;
color: var(--color-text-3);
margin-bottom: 4px;
} }
&.stat-success { .stat-value {
:deep(.arco-statistic-value) { font-size: 28px;
color: #00b42a; font-weight: 600;
} color: var(--color-text-1);
line-height: 1.2;
} }
&.stat-fail { .progress-bar {
:deep(.arco-statistic-value) { margin-top: 8px;
color: #f53f3f; height: 6px;
} background: var(--color-fill-3);
border-radius: 3px;
overflow: hidden;
} }
}
.meta-desc { .progress-fill {
margin-bottom: 16px; height: 100%;
padding: 12px 16px; transition: width 0.3s;
background: var(--color-fill-1); }
border-radius: 4px;
&.stat-ok .stat-value {
color: #00b42a;
}
&.stat-fail .stat-value {
color: #f53f3f;
}
} }
.detail-body { .detail-body {
@@ -434,56 +418,123 @@ onMounted(fetchBook);
gap: 16px; gap: 16px;
} }
.doc-list-card { .card-title {
height: fit-content; display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 500;
}
.doc-list-item { .doc-list-card {
cursor: pointer; align-self: start;
padding: 8px 12px;
.doc-list {
list-style: none;
margin: 0;
padding: 0;
}
.doc-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
margin-bottom: 4px;
border-radius: 4px; border-radius: 4px;
transition: background 0.15s; cursor: pointer;
transition: all 0.15s;
border: 1px solid transparent;
&:hover { &:hover {
background: var(--color-fill-2); background: var(--color-fill-2);
} }
&.active { &.active {
background: var(--color-primary-light-1); background: rgb(var(--primary-1));
border-color: rgb(var(--primary-3));
:deep(.arco-list-item-meta-title) { .doc-name {
color: rgb(var(--primary-6)); color: rgb(var(--primary-6));
font-weight: 500;
} }
} }
} }
.doc-item-main {
flex: 1;
min-width: 0;
margin-right: 8px;
}
.doc-item-title {
display: flex;
align-items: baseline;
gap: 4px;
}
.doc-num { .doc-num {
color: var(--color-text-3); color: var(--color-text-3);
margin-right: 6px;
font-family: var(--font-family-mono); font-family: var(--font-family-mono);
font-size: 12px;
flex-shrink: 0;
}
.doc-name {
font-size: 13px;
color: var(--color-text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-item-meta {
margin-top: 2px;
font-size: 11px;
color: var(--color-text-4);
} }
.doc-id { .doc-id {
color: var(--color-text-4);
font-family: var(--font-family-mono); font-family: var(--font-family-mono);
font-size: 11px;
} }
} }
.preview-card { .preview-card {
min-height: 700px; min-height: 700px;
.preview-title {
font-weight: 500;
}
.preview-pane { .preview-pane {
min-height: 600px; min-height: 600px;
padding: 12px; padding: 16px;
display: flex;
align-items: center;
justify-content: center;
} }
.preview-info { .file-info {
margin-top: 16px; display: flex;
color: var(--color-text-3); align-items: center;
gap: 24px;
padding: 32px;
background: var(--color-fill-1);
border-radius: 8px;
width: 100%;
max-width: 600px;
margin: 0 auto;
}
.file-text {
flex: 1;
.file-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 4px;
}
.file-hint {
font-size: 13px;
color: var(--color-text-3);
}
} }
.preview-iframe { .preview-iframe {