feat(upload): 封面上传支持横版与前端压缩
- 新增 imageCompress 与 imagePresets,上传前按规格高质量等比压缩 - 短剧封面拆分为竖版/横版两个上传位,带建议尺寸提示 - 轮播图、创作者中心封面与分集封面接入压缩与提示
This commit is contained in:
1 parent
dea414739e
commit
923b7dce1e
9 files changed
+486
-87
No files matched your search
@@ -29,6 +29,8 @@ export interface DramaItem {
|
||||
genre?: string
|
||||
description?: string
|
||||
cover?: string
|
||||
/** 横版封面,用于继续观看横幅等横向展示位 */
|
||||
horizontalCover?: string
|
||||
language?: string
|
||||
dramaType?: 'ANIME' | 'LIVE'
|
||||
dramaTypes?: Array<'ANIME' | 'LIVE'>
|
||||
@@ -57,6 +59,8 @@ export interface DramaSaveData {
|
||||
genre?: string
|
||||
description?: string
|
||||
cover?: string
|
||||
/** 横版封面,用于继续观看横幅等横向展示位 */
|
||||
horizontalCover?: string
|
||||
language?: string
|
||||
dramaType?: 'ANIME' | 'LIVE'
|
||||
dramaTypes?: Array<'ANIME' | 'LIVE'>
|
||||
|
||||
@@ -188,6 +188,19 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.creator-cover-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.creator-cover-hint {
|
||||
margin: 0;
|
||||
color: #9a97a8;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.creator-cover-preview {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
@@ -165,6 +165,19 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cover-block + .cover-block {
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.cover-label {
|
||||
margin: 0 0 8px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cover-editor {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
@@ -183,6 +196,12 @@
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
.cover-preview-card--landscape {
|
||||
flex: 0 0 160px;
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
.cover-preview-card img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -297,6 +316,10 @@
|
||||
flex-basis: 128px;
|
||||
}
|
||||
|
||||
.cover-preview-card--landscape {
|
||||
flex-basis: 160px;
|
||||
}
|
||||
|
||||
.cover-editor-meta {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/**
|
||||
* 上传前的图片压缩:只做「超出目标尺寸才等比缩小 + 同格式高质量重编码」,
|
||||
* 尺寸没超、体积也不大时原样返回,避免无意义的二次编码损失。
|
||||
*
|
||||
* 说明:
|
||||
* - 保留原格式(JPEG→JPEG / PNG→PNG / WebP→WebP),避免 PNG 透明底被抹掉;
|
||||
* - GIF / SVG 直接跳过,动图与矢量图重编码会丢帧或失真;
|
||||
* - 缩放用浏览器原生解码 + canvas,配合 EXIF 方向校正,避免竖拍图躺倒。
|
||||
*/
|
||||
|
||||
export interface ImageCompressOptions {
|
||||
/** 目标最大宽(像素)。 */
|
||||
maxWidth: number
|
||||
/** 目标最大高(像素)。 */
|
||||
maxHeight: number
|
||||
/** 重编码质量,默认 0.92,肉眼看不出差异。 */
|
||||
quality?: number
|
||||
/** 原图小于该体积且尺寸已达标时直接原样上传,默认 300KB。 */
|
||||
skipBelowBytes?: number
|
||||
}
|
||||
|
||||
const PASSTHROUGH_TYPES = ['image/gif', 'image/svg+xml']
|
||||
|
||||
/** 可压缩的位图格式;其余类型原样返回。 */
|
||||
export function canCompressImage(file: File): boolean {
|
||||
return file.type.startsWith('image/') && !PASSTHROUGH_TYPES.includes(file.type)
|
||||
}
|
||||
|
||||
/** 按比例算出目标尺寸;不需要缩放时返回 null。 */
|
||||
export function fitWithin(
|
||||
width: number,
|
||||
height: number,
|
||||
maxWidth: number,
|
||||
maxHeight: number
|
||||
): { width: number; height: number } | null {
|
||||
if (width <= 0 || height <= 0) return null
|
||||
const scale = Math.min(maxWidth / width, maxHeight / height, 1)
|
||||
if (scale >= 1) return null
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale))
|
||||
}
|
||||
}
|
||||
|
||||
export async function compressImage(file: File, options: ImageCompressOptions): Promise<File> {
|
||||
if (!canCompressImage(file)) return file
|
||||
|
||||
const quality = options.quality ?? 0.92
|
||||
const skipBelowBytes = options.skipBelowBytes ?? 300 * 1024
|
||||
const bitmap = await decode(file)
|
||||
if (!bitmap) return file
|
||||
|
||||
const { width, height } = bitmap
|
||||
const target = fitWithin(width, height, options.maxWidth, options.maxHeight)
|
||||
|
||||
// 尺寸已经在目标范围内、体积也不大:直接用原图,一点不损失。
|
||||
if (!target && file.size <= skipBelowBytes) {
|
||||
close(bitmap)
|
||||
return file
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = target?.width ?? width
|
||||
canvas.height = target?.height ?? height
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) {
|
||||
close(bitmap)
|
||||
return file
|
||||
}
|
||||
context.imageSmoothingEnabled = true
|
||||
context.imageSmoothingQuality = 'high'
|
||||
context.drawImage(bitmap.source, 0, 0, canvas.width, canvas.height)
|
||||
close(bitmap)
|
||||
|
||||
const mimeType = outputMime(file.type)
|
||||
const blob = await toBlob(canvas, mimeType, quality)
|
||||
if (!blob || blob.size >= file.size) return file
|
||||
return new File([blob], renameWithMime(file.name, mimeType), {
|
||||
type: mimeType,
|
||||
lastModified: file.lastModified
|
||||
})
|
||||
}
|
||||
|
||||
function outputMime(type: string): string {
|
||||
if (type === 'image/png' || type === 'image/webp') return type
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
function renameWithMime(name: string, mimeType: string): string {
|
||||
const ext = mimeType === 'image/png' ? 'png' : mimeType === 'image/webp' ? 'webp' : 'jpg'
|
||||
const base = name.includes('.') ? name.slice(0, name.lastIndexOf('.')) : name
|
||||
return `${base}.${ext}`
|
||||
}
|
||||
|
||||
interface DecodedBitmap {
|
||||
source: CanvasImageSource
|
||||
width: number
|
||||
height: number
|
||||
release?: () => void
|
||||
}
|
||||
|
||||
async function decode(file: File): Promise<DecodedBitmap | null> {
|
||||
if (typeof createImageBitmap === 'function') {
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' })
|
||||
return {
|
||||
source: bitmap,
|
||||
width: bitmap.width,
|
||||
height: bitmap.height,
|
||||
release: () => bitmap.close()
|
||||
}
|
||||
} catch {
|
||||
// 部分浏览器不支持 imageOrientation 选项,退回 <img> 解码。
|
||||
}
|
||||
}
|
||||
const url = URL.createObjectURL(file)
|
||||
try {
|
||||
const image = await loadImage(url)
|
||||
return {
|
||||
source: image,
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
release: () => URL.revokeObjectURL(url)
|
||||
}
|
||||
} catch {
|
||||
URL.revokeObjectURL(url)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function close(bitmap: DecodedBitmap): void {
|
||||
bitmap.release?.()
|
||||
}
|
||||
|
||||
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = () => reject(new Error('image decode failed'))
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function toBlob(
|
||||
canvas: HTMLCanvasElement,
|
||||
mimeType: string,
|
||||
quality: number
|
||||
): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob), mimeType, quality)
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { pageDramasApi } from '@/api/admin/drama'
|
||||
import { useRbac } from '@/stores/rbac'
|
||||
import { uploadFile } from '@/utils/uploader'
|
||||
import { BANNER_IMAGE, compressForPreset } from '@/views/contentManagement/upload/imagePresets'
|
||||
import PageHeading from '@/views/components/PageHeading.vue'
|
||||
|
||||
const { can } = useRbac()
|
||||
@@ -218,12 +219,13 @@ async function uploadImage(options) {
|
||||
imagePercent.value = 0
|
||||
imageError.value = ''
|
||||
try {
|
||||
const asset = await uploadFile(options.file, {
|
||||
const file = await compressForPreset(options.file, BANNER_IMAGE)
|
||||
const asset = await uploadFile(file, {
|
||||
bizType: 'BANNER',
|
||||
onProgress: (percent) => (imagePercent.value = percent)
|
||||
})
|
||||
form.imageUrl = asset.url
|
||||
imageFileName.value = options.file.name
|
||||
imageFileName.value = file.name
|
||||
await resetValidation()
|
||||
ElMessage.success('图片已上传')
|
||||
} catch {
|
||||
@@ -467,7 +469,9 @@ onMounted(async () => {
|
||||
{{ imageDisplayName }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="banner-image-hint">支持 JPG、PNG、WebP,单张不超过 5MB。</p>
|
||||
<p class="banner-image-hint"
|
||||
>{{ BANNER_IMAGE.hint }};支持 JPG、PNG、WebP,单张不超过 5MB。</p
|
||||
>
|
||||
<el-alert
|
||||
v-if="imageError"
|
||||
:title="imageError"
|
||||
|
||||
@@ -26,6 +26,11 @@ import { formatCount } from '@/utils/format'
|
||||
import { listContentTagsApi } from '@/api/admin/contentTag'
|
||||
import { statusClass } from '@/utils/status'
|
||||
import { uploadFile } from '@/utils/uploader'
|
||||
import {
|
||||
DRAMA_COVER_LANDSCAPE,
|
||||
DRAMA_COVER_PORTRAIT,
|
||||
compressForPreset
|
||||
} from '@/views/contentManagement/upload/imagePresets'
|
||||
|
||||
const router = useRouter()
|
||||
const { can, hasAny } = useRbac()
|
||||
@@ -48,6 +53,10 @@ const uploadingCover = ref(false)
|
||||
const coverPercent = ref(0)
|
||||
const coverError = ref('')
|
||||
const coverFileName = ref('')
|
||||
const uploadingHorizontal = ref(false)
|
||||
const horizontalPercent = ref(0)
|
||||
const horizontalError = ref('')
|
||||
const horizontalFileName = ref('')
|
||||
const editingId = ref(null)
|
||||
const editingCommentCount = ref(0)
|
||||
const editingLikeCount = ref(0)
|
||||
@@ -66,7 +75,8 @@ const form = reactive({
|
||||
heat: 0,
|
||||
isHot: false,
|
||||
sort: 0,
|
||||
cover: ''
|
||||
cover: '',
|
||||
horizontalCover: ''
|
||||
})
|
||||
|
||||
const MAX_COVER_SIZE = 5 * 1024 * 1024
|
||||
@@ -75,6 +85,11 @@ const COVER_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||
const coverDisplayName = computed(
|
||||
() => coverFileName.value || (form.cover ? coverBasename(form.cover) : '尚未选择封面')
|
||||
)
|
||||
const horizontalDisplayName = computed(
|
||||
() =>
|
||||
horizontalFileName.value ||
|
||||
(form.horizontalCover ? coverBasename(form.horizontalCover) : '尚未选择横版封面')
|
||||
)
|
||||
|
||||
const pageCoverPreviewList = computed(() => list.value.map((item) => item.cover).filter(Boolean))
|
||||
|
||||
@@ -192,10 +207,13 @@ function openCreate() {
|
||||
heat: 0,
|
||||
isHot: false,
|
||||
sort: 0,
|
||||
cover: ''
|
||||
cover: '',
|
||||
horizontalCover: ''
|
||||
})
|
||||
coverFileName.value = ''
|
||||
coverError.value = ''
|
||||
horizontalFileName.value = ''
|
||||
horizontalError.value = ''
|
||||
editingCommentCount.value = 0
|
||||
editingLikeCount.value = 0
|
||||
dialogVisible.value = true
|
||||
@@ -223,10 +241,13 @@ function openEdit(row) {
|
||||
heat: toCount(row.views),
|
||||
isHot: !!row.isHot,
|
||||
sort: toCount(row.sort),
|
||||
cover: row.cover || ''
|
||||
cover: row.cover || '',
|
||||
horizontalCover: row.horizontalCover || ''
|
||||
})
|
||||
coverFileName.value = row.cover ? coverBasename(row.cover) : ''
|
||||
coverError.value = ''
|
||||
horizontalFileName.value = row.horizontalCover ? coverBasename(row.horizontalCover) : ''
|
||||
horizontalError.value = ''
|
||||
editingCommentCount.value = toCount(row.commentCount)
|
||||
editingLikeCount.value = toCount(row.likeCount)
|
||||
dialogVisible.value = true
|
||||
@@ -245,7 +266,7 @@ function rowCategoryList(row) {
|
||||
}
|
||||
|
||||
async function saveDrama() {
|
||||
if (saving.value || uploadingCover.value) return
|
||||
if (saving.value || uploadingCover.value || uploadingHorizontal.value) return
|
||||
const titles = payloadLocales(form.titleLocales, textLanguages.value)
|
||||
const missing = textLanguages.value.find((code) => !titles[code])
|
||||
if (!titles.zh || missing) {
|
||||
@@ -282,32 +303,41 @@ async function saveDrama() {
|
||||
}
|
||||
}
|
||||
|
||||
function beforeCoverUpload(file) {
|
||||
coverError.value = ''
|
||||
function validateCoverFile(file, errorRef, label) {
|
||||
errorRef.value = ''
|
||||
if (!COVER_TYPES.includes(file.type)) {
|
||||
coverError.value = '封面仅支持 JPG、PNG 或 WebP 格式'
|
||||
errorRef.value = `${label}仅支持 JPG、PNG 或 WebP 格式`
|
||||
return false
|
||||
}
|
||||
if (file.size > MAX_COVER_SIZE) {
|
||||
coverError.value = '封面大小不能超过 5MB'
|
||||
errorRef.value = `${label}大小不能超过 5MB`
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 封面走分片上传(图片只有一个分片),成功后回填地址。 */
|
||||
function beforeCoverUpload(file) {
|
||||
return validateCoverFile(file, coverError, '竖版封面')
|
||||
}
|
||||
|
||||
function beforeHorizontalUpload(file) {
|
||||
return validateCoverFile(file, horizontalError, '横版封面')
|
||||
}
|
||||
|
||||
/** 封面走分片上传(图片只有一个分片),上传前按展示规格等比压缩,成功后回填地址。 */
|
||||
async function uploadCover(options) {
|
||||
uploadingCover.value = true
|
||||
coverPercent.value = 0
|
||||
coverError.value = ''
|
||||
try {
|
||||
const asset = await uploadFile(options.file, {
|
||||
const file = await compressForPreset(options.file, DRAMA_COVER_PORTRAIT)
|
||||
const asset = await uploadFile(file, {
|
||||
bizType: 'DRAMA_COVER',
|
||||
onProgress: (percent) => (coverPercent.value = percent)
|
||||
})
|
||||
form.cover = asset.url
|
||||
coverFileName.value = options.file.name
|
||||
ElMessage.success('封面已上传')
|
||||
coverFileName.value = file.name
|
||||
ElMessage.success('竖版封面已上传')
|
||||
} catch {
|
||||
coverError.value = '封面上传失败,请检查网络后重试'
|
||||
} finally {
|
||||
@@ -315,6 +345,26 @@ async function uploadCover(options) {
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadHorizontalCover(options) {
|
||||
uploadingHorizontal.value = true
|
||||
horizontalPercent.value = 0
|
||||
horizontalError.value = ''
|
||||
try {
|
||||
const file = await compressForPreset(options.file, DRAMA_COVER_LANDSCAPE)
|
||||
const asset = await uploadFile(file, {
|
||||
bizType: 'DRAMA_COVER',
|
||||
onProgress: (percent) => (horizontalPercent.value = percent)
|
||||
})
|
||||
form.horizontalCover = asset.url
|
||||
horizontalFileName.value = file.name
|
||||
ElMessage.success('横版封面已上传')
|
||||
} catch {
|
||||
horizontalError.value = '横版封面上传失败,请检查网络后重试'
|
||||
} finally {
|
||||
uploadingHorizontal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeCover() {
|
||||
if (uploadingCover.value) return
|
||||
form.cover = ''
|
||||
@@ -322,8 +372,15 @@ function removeCover() {
|
||||
coverError.value = ''
|
||||
}
|
||||
|
||||
function removeHorizontalCover() {
|
||||
if (uploadingHorizontal.value) return
|
||||
form.horizontalCover = ''
|
||||
horizontalFileName.value = ''
|
||||
horizontalError.value = ''
|
||||
}
|
||||
|
||||
function beforeDialogClose(done) {
|
||||
if (saving.value || uploadingCover.value) {
|
||||
if (saving.value || uploadingCover.value || uploadingHorizontal.value) {
|
||||
ElMessage.warning(saving.value ? '正在保存,请稍候' : '封面正在上传,请稍候')
|
||||
return
|
||||
}
|
||||
@@ -572,65 +629,140 @@ onMounted(async () => {
|
||||
<el-form ref="formRef" :model="form" label-position="top" class="drama-editor-form">
|
||||
<section class="editor-section">
|
||||
<h3 class="editor-section-title">封面</h3>
|
||||
<div class="cover-editor">
|
||||
<div class="cover-preview-card">
|
||||
<img v-if="form.cover" :src="form.cover" alt="短剧封面" />
|
||||
<div v-else class="cover-preview-empty">
|
||||
<AppIcon :icon="AppIcons.content" :size="28" />
|
||||
<span>暂无封面</span>
|
||||
<div class="cover-block">
|
||||
<p class="cover-label">竖版封面</p>
|
||||
<div class="cover-editor">
|
||||
<div class="cover-preview-card">
|
||||
<img v-if="form.cover" :src="form.cover" alt="短剧竖版封面" />
|
||||
<div v-else class="cover-preview-empty">
|
||||
<AppIcon :icon="AppIcons.content" :size="28" />
|
||||
<span>暂无封面</span>
|
||||
</div>
|
||||
<div v-if="uploadingCover" class="cover-upload-mask">
|
||||
<span>上传中</span>
|
||||
<b>{{ coverPercent }}%</b>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="uploadingCover" class="cover-upload-mask">
|
||||
<span>上传中</span>
|
||||
<b>{{ coverPercent }}%</b>
|
||||
|
||||
<div class="cover-editor-meta">
|
||||
<div class="cover-status-line">
|
||||
<el-tag :type="form.cover ? 'success' : 'info'" effect="light">
|
||||
{{ form.cover ? '已上传' : '未上传' }}
|
||||
</el-tag>
|
||||
<span class="cover-filename" :title="coverDisplayName">{{ coverDisplayName }}</span>
|
||||
</div>
|
||||
<p class="cover-hint"
|
||||
>{{ DRAMA_COVER_PORTRAIT.hint }};支持 JPG、PNG、WebP,单张不超过 5MB。</p
|
||||
>
|
||||
<el-alert
|
||||
v-if="coverError"
|
||||
:title="coverError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<div class="cover-actions">
|
||||
<el-upload
|
||||
v-if="can('cover:upload')"
|
||||
:show-file-list="false"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
:before-upload="beforeCoverUpload"
|
||||
:http-request="uploadCover"
|
||||
:disabled="uploadingCover"
|
||||
>
|
||||
<el-button :loading="uploadingCover" :disabled="uploadingCover">
|
||||
<AppIcon :icon="AppIcons.upload" />
|
||||
{{ form.cover ? '更换封面' : '选择封面' }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<el-button
|
||||
v-if="can('cover:upload') && form.cover"
|
||||
type="danger"
|
||||
:disabled="uploadingCover"
|
||||
@click="removeCover"
|
||||
>
|
||||
移除
|
||||
</el-button>
|
||||
<a
|
||||
v-if="form.cover"
|
||||
class="cover-original-link"
|
||||
:href="form.cover"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
查看原图
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cover-editor-meta">
|
||||
<div class="cover-status-line">
|
||||
<el-tag :type="form.cover ? 'success' : 'info'" effect="light">
|
||||
{{ form.cover ? '已上传' : '未上传' }}
|
||||
</el-tag>
|
||||
<span class="cover-filename" :title="coverDisplayName">{{ coverDisplayName }}</span>
|
||||
<div class="cover-block">
|
||||
<p class="cover-label">横版封面</p>
|
||||
<div class="cover-editor">
|
||||
<div class="cover-preview-card cover-preview-card--landscape">
|
||||
<img v-if="form.horizontalCover" :src="form.horizontalCover" alt="短剧横版封面" />
|
||||
<div v-else class="cover-preview-empty">
|
||||
<AppIcon :icon="AppIcons.content" :size="28" />
|
||||
<span>暂无横版封面</span>
|
||||
</div>
|
||||
<div v-if="uploadingHorizontal" class="cover-upload-mask">
|
||||
<span>上传中</span>
|
||||
<b>{{ horizontalPercent }}%</b>
|
||||
</div>
|
||||
</div>
|
||||
<p class="cover-hint">支持 JPG、PNG、WebP,单张不超过 5MB。</p>
|
||||
<el-alert
|
||||
v-if="coverError"
|
||||
:title="coverError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<div class="cover-actions">
|
||||
<el-upload
|
||||
v-if="can('cover:upload')"
|
||||
:show-file-list="false"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
:before-upload="beforeCoverUpload"
|
||||
:http-request="uploadCover"
|
||||
:disabled="uploadingCover"
|
||||
>
|
||||
<el-button :loading="uploadingCover" :disabled="uploadingCover">
|
||||
<AppIcon :icon="AppIcons.upload" />
|
||||
{{ form.cover ? '更换封面' : '选择封面' }}
|
||||
|
||||
<div class="cover-editor-meta">
|
||||
<div class="cover-status-line">
|
||||
<el-tag :type="form.horizontalCover ? 'success' : 'info'" effect="light">
|
||||
{{ form.horizontalCover ? '已上传' : '未上传' }}
|
||||
</el-tag>
|
||||
<span class="cover-filename" :title="horizontalDisplayName">{{
|
||||
horizontalDisplayName
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="cover-hint">
|
||||
{{ DRAMA_COVER_LANDSCAPE.hint }};支持 JPG、PNG、WebP,单张不超过 5MB。
|
||||
</p>
|
||||
<el-alert
|
||||
v-if="horizontalError"
|
||||
:title="horizontalError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<div class="cover-actions">
|
||||
<el-upload
|
||||
v-if="can('cover:upload')"
|
||||
:show-file-list="false"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
:before-upload="beforeHorizontalUpload"
|
||||
:http-request="uploadHorizontalCover"
|
||||
:disabled="uploadingHorizontal"
|
||||
>
|
||||
<el-button :loading="uploadingHorizontal" :disabled="uploadingHorizontal">
|
||||
<AppIcon :icon="AppIcons.upload" />
|
||||
{{ form.horizontalCover ? '更换横版封面' : '选择横版封面' }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<el-button
|
||||
v-if="can('cover:upload') && form.horizontalCover"
|
||||
type="danger"
|
||||
:disabled="uploadingHorizontal"
|
||||
@click="removeHorizontalCover"
|
||||
>
|
||||
移除
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<el-button
|
||||
v-if="can('cover:upload') && form.cover"
|
||||
type="danger"
|
||||
:disabled="uploadingCover"
|
||||
@click="removeCover"
|
||||
>
|
||||
移除
|
||||
</el-button>
|
||||
<a
|
||||
v-if="form.cover"
|
||||
class="cover-original-link"
|
||||
:href="form.cover"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
查看原图
|
||||
</a>
|
||||
<a
|
||||
v-if="form.horizontalCover"
|
||||
class="cover-original-link"
|
||||
:href="form.horizontalCover"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
查看原图
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import { compressImage } from '@/utils/imageCompress'
|
||||
|
||||
/**
|
||||
* 图片上传规格:建议尺寸来自客户端实际展示位,按 3 倍屏预留像素。
|
||||
*
|
||||
* - 竖版封面 3:4:首页三列 ~118dp、推荐分类两列 ~175dp、追剧收藏 ~118dp,
|
||||
* 同时作为播放器起播首帧铺满竖屏,1080×1440 是兼顾清晰度与体积的平衡点。
|
||||
* - 横版封面 16:9:追剧页「继续观看」横幅(约 2.06:1)与后续横向位共用。
|
||||
* - 推荐轮播:客户端轮播高度固定 420dp、按屏宽铺满(约 1:1.08)。
|
||||
*/
|
||||
export interface ImagePreset {
|
||||
label: string
|
||||
/** 建议尺寸文案,展示在上传按钮旁。 */
|
||||
hint: string
|
||||
maxWidth: number
|
||||
maxHeight: number
|
||||
}
|
||||
|
||||
export const DRAMA_COVER_PORTRAIT: ImagePreset = {
|
||||
label: '竖版封面',
|
||||
hint: '建议 1080×1440(3:4),最小 720×960',
|
||||
maxWidth: 1080,
|
||||
maxHeight: 1440
|
||||
}
|
||||
|
||||
export const DRAMA_COVER_LANDSCAPE: ImagePreset = {
|
||||
label: '横版封面',
|
||||
hint: '建议 1280×720(16:9),用于继续观看横幅等横向位',
|
||||
maxWidth: 1280,
|
||||
maxHeight: 720
|
||||
}
|
||||
|
||||
export const EPISODE_COVER: ImagePreset = {
|
||||
label: '分集封面',
|
||||
hint: '建议 720×960(3:4)',
|
||||
maxWidth: 720,
|
||||
maxHeight: 960
|
||||
}
|
||||
|
||||
export const BANNER_IMAGE: ImagePreset = {
|
||||
label: '推荐轮播图',
|
||||
hint: '建议 1080×1164(约 1:1.08,与客户端 420dp 轮播高度一致)',
|
||||
maxWidth: 1080,
|
||||
maxHeight: 1164
|
||||
}
|
||||
|
||||
/** 按规格压缩:尺寸未超且体积不大时原样返回,不做二次编码。 */
|
||||
export function compressForPreset(file: File, preset: ImagePreset): Promise<File> {
|
||||
return compressImage(file, {
|
||||
maxWidth: preset.maxWidth,
|
||||
maxHeight: preset.maxHeight
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { useRbac } from '@/stores/rbac'
|
||||
import { useDramaEdit } from './composables/useDramaEdit'
|
||||
import { useCreatorAccess } from './composables/useCreatorAccess'
|
||||
import { dramaStatusMeta } from './composables/useCreatorWorkspace'
|
||||
import { DRAMA_COVER_PORTRAIT, EPISODE_COVER } from '@/views/contentManagement/upload/imagePresets'
|
||||
|
||||
const { can } = useRbac()
|
||||
const canEdit = can('creator-center:drama:edit')
|
||||
@@ -148,15 +149,18 @@ function requestDramaCover(options) {
|
||||
<img v-if="infoForm.cover" :src="infoForm.cover" alt="" />
|
||||
<span v-else>无</span>
|
||||
</div>
|
||||
<el-upload
|
||||
v-if="canUpload"
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
:http-request="requestDramaCover"
|
||||
:disabled="uploadingId !== null"
|
||||
>
|
||||
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
|
||||
</el-upload>
|
||||
<div class="creator-cover-actions">
|
||||
<el-upload
|
||||
v-if="canUpload"
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
:http-request="requestDramaCover"
|
||||
:disabled="uploadingId !== null"
|
||||
>
|
||||
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
|
||||
</el-upload>
|
||||
<p class="creator-cover-hint">{{ DRAMA_COVER_PORTRAIT.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="预告片地址">
|
||||
@@ -353,14 +357,17 @@ function requestDramaCover(options) {
|
||||
<img v-if="episodeForm.cover" :src="episodeForm.cover" alt="" />
|
||||
<span v-else>无</span>
|
||||
</div>
|
||||
<el-upload
|
||||
v-if="canUpload"
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
:http-request="(options) => requestEpisodeCover({ id: episodeEditingId }, options)"
|
||||
>
|
||||
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
|
||||
</el-upload>
|
||||
<div class="creator-cover-actions">
|
||||
<el-upload
|
||||
v-if="canUpload"
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
:http-request="(options) => requestEpisodeCover({ id: episodeEditingId }, options)"
|
||||
>
|
||||
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
|
||||
</el-upload>
|
||||
<p class="creator-cover-hint">{{ EPISODE_COVER.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="episodeEditingId" label="集号">
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
} from '@/api/admin/creatorCenter'
|
||||
import { dateCell } from '@/utils/format'
|
||||
import { probeVideoDuration, resumeUpload, uploadFile } from '@/utils/uploader'
|
||||
import {
|
||||
DRAMA_COVER_PORTRAIT,
|
||||
EPISODE_COVER,
|
||||
compressForPreset
|
||||
} from '@/views/contentManagement/upload/imagePresets'
|
||||
import { submissionStatusMeta, submissionTypeText, submitOptions } from './useCreatorWorkspace'
|
||||
import { useCreatorAccess } from './useCreatorAccess'
|
||||
|
||||
@@ -266,7 +271,9 @@ export function useDramaEdit() {
|
||||
uploadingId.value = row ? row.id : -1
|
||||
uploadPercent.value = 0
|
||||
try {
|
||||
const asset = await uploadFile(file, {
|
||||
const preset = row ? EPISODE_COVER : DRAMA_COVER_PORTRAIT
|
||||
const compressed = await compressForPreset(file, preset)
|
||||
const asset = await uploadFile(compressed, {
|
||||
bizType: 'DRAMA_COVER',
|
||||
onProgress: (percent) => (uploadPercent.value = percent)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user