diff --git a/src/api/admin/types.ts b/src/api/admin/types.ts index 7bd71f1..511431b 100644 --- a/src/api/admin/types.ts +++ b/src/api/admin/types.ts @@ -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'> diff --git a/src/styles/business/creator-center.css b/src/styles/business/creator-center.css index b274f9f..720c7bf 100644 --- a/src/styles/business/creator-center.css +++ b/src/styles/business/creator-center.css @@ -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; diff --git a/src/styles/business/drama-management.css b/src/styles/business/drama-management.css index 4cb4cf9..2f39b23 100644 --- a/src/styles/business/drama-management.css +++ b/src/styles/business/drama-management.css @@ -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%; } diff --git a/src/utils/imageCompress.ts b/src/utils/imageCompress.ts new file mode 100644 index 0000000..2a90fa4 --- /dev/null +++ b/src/utils/imageCompress.ts @@ -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 { + 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 { + 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 选项,退回 解码。 + } + } + 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 { + 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 { + return new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob), mimeType, quality) + }) +} diff --git a/src/views/contentManagement/BannerManagementView.vue b/src/views/contentManagement/BannerManagementView.vue index 01a414a..ce641a0 100644 --- a/src/views/contentManagement/BannerManagementView.vue +++ b/src/views/contentManagement/BannerManagementView.vue @@ -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 }} - + 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 () => {

封面

-
-
- 短剧封面 -
- - 暂无封面 +
+

竖版封面

+
+
+ 短剧竖版封面 +
+ + 暂无封面 +
+
+ 上传中 + {{ coverPercent }}% +
-
- 上传中 - {{ coverPercent }}% + +
+
+ + {{ form.cover ? '已上传' : '未上传' }} + + {{ coverDisplayName }} +
+

{{ DRAMA_COVER_PORTRAIT.hint }};支持 JPG、PNG、WebP,单张不超过 5MB。

+ +
+ + + + {{ form.cover ? '更换封面' : '选择封面' }} + + + + 移除 + + + 查看原图 + +
+
-
-
- - {{ form.cover ? '已上传' : '未上传' }} - - {{ coverDisplayName }} +
+

横版封面

+
+
+ 短剧横版封面 +
+ + 暂无横版封面 +
+
+ 上传中 + {{ horizontalPercent }}% +
-

支持 JPG、PNG、WebP,单张不超过 5MB。

- -
- - - - {{ form.cover ? '更换封面' : '选择封面' }} + +
+
+ + {{ form.horizontalCover ? '已上传' : '未上传' }} + + {{ + horizontalDisplayName + }} +
+

+ {{ DRAMA_COVER_LANDSCAPE.hint }};支持 JPG、PNG、WebP,单张不超过 5MB。 +

+ +
+ + + + {{ form.horizontalCover ? '更换横版封面' : '选择横版封面' }} + + + + 移除 - - - 移除 - - - 查看原图 - + + 查看原图 + +
diff --git a/src/views/contentManagement/upload/imagePresets.ts b/src/views/contentManagement/upload/imagePresets.ts new file mode 100644 index 0000000..70d578e --- /dev/null +++ b/src/views/contentManagement/upload/imagePresets.ts @@ -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 { + return compressImage(file, { + maxWidth: preset.maxWidth, + maxHeight: preset.maxHeight + }) +} diff --git a/src/views/creator-center/DramaEditView.vue b/src/views/creator-center/DramaEditView.vue index e5e08e3..1858b7f 100644 --- a/src/views/creator-center/DramaEditView.vue +++ b/src/views/creator-center/DramaEditView.vue @@ -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) { 无
- - 上传封面 - +
+ + 上传封面 + +

{{ DRAMA_COVER_PORTRAIT.hint }}

+
@@ -353,14 +357,17 @@ function requestDramaCover(options) { 无
- - 上传封面 - +
+ + 上传封面 + +

{{ EPISODE_COVER.hint }}

+
diff --git a/src/views/creator-center/composables/useDramaEdit.js b/src/views/creator-center/composables/useDramaEdit.js index 9dbcbb4..2849af7 100644 --- a/src/views/creator-center/composables/useDramaEdit.js +++ b/src/views/creator-center/composables/useDramaEdit.js @@ -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) })