refactor: restructure project to standard Go layout

- Reorganize code into cmd/bilidown and internal/ packages
- Rename client/ to web/ for frontend source
- Remove systray dependency for headless web service
- Embed static files into binary using go:embed
- Update import paths to use internal/ prefix
- Update .gitignore with common patterns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 17:21:49 +08:00
parent 4facf82956
commit dd8eaef97d
61 changed files with 244 additions and 869 deletions
+85
View File
@@ -0,0 +1,85 @@
import van from 'vanjs-core'
import { goto } from 'vanjs-router'
import { v4 } from 'uuid'
import { checkURL, handleB23, handleSeasonsArchivesList, start } from '../mixin'
import { WorkRoute } from '..'
import { VanComponent } from '../../mixin'
const { button, div, input, label, span } = van.tags
class InputBoxComp implements VanComponent {
element: HTMLElement
btnID = v4()
constructor(public workRoute: WorkRoute) {
this.element = div(
div({ class: () => `hstack gap-3 align-items-stretch ${workRoute.urlInvalidClass.val}` },
div({ class: () => `form-floating flex-fill` },
input({
class: () => `form-control border-3 ${workRoute.urlInvalidClass.val}`,
placeholder: '请输入待解析的视频链接',
value: workRoute.urlValue,
oninput: event => workRoute.urlValue.val = event.target.value,
onkeyup: event => {
if (event.key === 'Enter') document.getElementById(this.btnID)?.click()
}
}),
label({ class: 'w-100' }, '请输入视频链接或 BV/EP/SS 号')
),
ParseButton(this, false, this.btnID),
ParseButton(this, true)
),
div({ class: 'invalid-feedback' }, () => workRoute.urlInvalid.val ? '您输入的视频链接格式错误' : ''),
)
}
}
const ParseButton = (parent: InputBoxComp, large: boolean, id: string = '') => {
const { workRoute } = parent
return button({
class: `btn btn-success text-nowrap ${large ? `btn-lg d-none d-md-block` : 'd-md-none'}`,
async onclick() {
try {
workRoute.btnLoading.val = true
workRoute.urlValue.val = workRoute.urlValue.val.trim()
try {
const handleB23Result = await handleB23(workRoute.urlValue.val)
if (handleB23Result) workRoute.urlValue.val = handleB23Result
} catch (error) {
if (error instanceof Error) return alert(error.message)
}
try {
const handleSeasonsArchivesListResult = await handleSeasonsArchivesList(workRoute.urlValue.val)
if (handleSeasonsArchivesListResult) workRoute.urlValue.val = handleSeasonsArchivesListResult
} catch (error) {
if (error instanceof Error) return alert(error.message)
}
const { type, value } = checkURL(workRoute.urlValue.val)
workRoute.urlInvalid.val = false
await start(workRoute, {
idType: type,
value,
from: 'click'
}).catch(error => {
const errorMessage = `获取视频信息失败:${error.message}`
alert(errorMessage)
goto('work')
workRoute.videoInfoCardMode.val = 'hide'
})
} catch (error) {
workRoute.urlInvalid.val = true
} finally {
setTimeout(() => {
workRoute.btnLoading.val = false
}, 200)
}
},
id,
disabled: workRoute.btnLoading
}, span({ class: 'spinner-border spinner-border-sm me-2', hidden: () => !workRoute.btnLoading.val }),
() => workRoute.btnLoading.val ? '解析中' : '解析视频'
)
}
export default (workRoute: WorkRoute) => new InputBoxComp(workRoute).element
+337
View File
@@ -0,0 +1,337 @@
import van, { State } from 'vanjs-core'
import { VanComponent, formatSeconds } from '../../mixin'
import { PageInParseResult, PlayInfo, VideoFormat } from '../type'
import { WorkRoute } from '..'
import { createTask, getPlayInfo } from '../data'
import PQueue from 'p-queue'
const { a, button, div, input, select, option, label } = van.tags
type Option = {
workRoute: WorkRoute
}
const videoFormatMap: Record<VideoFormat, string> = {
127: "超高清 8K",
126: "杜比视界",
125: "真彩 HDR",
120: "超清 4K",
116: "高清 1080P60",
112: "高清 1080P+",
80: "高清 1080P",
74: "高清 720P60",
64: "高清 720P",
32: "清晰 480P",
16: "流畅 360P",
6: "极速 240P",
}
const codecMap: Record<12 | 7 | 13, string> = {
12: "HEVC (hev1)",
7: "AVC (avc1)",
13: "AV1 (av01)",
}
export class ParseModalComp implements VanComponent {
element: HTMLElement
totalCount: State<number>
finishCount = van.state(0)
abortControllers: AbortController[] = []
currentController?: AbortController
allPlayInfo: State<{
page: PageInParseResult
info: PlayInfo | null
selected: State<boolean>
formatIndex: State<number>
}[]> = van.state([])
/** 该属性用于在点击“开始下载”按钮后使按钮变为禁用状态,防止多次点击 */
downloadBtnDisabled = van.state(false)
/** 下载类型:audio 仅音频,video 仅视频,merge 音视频合并 */
downloadType = van.state<'audio' | 'video' | 'merge'>('merge')
/** 优先视频编码格式:12 hev1, 7 avc1, 13 av01 */
preferredCodec = van.state<12 | 7 | 13>(12)
/** 是否优先使用HiResflac)音频 */
preferHiResAudio = van.state(true)
errorList: State<string[]> = van.state([])
constructor(public option: Option) {
this.totalCount = van.derive(() => option.workRoute.selectedPages.val.length)
const allFinish = van.derive(() => this.totalCount.val == this.finishCount.val)
this.element = div({ class: `modal fade`, tabIndex: -1 },
div({ class: () => `modal-dialog modal-xl modal-fullscreen-xl-down ${(this.totalCount.val + this.errorList.val.length) < 10 ? '' : 'modal-dialog-scrollable'}` },
div({ class: `modal-content` },
div({ class: `modal-header` },
div({ class: `h5 modal-title` }, () => allFinish.val ? '批量下载' : '批量解析'),
button({ class: `btn-close`, 'data-bs-dismiss': `modal` })
),
div({ class: `modal-body vstack gap-3`, tabIndex: -1, style: 'outline: none;' },
this.ParseProgress(),
div({ class: 'vstack gap-2', hidden: () => this.errorList.val.length == 0 || !allFinish.val },
div({ class: 'text-danger' }, () => `以下 ${this.errorList.val.length} 个视频解析失败`),
() => div({ class: 'list-group' },
this.errorList.val.map(error => div({ class: 'list-group-item disabled' }, error))
)
),
this.ListGroup()
),
this.ModalFooter()
)
)
)
this.element.addEventListener('hidden.bs.modal', () => {
this.allPlayInfo.val = []
this.finishCount.val = this.totalCount.val
for (const controller of this.abortControllers) {
controller.abort()
}
this.abortControllers = []
})
this.element.addEventListener('show.bs.modal', () => {
this.start()
})
}
/** 开始解析 */
async start() {
this.finishCount.val = 0
this.errorList.val = []
const queue = new PQueue({ concurrency: 10 })
for (const page of this.option.workRoute.selectedPages.val) {
queue.add(async () => {
if (this.totalCount.val == this.finishCount.val) return
const controller = new AbortController()
this.abortControllers.push(controller)
const playInfo = await getPlayInfo(page.bvid, page.cid, controller)
playInfo.accept_quality = [...new Set(playInfo.dash.video.map(video => video.id))].sort((a, b) => b - a)
this.allPlayInfo.val = this.allPlayInfo.val.concat({
page,
info: playInfo,
selected: van.state(true),
formatIndex: van.state(0),
})
this.finishCount.val++
}).catch(() => {
this.finishCount.val++
const badgeNotNum = !page.badge.match(/^\d+$/)
this.errorList.val = this.errorList.val.concat(`${page.part}${badgeNotNum ? ` - ${page.badge}` : ''}`)
})
}
await queue.onIdle()
}
download() {
const selectedPlayInfos = this.allPlayInfo.val.filter(info => info.selected.val)
const workRoute = this.option.workRoute
this.downloadBtnDisabled.val = true
// 需要传递给服务器,需要创建下载任务的数据列表
createTask(selectedPlayInfos.map(info => {
const badgeNotNum = !info.page.badge.match(/^\d+$/)
const isVideoMode = workRoute.videoInfoCardMode.val == 'video'
const cardTitle = workRoute.videoInfoCardData.val.title
const owner = workRoute.videoInfoCardData.val.staff.length > 0
? workRoute.videoInfoCardData.val.staff[0].split("[")[0].trim()
: workRoute.videoInfoCardData.val.owner.name.trim()
const activeVideoInfo = getActiveFormatVideo(info.info!, info.info!.accept_quality[info.formatIndex.val], this.preferredCodec.val)
const pagesLength = workRoute.videoInfoCardData.val.pages.length
return ({
bvid: info.page.bvid,
cid: info.page.cid,
cover: workRoute.videoInfoCardData.val.cover,
title: (badgeNotNum
? [
info.page.part.trim(),
`[${info.page.badge.trim()}]`,
`[${cardTitle.trim()}]`,
`[${videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]}]`,
`[${formatSeconds(info.info!.dash.duration)}]`
]
: [
pagesLength == 1 ? workRoute.allSection.val[workRoute.sectionTabsActiveIndex.val].title : `[${cardTitle.trim()}]`,
workRoute.sectionPages.val.length == 1 ? '' : `[${info.page.badge.trim()}]`,
info.page.part.trim(),
isVideoMode ? `[${owner}]` : '',
`[${videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]}]`,
`[${formatSeconds(info.info!.dash.duration)}]`
]).filter(p => p).join(' '),
format: info.info!.accept_quality[info.formatIndex.val],
owner,
audio: getAudioURL(info.info!, this.preferHiResAudio.val),
duration: info.info!.dash.duration,
downloadType: this.downloadType.val,
...activeVideoInfo
})
})).then(() => {
workRoute.parseModal.hide()
}).catch(error => {
alert(error.message)
}).finally(() => {
this.downloadBtnDisabled.val = false
})
}
ParseProgress() {
return div({ class: 'vstack gap-3', hidden: () => this.totalCount.val == this.finishCount.val },
div({ class: 'text-center fs-5' }, () => `正在解析,剩余 ${this.totalCount.val - this.finishCount.val}`),
div({ class: 'progress' }, div({
class: 'progress-bar progress-bar-striped progress-bar-animated',
style: () => `width: ${this.finishCount.val / this.totalCount.val * 100}%`
},)),
)
}
ListGroup() {
return () => div({ class: 'list-group', hidden: () => this.totalCount.val != this.finishCount.val },
this.allPlayInfo.val.filter(info => info.info)
.sort((a, b) => a.page.page - b.page.page)
.map(info => {
const badgeNotNum = !info.page.badge.match(/^\d+$/)
return div({
class: () => `list-group-item user-select-none py-0 ${info.info ? '' : 'disabled'}`,
role: 'button',
onclick(event) {
if ((event.target as HTMLElement).getAttribute('class')?.match(/dropdown-?/)) return
info.selected.val = !info.selected.val
}
},
div({ class: 'hstack gap-2' },
div({ class: 'hstack gap-3 flex-fill py-1' },
input({
class: 'form-check-input', type: 'checkbox', checked: info.selected,
}),
div({},
div((badgeNotNum ? '' : `${info.page.badge}. `) + info.page.part),
badgeNotNum ? div({ class: info.page.part ? 'small text-secondary' : '' },
info.page.badge) : ''
),
),
div({ class: 'dropdown' },
div({ class: 'dropdown-toggle py-2 text-primary', 'data-bs-toggle': 'dropdown' },
() => videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]
),
() => {
return div({ class: 'dropdown-menu shadow' },
info.info!.accept_quality.map((formatID, index) => {
return div({
class: () => `dropdown-item ${info.formatIndex.val == index ? 'active' : ''}`,
onclick() {
info.formatIndex.val = index
}
}, videoFormatMap[formatID as VideoFormat])
})
)
}
)
)
)
})
)
}
ModalFooter() {
const _that = this
const selectedCount = van.derive(() => this.allPlayInfo.val.filter(info => info.selected.val).length)
const totalCount = van.derive(() => this.allPlayInfo.val.length)
/** 解析完成列表全部选中 */
const allSelected = van.derive(() => selectedCount.val == totalCount.val)
/** 是否全部解析完成 */
const allFinish = van.derive(() => this.totalCount.val == this.finishCount.val)
return div({ class: `modal-footer` },
div({ class: 'me-auto', hidden: () => !allFinish.val || totalCount.val == 0 },
div({ class: 'hstack gap-3 text-nowrap' },
() => `已选择 (${selectedCount.val}/${totalCount.val}) 项`,
select({
class: 'form-select form-select-sm',
value: _that.downloadType,
oninput: (e) => _that.downloadType.val = (e.target as HTMLSelectElement).value as 'audio' | 'video' | 'merge'
},
option({ value: 'merge' }, '音视频合并'),
option({ value: 'audio' }, '仅音频'),
option({ value: 'video' }, '仅视频')
),
select({
class: 'form-select form-select-sm',
value: _that.preferredCodec,
oninput: (e) => _that.preferredCodec.val = Number((e.target as HTMLSelectElement).value) as 12 | 7 | 13
},
option({ value: '12' }, 'HEVC (hev1)'),
option({ value: '7' }, 'AVC (avc1)'),
option({ value: '13' }, 'AV1 (av01)')
),
div({ class: 'form-check form-check-inline' },
input({
type: 'checkbox',
class: 'form-check-input',
id: 'preferHiResAudio',
checked: _that.preferHiResAudio,
oninput: (e) => _that.preferHiResAudio.val = (e.target as HTMLInputElement).checked
}),
label({ class: 'form-check-label', for: 'preferHiResAudio' }, 'Hi-Res')
)
)
),
button({
class: `btn btn-secondary`,
'data-bs-dismiss': `modal`,
hidden: allFinish
}, '取消解析'),
button({
class: 'btn btn-secondary', hidden: () => !allFinish.val || allSelected.val || totalCount.val == 0,
onclick() {
_that.allPlayInfo.val.forEach(info => info.selected.val = true)
}
}, '全选'),
button({
class: 'btn btn-warning', hidden: () => !allFinish.val || !allSelected.val || totalCount.val == 0,
onclick() {
_that.allPlayInfo.val.forEach(info => info.selected.val = false)
}
}, '全不选'),
button({
class: `btn btn-primary`, onclick() {
_that.download()
},
hidden: () => !allFinish.val,
disabled: () => selectedCount.val <= 0 || _that.downloadBtnDisabled.val
}, '开始下载'),
)
}
}
const getAudioURL = (playInfo: PlayInfo, preferHiRes: boolean = true): string => {
if (preferHiRes && playInfo.dash.flac) {
return playInfo.dash.flac.audio.baseUrl
} else {
return playInfo.dash.audio.sort((a, b) => b.id - a.id)[0].baseUrl
}
}
const getActiveFormatVideo = (playInfo: PlayInfo, format: VideoFormat, preferredCodec: 12 | 7 | 13 = 12): { video: string, width: number, height: number } => {
// 优先级顺序:用户首选编码格式,然后按默认优先级 12 > 7 > 13
const codecOrder = [preferredCodec, 12, 7, 13].filter((value, index, self) => self.indexOf(value) === index)
for (const code of codecOrder) {
for (const item of playInfo.dash.video) {
if (item.id == format && item.codecid == code) {
return {
video: item.baseUrl,
width: item.width,
height: item.height
}
}
}
}
throw new Error('未找到对应视频分辨率格式')
}
+198
View File
@@ -0,0 +1,198 @@
import van, { State, Val } from 'vanjs-core'
import { VideoParseResult, VideoInfoCardMode } from '../type'
import { secondToTime } from '../mixin'
import { VanComponent } from '../../mixin'
import VideoItemList from './videoItemList'
import { WorkRoute } from '..'
const { a, div, img } = van.tags
class VideoInfoCardComp implements VanComponent {
element: HTMLElement
constructor(
public workRoute: WorkRoute,
) {
const {
videoInfoCardData: data,
videoInfoCardMode: mode,
ownerFaceHide
} = workRoute
this.element = div({ class: 'card border-2 shadow-sm' },
div({ class: 'card-header' },
a({
class: 'link-dark text-decoration-none fw-bold focus-ring', href: () => data.val.targetURL,
target: '_blank',
},
() => data.val.title,
)
),
div({ class: 'card-body vstack gap-4' },
div({ class: 'vstack gap-3' },
div({ class: 'row gx-3 gy-3' },
// 封面
div({
class: () => mode.val == 'video'
? 'col-md-5 col-xl-4'
: 'col-8 col-sm-6 mx-auto col-md-5 col-lg-3 col-xl-2'
},
div({ class: 'position-relative shadow-sm rounded overflow-hidden' },
a({
href: () => data.val.targetURL,
title: () => `打开视频播放页面`,
target: '_blank',
},
img({
src: () => data.val.cover,
class: 'w-100',
ondragstart: event => event.preventDefault(),
referrerPolicy: 'no-referrer',
})
),
a({
href: () => `https://space.bilibili.com/${data.val.owner.mid}`,
title: () => `查看用户主页:${data.val.owner.name}`,
target: '_blank',
tabIndex: () => mode.val == 'video' ? 0 : -1,
},
img({
src: () => data.val.owner.face,
hidden: ownerFaceHide,
referrerPolicy: 'no-referrer',
ondragstart: event => event.preventDefault(),
style: `right: 1rem; bottom: 1rem;`,
class: 'rounded-3 border shadow position-absolute w-25'
})
),
),
),
// 字段信息
div({
class: () => mode.val == 'video'
? 'col-md-7 col-xl-8 vstack gap-2'
: 'col-md-7 col-lg-9 col-xl-10 vstack gap-2'
},
div({ class: 'position-relative h-100' },
div({ class: 'position-absolute top-0 bottom-0 position-relative-sm-down' },
Right(this)
)
),
),
),
DescriptionGroup(this, true),
),
VideoItemList(workRoute)
)
)
}
}
const Right = (parent: VideoInfoCardComp) => {
const mode = parent.workRoute.videoInfoCardMode
const data = parent.workRoute.videoInfoCardData
return div({ class: 'vstack gap-2 h-100' },
div({ class: 'row gx-2 gy-2' },
div({ class: 'col-xl-7 col-xxl-8' },
InputGroup(
van.derive(() => mode.val == 'video'
? (data.val.staff.length > 0 ? '制作信息' : '发布者')
: '参演人员'),
van.derive(() => {
if (data.val.staff.length > 0)
return data.val.staff.map(i => i.trim()).join(', ')
return data.val.owner.name
}), { disabled: true }
),
),
div({ class: 'col-xl-5 col-xxl-4' },
InputGroup('发布时间',
van.derive(() => data.val.publishData), { disabled: true }
)
),
div({ class: 'col-sm col-md-12 col-lg-4', hidden: () => mode.val != 'video' },
InputGroup('分辨率',
van.derive(() => `${data.val.dimension.width}x${data.val.dimension.height}`),
{ disabled: true }
)
),
div({ class: 'col col-lg-4', hidden: () => mode.val != 'video' },
InputGroup('时长',
van.derive(() => `${secondToTime(data.val.duration)}`),
{ disabled: true }
)
),
div({ class: 'col col-lg-4', hidden: () => mode.val != 'video' },
InputGroup('集数',
van.derive(() => data.val.pages.length.toString()),
{ disabled: true }
)
),
div({ class: 'col-md-12 col-lg-4', hidden: () => mode.val != 'season' },
InputGroup('状态',
van.derive(() => data.val.status),
{ disabled: true }
)
),
div({ class: 'col-sm col-lg-4', hidden: () => mode.val != 'season' },
InputGroup('地区',
van.derive(() => data.val.areas.map(i => i.trim()).join(', ')),
{ disabled: true }
)
),
div({ class: 'col-sm col-lg-4', hidden: () => mode.val != 'season' },
InputGroup('标签',
van.derive(() => data.val.styles.join(', ')),
{ disabled: true }
)
),
),
DescriptionGroup(parent),
)
}
/** 用于显示 `description` 字段的 `.input-group`
*
* @param parent 父组件
* @param bottom 是否在底部
*/
const DescriptionGroup = (parent: VideoInfoCardComp, bottom = false) => {
const mode = parent.workRoute.videoInfoCardMode
const data = parent.workRoute.videoInfoCardData
const _class = van.derive(() => mode.val == 'video' ? 'd-md-flex' : '')
const size = van.derive(() => mode.val == 'video' ? 'lg' : 'lg')
return div({
class: () => `shadow-sm input-group input-group-sm ${bottom
? `d-none d-lg-none ${_class.val}`
: `overflow-hidden flex-fill ${mode.val == 'video' ? 'd-md-none d-lg-flex ' : ''}`
}`,
},
div({ class: 'input-group-text align-items-start' }, '描述'),
() => {
const lines = (data.val.description.match(/^(\s*|.)$/) ? '暂无描述' : data.val.description).split('\n')
return div({ class: `form-control overflow-auto ${bottom ? `max-height-description` : `h-100`}` },
lines.map(line => div(line))
)
}
)
}
const InputGroup = (title: Val<string>, value: State<string>, option?: {
disabled?: Val<boolean>
elementType?: 'input' | 'textarea'
}) => {
return div({ class: 'input-group input-group-sm shadow-sm rounded' },
div({ class: 'input-group-text' }, title),
van.tags[option?.elementType || 'input']({
class: 'form-control bg-white',
disabled: option?.disabled || false,
style: 'cursor: text;',
value
})
)
}
export default (
workRoute: WorkRoute
) => new VideoInfoCardComp(workRoute).element
+102
View File
@@ -0,0 +1,102 @@
import van, { State } from 'vanjs-core'
import { VideoParseResult, VideoInfoCardMode, PageInParseResult, SectionItem } from '../type'
import { VanComponent } from '../../mixin'
import { WorkRoute } from '..'
const { button, div, span } = van.tags
class VideoItemListComp implements VanComponent {
element: HTMLElement
constructor(
public workRoute: WorkRoute
) {
const { videoInfoCardData: data } = workRoute
this.element = div({
hidden: () => false && data.val.pages.length <= 1,
class: 'vstack gap-4'
},
div({ class: 'vstack gap-4' },
div({ hidden: () => workRoute.allSection.val.length == 1 && workRoute.allSection.val[0].title == '正片' }, SectionTabs(this, workRoute.allSection)),
ButtonGroup(workRoute),
ListBox(workRoute.sectionPages),
)
)
}
}
const SectionTabs = (parent: VideoItemListComp, allSection: State<SectionItem[]>) => {
return () => div({ class: 'nav nav-underline' },
allSection.val.map((item, index) => div({ class: 'nav-item', role: 'button' },
div({
tabIndex: 0,
class: `nav-link ${parent.workRoute.sectionTabsActiveIndex.val == index ? 'active' : ''}`,
onclick() {
parent.workRoute.sectionTabsActiveIndex.val = index
},
onkeyup(e) {
if (e.key == 'Enter') {
e.target.click()
}
}
}, () => item.title)
))
)
}
const ButtonGroup = (workRoute: WorkRoute) => {
const pages = workRoute.sectionPages
const selectedCount = van.derive(() => pages.val.filter(page => page.selected.val).length)
const totalCount = van.derive(() => pages.val.length)
return div({ class: 'hstack gap-3' },
button({
class: 'btn btn-secondary',
onclick() {
pages.val.forEach(page => page.selected.val = selectedCount.val < totalCount.val)
}
}, () => `${selectedCount.val < totalCount.val ? '全选' : '取消全选'} (${selectedCount.val}/${totalCount.val})`),
button({
class: 'btn btn-primary',
disabled: () => selectedCount.val <= 0,
async onclick() {
workRoute.parseModal.show()
}
}, '解析选中项目')
)
}
const ListBox = (pages: State<PageInParseResult[]>) => {
return () => div({ class: 'row gy-3 gx-3' },
pages.val.map(page => {
const badgeNotNum = !page.badge.match(/^\d+$/)
const active = page.selected
return div({ class: 'col-xxl-3 col-lg-4 col-md-6' },
div({
tabIndex: 0,
class: () => `${badgeNotNum
? `vstack gap-2 justify-content-center`
: `hstack gap-3`
} shadow-sm h-100 text-break user-select-none card card-body video-item-btn bg-success bg-opacity-10 ${active.val ? 'active' : ''}`,
onclick() {
active.val = !active.val
},
onkeyup(e) {
if (e.key == 'Enter') {
active.val = !active.val
}
}
},
span({ class: 'badge text-bg-success bg-opacity-75 border', hidden: badgeNotNum }, page.badge),
div(page.part),
div({ class: `${page.part ? 'small text-muted' : ''}`, hidden: !badgeNotNum }, page.badge),
)
)
}),
)
}
export default (
workRoute: WorkRoute
) => new VideoItemListComp(workRoute).element