refactor(admin): 拆分剧集转码产物面板组件

This commit is contained in:
Zhou Weidong committed 2026-10-22 14:47:01 +08:00
1 parent 52b10d3040
commit e2ca3bfa81
8 files changed
+860 -608

No files matched your search

@@ -0,0 +1,315 @@
<!-- Author: fruit.com.cn -->
<!-- 剧集转码逐帧、雪碧图与分页预览 -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import type {
MediaThumbnailFrameItem,
MediaThumbnailResult,
MediaThumbnailSpriteInfo
} from '@/api/admin/types'
import { useI18n } from '@/hooks/web/useI18n'
import { formatTimeMs, THUMBNAIL_PAGE_SIZES } from './episodeTranscodeUi'
interface FramesView {
data: MediaThumbnailResult | null
loading: boolean
errorMessage: string
pageIndex: number
pageSize: number
}
const props = defineProps<{
view: FramesView
}>()
const emit = defineEmits<{
pageChange: [value: number]
pageSizeChange: [value: number]
retry: []
}>()
const FRAME_CARD_WIDTH = 128
const PREVIEW_MAX_WIDTH = 520
const PREVIEW_MAX_HEIGHT = 360
const { t } = useI18n()
const frameView = ref<'frames' | 'sprites'>('frames')
const previewVisible = ref(false)
const previewFrame = ref<MediaThumbnailFrameItem | null>(null)
const frames = computed(() => props.view.data?.frames?.list || [])
const sprite = computed(() => props.view.data?.sprite || null)
const spriteUrls = computed(() => sprite.value?.spriteUrls || [])
const framesTotal = computed(() => Number(props.view.data?.frames?.total || 0))
const previewStyle = computed(() => {
const frame = previewFrame.value
const info = sprite.value
if (!frame || !info) return {}
const scale = Math.min(
PREVIEW_MAX_WIDTH / Math.max(1, frame.width),
PREVIEW_MAX_HEIGHT / Math.max(1, frame.height)
)
return frameStyle(frame, info, scale)
})
function openFramePreview(frame: MediaThumbnailFrameItem) {
previewFrame.value = frame
previewVisible.value = true
}
function frameStyle(frame: MediaThumbnailFrameItem, info: MediaThumbnailSpriteInfo, scale: number) {
return {
width: `${Math.round(frame.width * scale)}px`,
height: `${Math.round(frame.height * scale)}px`,
backgroundImage: `url("${frame.spriteUrl || frame.imageUrl}")`,
backgroundPosition: `-${Math.round(frame.x * scale)}px -${Math.round(frame.y * scale)}px`,
backgroundSize: `${Math.round((info.columns || 1) * frame.width * scale)}px ${Math.round(
(info.rows || 1) * frame.height * scale
)}px`
}
}
function frameCardStyle(frame: MediaThumbnailFrameItem) {
if (!sprite.value) return {}
return frameStyle(frame, sprite.value, FRAME_CARD_WIDTH / Math.max(1, frame.width))
}
function spriteFrameCount(index: number) {
const info = sprite.value
if (!info) return 0
const capacity = Math.max(1, info.columns * info.rows)
return Math.max(0, Math.min(capacity, info.totalFrames - index * capacity))
}
</script>
<template>
<div class="episode-transcode-frames__heading">
<h4>{{ t('episodeTranscode.frameTitle') }}</h4>
<div class="episode-transcode-frames__tools">
<span>{{ t('episodeTranscode.frameCount', { total: framesTotal }) }}</span>
<el-radio-group v-model="frameView" size="small">
<el-radio-button label="frames">{{ t('episodeTranscode.frameView') }}</el-radio-button>
<el-radio-button label="sprites">{{ t('episodeTranscode.spriteView') }}</el-radio-button>
</el-radio-group>
</div>
</div>
<el-alert
v-if="view.errorMessage"
type="error"
:title="view.errorMessage"
:closable="false"
show-icon
>
<template #default>
<el-button size="small" text @click="emit('retry')">
{{ t('episodeTranscode.retryLoad') }}
</el-button>
</template>
</el-alert>
<div v-loading="view.loading">
<template v-if="frameView === 'frames'">
<div v-if="frames.length" class="episode-transcode-frames__grid">
<button
v-for="frame in frames"
:key="frame.frameIndex"
class="episode-transcode-frames__card"
type="button"
:aria-label="t('episodeTranscode.previewFrame', { time: formatTimeMs(frame.timeMs) })"
@click="openFramePreview(frame)"
>
<span class="episode-transcode-frames__image" :style="frameCardStyle(frame)"></span>
<span>{{ formatTimeMs(frame.timeMs) }}</span>
</button>
</div>
<el-empty v-else :description="t('episodeTranscode.noFrames')" :image-size="58" />
<el-pagination
v-if="framesTotal"
class="episode-transcode-frames__pagination"
background
layout="sizes, prev, pager, next"
:current-page="view.pageIndex"
:page-size="view.pageSize"
:page-sizes="THUMBNAIL_PAGE_SIZES"
:total="framesTotal"
@current-change="emit('pageChange', $event)"
@size-change="emit('pageSizeChange', $event)"
/>
</template>
<div v-else-if="spriteUrls.length && sprite" class="episode-transcode-frames__sprites">
<figure
v-for="(url, index) in spriteUrls"
:key="url"
class="episode-transcode-frames__sprite"
>
<div
class="episode-transcode-frames__sprite-frame"
:style="{
aspectRatio: `${sprite.columns * sprite.thumbWidth} / ${
sprite.rows * sprite.thumbHeight
}`
}"
>
<el-image
:src="url"
:preview-src-list="spriteUrls"
:initial-index="index"
fit="contain"
preview-teleported
hide-on-click-modal
/>
<span
class="episode-transcode-frames__sprite-grid"
:style="{
backgroundSize: `calc(100% / ${sprite.columns}) calc(100% / ${sprite.rows})`
}"
></span>
</div>
<figcaption>
<b>{{ t('episodeTranscode.spriteSheet', { index: index + 1 }) }}</b>
<span>{{
t('episodeTranscode.spriteSheetFrames', { count: spriteFrameCount(index) })
}}</span>
</figcaption>
</figure>
</div>
<el-empty v-else :description="t('episodeTranscode.noSprites')" :image-size="58" />
</div>
<el-dialog
v-model="previewVisible"
:title="t('episodeTranscode.zoomTitle')"
width="min(92vw, 600px)"
append-to-body
destroy-on-close
>
<div class="episode-transcode-frames__preview-shell">
<div class="episode-transcode-frames__preview-image" :style="previewStyle"></div>
</div>
</el-dialog>
</template>
<style scoped>
.episode-transcode-frames__heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 18px 0 10px;
}
.episode-transcode-frames__heading h4 {
margin: 0;
font-size: 13px;
}
.episode-transcode-frames__tools {
display: flex;
font-size: 12px;
color: var(--el-text-color-secondary);
align-items: center;
gap: 12px;
}
.episode-transcode-frames__grid {
display: grid;
justify-content: center;
gap: 10px;
grid-template-columns: repeat(4, 128px);
}
.episode-transcode-frames__card {
display: flex;
min-width: 0;
padding: 0;
font-size: 11px;
color: var(--el-text-color-secondary);
cursor: zoom-in;
background: transparent;
border: 0;
flex-direction: column;
gap: 5px;
}
.episode-transcode-frames__image {
display: block;
overflow: hidden;
background-color: var(--el-fill-color-dark);
background-repeat: no-repeat;
border: 1px solid var(--el-border-color-lighter);
border-radius: 7px;
}
.episode-transcode-frames__card:hover .episode-transcode-frames__image {
border-color: var(--el-color-primary);
}
.episode-transcode-frames__pagination {
margin-top: 14px;
justify-content: flex-end;
}
.episode-transcode-frames__sprites {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.episode-transcode-frames__sprite {
min-width: 0;
margin: 0;
}
.episode-transcode-frames__sprite-frame {
position: relative;
overflow: hidden;
background: var(--el-fill-color-dark);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.episode-transcode-frames__sprite-frame :deep(.el-image) {
display: block;
width: 100%;
height: 100%;
}
.episode-transcode-frames__sprite-grid {
position: absolute;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(to right, rgb(255 255 255 / 34%) 1px, transparent 1px),
linear-gradient(to bottom, rgb(255 255 255 / 34%) 1px, transparent 1px);
background-repeat: repeat;
}
.episode-transcode-frames__sprite figcaption {
display: flex;
padding-top: 6px;
font-size: 11px;
color: var(--el-text-color-secondary);
justify-content: space-between;
gap: 8px;
}
.episode-transcode-frames__preview-shell {
display: flex;
min-height: 240px;
background: var(--el-fill-color-dark);
border-radius: 8px;
align-items: center;
justify-content: center;
}
.episode-transcode-frames__preview-image {
background-repeat: no-repeat;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
@media (width <= 600px) {
.episode-transcode-frames__grid {
grid-template-columns: repeat(2, 128px);
}
}
</style>