@@ -1,8 +1,8 @@
< 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 { get DocumentUrl } from '@/api/document ' ;
import { Message } from '@arco-design/web-vue' ;
const route = useRoute ( ) ;
@@ -11,16 +11,57 @@ 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' ) ;
type FileKind = 'docx' | 'html' | 'json' | 'error_log' ;
const fileTypeMap : Record < typeof activeTab.value , FileKind > = {
word : 'docx' ,
html : 'html' ,
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 fileLoading = ref ( false ) ;
const fetchFileUrl = async ( ) => {
if ( ! selectedDoc . value ) {
fileUrl . value = '' ;
return ;
}
fileLoading . value = true ;
try {
const r = await getDocumentUrl (
selectedDoc . value . doc _id ,
fileTypeMap [ 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 , { immediate : false } ) ;
const onRetry = async ( ) => {
const r = await retryFailed ( bookId ) ;
if ( r . ok ) {
@@ -39,129 +80,476 @@ const onDelete = async () => {
}
} ;
const formatBytes = ( n : number ) : string => {
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' ,
done : 'green' ,
failed : 'red' ,
partial : 'orange' ,
const openInNewTab = ( url : string ) => {
window . open ( url , '_blank' ) ;
} ;
const statusText : Record < string , string > = {
pending : '待处理' ,
processing : '处理中' ,
html _ready : 'HTML 已就绪' ,
json _ready : 'JSON 已就绪' ,
done : '完成' ,
failed : '失败' ,
partial : '部分失败' ,
} ;
const statusColor : Record < string , string > = {
pending : 'gray' ,
processing : 'arcoblue' ,
html _ready : 'green' ,
json _ready : 'green' ,
failed : 'red' ,
partial : 'orange' ,
done : 'green' ,
} ;
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 truncate = ( s : string , len = 8 ) => ( s ? ` ${ s . slice ( 0 , len ) } … ` : '' ) ;
onMounted ( fetchBook ) ;
< / script >
< template >
< div class = "book-detail" >
< a-card :bordered = "false" >
< a-skeleton v-if = "loading" :rows="6" / >
< a-skeleton v-if = "loading" :rows="8" / >
< template v-else-if = "book" >
< a -page -header
:title = "book.name"
: subtitle = "`ID: ${book.id} | ${book.subject || ''} ${book.grade || ''} ${book.version || ''}`"
>
< template # extra >
< a-space >
< a-tag :color = "statusColor[book.status]" size = "medium " >
< ! - - 顶部 : 标题 + 操作 - - >
< a-card :bordered = "false" class = "header-card" >
< div class = "header-row" >
< div class = "header-info" >
< div class = "book-name" > { { book . name } } < / div >
< div class = "book-meta" >
< a-tag :color = "statusColor[book.status]" size = "small " >
{ { statusText [ book . status ] || book . status } }
< / a-tag >
< span class = "meta-text" > ID : { { book . id } } < / span >
< span v-if = "book.subject" class="meta-text" > {{ book.subject }} < / span >
< span v-if = "book.grade" class="meta-text" > {{ book.grade }} < / span >
< span v-if = "book.version" class="meta-text" > {{ book.version }} < / span >
< / div >
< / div >
< a -space >
< a-button
v-if = "book.failed_count > 0"
size = "small"
@click ="onRetry"
>
< icon-refresh / > 重试失败
< / a-button >
< a-popconfirm
content = "确定软删该教辅?"
@ok ="onDelete"
>
< a-button status = "danger" >
< a-popconfirm content = "确定软删该教辅?" @ok ="onDelete" >
< a -button size = "small" status = "danger" >
< icon-delete / > 删除
< / a-button >
< / a-popconfirm >
< / a-space >
< / template >
< / a-page-header >
< a-descriptions
:data = "[]"
:column = "3"
class = "meta"
size = "medium"
>
< 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 . 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"
/ >
< / a-collapse >
< a-empty v-else description = "教辅中暂无文档" / >
< / template >
< a-empty v-else description = "教辅不存在或已删除" / >
< / div >
< / a-card >
<!-- 4 张统计卡 -- >
< a-row :gutter = "16" class = "stat-row" >
< a-col :span = "6" >
< a-card :bordered = "false" class = "stat-card" >
< div class = "stat-label" > 文档总数 < / div >
< div class = "stat-value" > { { book . total _count } } < / div >
< / a-card >
< / a-col >
< a-col :span = "6" >
< a-card :bordered = "false" class = "stat-card stat-ok" >
< div class = "stat-label" > 成功 < / div >
< div class = "stat-value" > { { book . success _count } } < / div >
< / a-card >
< / a-col >
< a-col :span = "6" >
< a-card :bordered = "false" class = "stat-card stat-fail" >
< div class = "stat-label" > 失败 < / div >
< div class = "stat-value" > { { book . failed _count } } < / div >
< / a-card >
< / a-col >
< a-col :span = "6" >
< a-card :bordered = "false" class = "stat-card" >
< 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-col >
< / a-row >
<!-- 左侧目录 + 右侧 Tabs -- >
< div class = "detail-body" >
< a-card
:bordered = "false"
class = "doc-list-card"
>
< template # title >
< div class = "card-title" >
< icon-list / >
< span > 文档目录 ( { { book . documents . length } } ) < / span >
< / div >
< / template >
< a-scrollbar style = "height: 640px" >
< a-empty
v-if = "!book.documents.length"
description = "暂无文档"
/ >
< ul v-else class = "doc-list" >
< li
v-for = "(doc, idx) in book.documents"
:key = "doc.doc_id"
: class = "['doc-item', { active: selectedDocId === doc.doc_id }]"
@click ="selectedDocId = doc.doc_id"
>
< div class = "doc-item-main" >
< div class = "doc-item-title" >
< span class = "doc-num" > { { idx + 1 } } . < / span >
< span class = "doc-name" > { { doc . original _filename . replace ( '.docx' , '' ) } } < / span >
< / div >
< div class = "doc-item-meta" >
< span class = "doc-id" > { { truncate ( doc . doc _id , 10 ) } } < / span >
< / div >
< / div >
< a-tag
size = "small"
: color = "statusColor[doc.status] || 'gray'"
>
{ { statusText [ doc . status ] || doc . status } }
< / a-tag >
< / li >
< / ul >
< / a-scrollbar >
< / a-card >
< a-card :bordered = "false" class = "preview-card" >
< template # title >
< div class = "card-title" v-if = "selectedDoc" >
< icon -eye / >
< span > { { selectedDoc . original _filename } } < / span >
< / div >
< div class = "card-title" v-else >
< icon -eye / >
< span > 请选择文档 < / span >
< / div >
< / 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-empty
v-if = "!fileUrl"
description = "文件未上传"
/ >
< div v-else class = "file-info" >
< icon-file :size = "48" style = "color: #165dff" / >
< div class = "file-text" >
< div class = "file-title" > Word 源文件 < / div >
< div class = "file-hint" > docx 格式不能直接在浏览器中预览 < / div >
< / div >
< a-button
type = "primary"
size = "large"
@click ="openInNewTab(fileUrl)"
>
< icon-download / > 下载 . docx
< / a-button >
< / div >
< / div >
< / a-tab-pane >
< a-tab-pane key = "html" >
< template # title >
< icon-code / > 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-storage / > JSON 结构
< / template >
< div class = "preview-pane" >
< a-spin v-if = "fileLoading" tip="加载中..." / >
< a-empty
v-else-if = "!fileUrl"
description = "JSON 未生成"
/ >
< div v-else class = "file-info" >
< icon-code :size = "48" style = "color: #165dff" / >
< div class = "file-text" >
< div class = "file-title" > JSON 结构数据 < / div >
< div class = "file-hint" > 点击下载查看完整的题目结构 < / div >
< / div >
< a-button
type = "primary"
size = "large"
@click ="openInNewTab(fileUrl)"
>
< icon-download / > 下载 . json
< / a-button >
< / div >
< / div >
< / a-tab-pane >
< / a-tabs >
< / template >
< a-empty v-else description = "请在左侧选择一份文档" / >
< / a-card >
< / div >
< / template >
< a-empty v-else description = "教辅不存在或已删除" / >
< / div >
< / template >
< style scoped lang = "less" >
. book - detail {
padding : 16 px ;
background : var ( -- color - fill - 1 ) ;
min - height : 100 % ;
. header - card {
margin - bottom : 16 px ;
}
. meta {
margin - top : 16 px ;
. header - row {
display : flex ;
justify - content : space - between ;
align - items : center ;
}
. header - info {
flex : 1 ;
min - width : 0 ;
. book - name {
font - size : 20 px ;
font - weight : 600 ;
color : var ( -- color - text - 1 ) ;
margin - bottom : 8 px ;
word - break : break - all ;
}
. book - meta {
display : flex ;
align - items : center ;
gap : 12 px ;
flex - wrap : wrap ;
}
. meta - text {
color : var ( -- color - text - 3 ) ;
font - size : 13 px ;
}
}
. stat - row {
margin - bottom : 16 px ;
}
. stat - card {
: deep ( . arco - card - body ) {
padding : 16 px 20 px ;
}
. stat - label {
font - size : 13 px ;
color : var ( -- color - text - 3 ) ;
margin - bottom : 4 px ;
}
. stat - value {
font - size : 28 px ;
font - weight : 600 ;
color : var ( -- color - text - 1 ) ;
line - height : 1.2 ;
}
. progress - bar {
margin - top : 8 px ;
height : 6 px ;
background : var ( -- color - fill - 3 ) ;
border - radius : 3 px ;
overflow : hidden ;
}
. progress - fill {
height : 100 % ;
transition : width 0.3 s ;
}
& . stat - ok . stat - value {
color : # 00 b42a ;
}
& . stat - fail . stat - value {
color : # f53f3f ;
}
}
. detail - body {
display : grid ;
grid - template - columns : 380 px 1 fr ;
gap : 16 px ;
}
. card - title {
display : flex ;
align - items : center ;
gap : 8 px ;
font - size : 15 px ;
font - weight : 500 ;
}
. doc - list - card {
align - self : start ;
. doc - list {
list - style : none ;
margin : 0 ;
padding : 0 ;
}
. doc - item {
display : flex ;
justify - content : space - between ;
align - items : center ;
padding : 10 px 12 px ;
margin - bottom : 4 px ;
border - radius : 4 px ;
cursor : pointer ;
transition : all 0.15 s ;
border : 1 px solid transparent ;
& : hover {
background : var ( -- color - fill - 2 ) ;
}
& . active {
background : rgb ( var ( -- primary - 1 ) ) ;
border - color : rgb ( var ( -- primary - 3 ) ) ;
. doc - name {
color : rgb ( var ( -- primary - 6 ) ) ;
font - weight : 500 ;
}
}
}
. doc - item - main {
flex : 1 ;
min - width : 0 ;
margin - right : 8 px ;
}
. doc - item - title {
display : flex ;
align - items : baseline ;
gap : 4 px ;
}
. doc - num {
color : var ( -- color - text - 3 ) ;
font - family : var ( -- font - family - mono ) ;
font - size : 12 px ;
flex - shrink : 0 ;
}
. doc - name {
font - size : 13 px ;
color : var ( -- color - text - 1 ) ;
overflow : hidden ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
. doc - item - meta {
margin - top : 2 px ;
font - size : 11 px ;
color : var ( -- color - text - 4 ) ;
}
. doc - id {
font - family : var ( -- font - family - mono ) ;
}
}
. preview - card {
min - height : 700 px ;
. preview - pane {
min - height : 600 px ;
padding : 16 px ;
display : flex ;
align - items : center ;
justify - content : center ;
}
. file - info {
display : flex ;
align - items : center ;
gap : 24 px ;
padding : 32 px ;
background : var ( -- color - fill - 1 ) ;
border - radius : 8 px ;
width : 100 % ;
max - width : 600 px ;
margin : 0 auto ;
}
. file - text {
flex : 1 ;
. file - title {
font - size : 16 px ;
font - weight : 500 ;
margin - bottom : 4 px ;
}
. file - hint {
font - size : 13 px ;
color : var ( -- color - text - 3 ) ;
}
}
. preview - iframe {
width : 100 % ;
height : 600 px ;
border : 1 px solid var ( -- color - border - 2 ) ;
border - radius : 4 px ;
background : white ;
}
}
}
@ media ( max - width : 1024 px ) {
. book - detail . detail - body {
grid - template - columns : 1 fr ;
}
: deep ( . arco - descriptions - item - label ) {
width : 100 px ;
}
< / style >