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
|
genre?: string
|
||||||
description?: string
|
description?: string
|
||||||
cover?: string
|
cover?: string
|
||||||
|
/** 横版封面,用于继续观看横幅等横向展示位 */
|
||||||
|
horizontalCover?: string
|
||||||
language?: string
|
language?: string
|
||||||
dramaType?: 'ANIME' | 'LIVE'
|
dramaType?: 'ANIME' | 'LIVE'
|
||||||
dramaTypes?: Array<'ANIME' | 'LIVE'>
|
dramaTypes?: Array<'ANIME' | 'LIVE'>
|
||||||
@@ -57,6 +59,8 @@ export interface DramaSaveData {
|
|||||||
genre?: string
|
genre?: string
|
||||||
description?: string
|
description?: string
|
||||||
cover?: string
|
cover?: string
|
||||||
|
/** 横版封面,用于继续观看横幅等横向展示位 */
|
||||||
|
horizontalCover?: string
|
||||||
language?: string
|
language?: string
|
||||||
dramaType?: 'ANIME' | 'LIVE'
|
dramaType?: 'ANIME' | 'LIVE'
|
||||||
dramaTypes?: Array<'ANIME' | 'LIVE'>
|
dramaTypes?: Array<'ANIME' | 'LIVE'>
|
||||||
|
|||||||
@@ -188,6 +188,19 @@
|
|||||||
gap: 10px;
|
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 {
|
.creator-cover-preview {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
|
|||||||
@@ -165,6 +165,19 @@
|
|||||||
line-height: 1.4;
|
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 {
|
.cover-editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
@@ -183,6 +196,12 @@
|
|||||||
background: var(--el-fill-color-lighter);
|
background: var(--el-fill-color-lighter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cover-preview-card--landscape {
|
||||||
|
flex: 0 0 160px;
|
||||||
|
width: 160px;
|
||||||
|
height: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
.cover-preview-card img {
|
.cover-preview-card img {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -297,6 +316,10 @@
|
|||||||
flex-basis: 128px;
|
flex-basis: 128px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cover-preview-card--landscape {
|
||||||
|
flex-basis: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
.cover-editor-meta {
|
.cover-editor-meta {
|
||||||
width: 100%;
|
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 { pageDramasApi } from '@/api/admin/drama'
|
||||||
import { useRbac } from '@/stores/rbac'
|
import { useRbac } from '@/stores/rbac'
|
||||||
import { uploadFile } from '@/utils/uploader'
|
import { uploadFile } from '@/utils/uploader'
|
||||||
|
import { BANNER_IMAGE, compressForPreset } from '@/views/contentManagement/upload/imagePresets'
|
||||||
import PageHeading from '@/views/components/PageHeading.vue'
|
import PageHeading from '@/views/components/PageHeading.vue'
|
||||||
|
|
||||||
const { can } = useRbac()
|
const { can } = useRbac()
|
||||||
@@ -218,12 +219,13 @@ async function uploadImage(options) {
|
|||||||
imagePercent.value = 0
|
imagePercent.value = 0
|
||||||
imageError.value = ''
|
imageError.value = ''
|
||||||
try {
|
try {
|
||||||
const asset = await uploadFile(options.file, {
|
const file = await compressForPreset(options.file, BANNER_IMAGE)
|
||||||
|
const asset = await uploadFile(file, {
|
||||||
bizType: 'BANNER',
|
bizType: 'BANNER',
|
||||||
onProgress: (percent) => (imagePercent.value = percent)
|
onProgress: (percent) => (imagePercent.value = percent)
|
||||||
})
|
})
|
||||||
form.imageUrl = asset.url
|
form.imageUrl = asset.url
|
||||||
imageFileName.value = options.file.name
|
imageFileName.value = file.name
|
||||||
await resetValidation()
|
await resetValidation()
|
||||||
ElMessage.success('图片已上传')
|
ElMessage.success('图片已上传')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -467,7 +469,9 @@ onMounted(async () => {
|
|||||||
{{ imageDisplayName }}
|
{{ imageDisplayName }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<el-alert
|
||||||
v-if="imageError"
|
v-if="imageError"
|
||||||
:title="imageError"
|
:title="imageError"
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import { formatCount } from '@/utils/format'
|
|||||||
import { listContentTagsApi } from '@/api/admin/contentTag'
|
import { listContentTagsApi } from '@/api/admin/contentTag'
|
||||||
import { statusClass } from '@/utils/status'
|
import { statusClass } from '@/utils/status'
|
||||||
import { uploadFile } from '@/utils/uploader'
|
import { uploadFile } from '@/utils/uploader'
|
||||||
|
import {
|
||||||
|
DRAMA_COVER_LANDSCAPE,
|
||||||
|
DRAMA_COVER_PORTRAIT,
|
||||||
|
compressForPreset
|
||||||
|
} from '@/views/contentManagement/upload/imagePresets'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { can, hasAny } = useRbac()
|
const { can, hasAny } = useRbac()
|
||||||
@@ -48,6 +53,10 @@ const uploadingCover = ref(false)
|
|||||||
const coverPercent = ref(0)
|
const coverPercent = ref(0)
|
||||||
const coverError = ref('')
|
const coverError = ref('')
|
||||||
const coverFileName = ref('')
|
const coverFileName = ref('')
|
||||||
|
const uploadingHorizontal = ref(false)
|
||||||
|
const horizontalPercent = ref(0)
|
||||||
|
const horizontalError = ref('')
|
||||||
|
const horizontalFileName = ref('')
|
||||||
const editingId = ref(null)
|
const editingId = ref(null)
|
||||||
const editingCommentCount = ref(0)
|
const editingCommentCount = ref(0)
|
||||||
const editingLikeCount = ref(0)
|
const editingLikeCount = ref(0)
|
||||||
@@ -66,7 +75,8 @@ const form = reactive({
|
|||||||
heat: 0,
|
heat: 0,
|
||||||
isHot: false,
|
isHot: false,
|
||||||
sort: 0,
|
sort: 0,
|
||||||
cover: ''
|
cover: '',
|
||||||
|
horizontalCover: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const MAX_COVER_SIZE = 5 * 1024 * 1024
|
const MAX_COVER_SIZE = 5 * 1024 * 1024
|
||||||
@@ -75,6 +85,11 @@ const COVER_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
|||||||
const coverDisplayName = computed(
|
const coverDisplayName = computed(
|
||||||
() => coverFileName.value || (form.cover ? coverBasename(form.cover) : '尚未选择封面')
|
() => 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))
|
const pageCoverPreviewList = computed(() => list.value.map((item) => item.cover).filter(Boolean))
|
||||||
|
|
||||||
@@ -192,10 +207,13 @@ function openCreate() {
|
|||||||
heat: 0,
|
heat: 0,
|
||||||
isHot: false,
|
isHot: false,
|
||||||
sort: 0,
|
sort: 0,
|
||||||
cover: ''
|
cover: '',
|
||||||
|
horizontalCover: ''
|
||||||
})
|
})
|
||||||
coverFileName.value = ''
|
coverFileName.value = ''
|
||||||
coverError.value = ''
|
coverError.value = ''
|
||||||
|
horizontalFileName.value = ''
|
||||||
|
horizontalError.value = ''
|
||||||
editingCommentCount.value = 0
|
editingCommentCount.value = 0
|
||||||
editingLikeCount.value = 0
|
editingLikeCount.value = 0
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
@@ -223,10 +241,13 @@ function openEdit(row) {
|
|||||||
heat: toCount(row.views),
|
heat: toCount(row.views),
|
||||||
isHot: !!row.isHot,
|
isHot: !!row.isHot,
|
||||||
sort: toCount(row.sort),
|
sort: toCount(row.sort),
|
||||||
cover: row.cover || ''
|
cover: row.cover || '',
|
||||||
|
horizontalCover: row.horizontalCover || ''
|
||||||
})
|
})
|
||||||
coverFileName.value = row.cover ? coverBasename(row.cover) : ''
|
coverFileName.value = row.cover ? coverBasename(row.cover) : ''
|
||||||
coverError.value = ''
|
coverError.value = ''
|
||||||
|
horizontalFileName.value = row.horizontalCover ? coverBasename(row.horizontalCover) : ''
|
||||||
|
horizontalError.value = ''
|
||||||
editingCommentCount.value = toCount(row.commentCount)
|
editingCommentCount.value = toCount(row.commentCount)
|
||||||
editingLikeCount.value = toCount(row.likeCount)
|
editingLikeCount.value = toCount(row.likeCount)
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
@@ -245,7 +266,7 @@ function rowCategoryList(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveDrama() {
|
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 titles = payloadLocales(form.titleLocales, textLanguages.value)
|
||||||
const missing = textLanguages.value.find((code) => !titles[code])
|
const missing = textLanguages.value.find((code) => !titles[code])
|
||||||
if (!titles.zh || missing) {
|
if (!titles.zh || missing) {
|
||||||
@@ -282,32 +303,41 @@ async function saveDrama() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function beforeCoverUpload(file) {
|
function validateCoverFile(file, errorRef, label) {
|
||||||
coverError.value = ''
|
errorRef.value = ''
|
||||||
if (!COVER_TYPES.includes(file.type)) {
|
if (!COVER_TYPES.includes(file.type)) {
|
||||||
coverError.value = '封面仅支持 JPG、PNG 或 WebP 格式'
|
errorRef.value = `${label}仅支持 JPG、PNG 或 WebP 格式`
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (file.size > MAX_COVER_SIZE) {
|
if (file.size > MAX_COVER_SIZE) {
|
||||||
coverError.value = '封面大小不能超过 5MB'
|
errorRef.value = `${label}大小不能超过 5MB`
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 封面走分片上传(图片只有一个分片),成功后回填地址。 */
|
function beforeCoverUpload(file) {
|
||||||
|
return validateCoverFile(file, coverError, '竖版封面')
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforeHorizontalUpload(file) {
|
||||||
|
return validateCoverFile(file, horizontalError, '横版封面')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 封面走分片上传(图片只有一个分片),上传前按展示规格等比压缩,成功后回填地址。 */
|
||||||
async function uploadCover(options) {
|
async function uploadCover(options) {
|
||||||
uploadingCover.value = true
|
uploadingCover.value = true
|
||||||
coverPercent.value = 0
|
coverPercent.value = 0
|
||||||
coverError.value = ''
|
coverError.value = ''
|
||||||
try {
|
try {
|
||||||
const asset = await uploadFile(options.file, {
|
const file = await compressForPreset(options.file, DRAMA_COVER_PORTRAIT)
|
||||||
|
const asset = await uploadFile(file, {
|
||||||
bizType: 'DRAMA_COVER',
|
bizType: 'DRAMA_COVER',
|
||||||
onProgress: (percent) => (coverPercent.value = percent)
|
onProgress: (percent) => (coverPercent.value = percent)
|
||||||
})
|
})
|
||||||
form.cover = asset.url
|
form.cover = asset.url
|
||||||
coverFileName.value = options.file.name
|
coverFileName.value = file.name
|
||||||
ElMessage.success('封面已上传')
|
ElMessage.success('竖版封面已上传')
|
||||||
} catch {
|
} catch {
|
||||||
coverError.value = '封面上传失败,请检查网络后重试'
|
coverError.value = '封面上传失败,请检查网络后重试'
|
||||||
} finally {
|
} 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() {
|
function removeCover() {
|
||||||
if (uploadingCover.value) return
|
if (uploadingCover.value) return
|
||||||
form.cover = ''
|
form.cover = ''
|
||||||
@@ -322,8 +372,15 @@ function removeCover() {
|
|||||||
coverError.value = ''
|
coverError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeHorizontalCover() {
|
||||||
|
if (uploadingHorizontal.value) return
|
||||||
|
form.horizontalCover = ''
|
||||||
|
horizontalFileName.value = ''
|
||||||
|
horizontalError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
function beforeDialogClose(done) {
|
function beforeDialogClose(done) {
|
||||||
if (saving.value || uploadingCover.value) {
|
if (saving.value || uploadingCover.value || uploadingHorizontal.value) {
|
||||||
ElMessage.warning(saving.value ? '正在保存,请稍候' : '封面正在上传,请稍候')
|
ElMessage.warning(saving.value ? '正在保存,请稍候' : '封面正在上传,请稍候')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -572,65 +629,140 @@ onMounted(async () => {
|
|||||||
<el-form ref="formRef" :model="form" label-position="top" class="drama-editor-form">
|
<el-form ref="formRef" :model="form" label-position="top" class="drama-editor-form">
|
||||||
<section class="editor-section">
|
<section class="editor-section">
|
||||||
<h3 class="editor-section-title">封面</h3>
|
<h3 class="editor-section-title">封面</h3>
|
||||||
<div class="cover-editor">
|
<div class="cover-block">
|
||||||
<div class="cover-preview-card">
|
<p class="cover-label">竖版封面</p>
|
||||||
<img v-if="form.cover" :src="form.cover" alt="短剧封面" />
|
<div class="cover-editor">
|
||||||
<div v-else class="cover-preview-empty">
|
<div class="cover-preview-card">
|
||||||
<AppIcon :icon="AppIcons.content" :size="28" />
|
<img v-if="form.cover" :src="form.cover" alt="短剧竖版封面" />
|
||||||
<span>暂无封面</span>
|
<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>
|
||||||
<div v-if="uploadingCover" class="cover-upload-mask">
|
|
||||||
<span>上传中</span>
|
<div class="cover-editor-meta">
|
||||||
<b>{{ coverPercent }}%</b>
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="cover-editor-meta">
|
<div class="cover-block">
|
||||||
<div class="cover-status-line">
|
<p class="cover-label">横版封面</p>
|
||||||
<el-tag :type="form.cover ? 'success' : 'info'" effect="light">
|
<div class="cover-editor">
|
||||||
{{ form.cover ? '已上传' : '未上传' }}
|
<div class="cover-preview-card cover-preview-card--landscape">
|
||||||
</el-tag>
|
<img v-if="form.horizontalCover" :src="form.horizontalCover" alt="短剧横版封面" />
|
||||||
<span class="cover-filename" :title="coverDisplayName">{{ coverDisplayName }}</span>
|
<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>
|
</div>
|
||||||
<p class="cover-hint">支持 JPG、PNG、WebP,单张不超过 5MB。</p>
|
|
||||||
<el-alert
|
<div class="cover-editor-meta">
|
||||||
v-if="coverError"
|
<div class="cover-status-line">
|
||||||
:title="coverError"
|
<el-tag :type="form.horizontalCover ? 'success' : 'info'" effect="light">
|
||||||
type="error"
|
{{ form.horizontalCover ? '已上传' : '未上传' }}
|
||||||
:closable="false"
|
</el-tag>
|
||||||
show-icon
|
<span class="cover-filename" :title="horizontalDisplayName">{{
|
||||||
/>
|
horizontalDisplayName
|
||||||
<div class="cover-actions">
|
}}</span>
|
||||||
<el-upload
|
</div>
|
||||||
v-if="can('cover:upload')"
|
<p class="cover-hint">
|
||||||
:show-file-list="false"
|
{{ DRAMA_COVER_LANDSCAPE.hint }};支持 JPG、PNG、WebP,单张不超过 5MB。
|
||||||
accept="image/jpeg,image/png,image/webp"
|
</p>
|
||||||
:before-upload="beforeCoverUpload"
|
<el-alert
|
||||||
:http-request="uploadCover"
|
v-if="horizontalError"
|
||||||
:disabled="uploadingCover"
|
:title="horizontalError"
|
||||||
>
|
type="error"
|
||||||
<el-button :loading="uploadingCover" :disabled="uploadingCover">
|
:closable="false"
|
||||||
<AppIcon :icon="AppIcons.upload" />
|
show-icon
|
||||||
{{ form.cover ? '更换封面' : '选择封面' }}
|
/>
|
||||||
|
<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-button>
|
||||||
</el-upload>
|
<a
|
||||||
<el-button
|
v-if="form.horizontalCover"
|
||||||
v-if="can('cover:upload') && form.cover"
|
class="cover-original-link"
|
||||||
type="danger"
|
:href="form.horizontalCover"
|
||||||
:disabled="uploadingCover"
|
target="_blank"
|
||||||
@click="removeCover"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
移除
|
查看原图
|
||||||
</el-button>
|
</a>
|
||||||
<a
|
</div>
|
||||||
v-if="form.cover"
|
|
||||||
class="cover-original-link"
|
|
||||||
:href="form.cover"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
查看原图
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</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 { useDramaEdit } from './composables/useDramaEdit'
|
||||||
import { useCreatorAccess } from './composables/useCreatorAccess'
|
import { useCreatorAccess } from './composables/useCreatorAccess'
|
||||||
import { dramaStatusMeta } from './composables/useCreatorWorkspace'
|
import { dramaStatusMeta } from './composables/useCreatorWorkspace'
|
||||||
|
import { DRAMA_COVER_PORTRAIT, EPISODE_COVER } from '@/views/contentManagement/upload/imagePresets'
|
||||||
|
|
||||||
const { can } = useRbac()
|
const { can } = useRbac()
|
||||||
const canEdit = can('creator-center:drama:edit')
|
const canEdit = can('creator-center:drama:edit')
|
||||||
@@ -148,15 +149,18 @@ function requestDramaCover(options) {
|
|||||||
<img v-if="infoForm.cover" :src="infoForm.cover" alt="" />
|
<img v-if="infoForm.cover" :src="infoForm.cover" alt="" />
|
||||||
<span v-else>无</span>
|
<span v-else>无</span>
|
||||||
</div>
|
</div>
|
||||||
<el-upload
|
<div class="creator-cover-actions">
|
||||||
v-if="canUpload"
|
<el-upload
|
||||||
:show-file-list="false"
|
v-if="canUpload"
|
||||||
accept="image/*"
|
:show-file-list="false"
|
||||||
:http-request="requestDramaCover"
|
accept="image/*"
|
||||||
:disabled="uploadingId !== null"
|
:http-request="requestDramaCover"
|
||||||
>
|
:disabled="uploadingId !== null"
|
||||||
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
|
>
|
||||||
</el-upload>
|
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
|
||||||
|
</el-upload>
|
||||||
|
<p class="creator-cover-hint">{{ DRAMA_COVER_PORTRAIT.hint }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="预告片地址">
|
<el-form-item label="预告片地址">
|
||||||
@@ -353,14 +357,17 @@ function requestDramaCover(options) {
|
|||||||
<img v-if="episodeForm.cover" :src="episodeForm.cover" alt="" />
|
<img v-if="episodeForm.cover" :src="episodeForm.cover" alt="" />
|
||||||
<span v-else>无</span>
|
<span v-else>无</span>
|
||||||
</div>
|
</div>
|
||||||
<el-upload
|
<div class="creator-cover-actions">
|
||||||
v-if="canUpload"
|
<el-upload
|
||||||
:show-file-list="false"
|
v-if="canUpload"
|
||||||
accept="image/*"
|
:show-file-list="false"
|
||||||
:http-request="(options) => requestEpisodeCover({ id: episodeEditingId }, options)"
|
accept="image/*"
|
||||||
>
|
:http-request="(options) => requestEpisodeCover({ id: episodeEditingId }, options)"
|
||||||
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
|
>
|
||||||
</el-upload>
|
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
|
||||||
|
</el-upload>
|
||||||
|
<p class="creator-cover-hint">{{ EPISODE_COVER.hint }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="episodeEditingId" label="集号">
|
<el-form-item v-if="episodeEditingId" label="集号">
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ import {
|
|||||||
} from '@/api/admin/creatorCenter'
|
} from '@/api/admin/creatorCenter'
|
||||||
import { dateCell } from '@/utils/format'
|
import { dateCell } from '@/utils/format'
|
||||||
import { probeVideoDuration, resumeUpload, uploadFile } from '@/utils/uploader'
|
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 { submissionStatusMeta, submissionTypeText, submitOptions } from './useCreatorWorkspace'
|
||||||
import { useCreatorAccess } from './useCreatorAccess'
|
import { useCreatorAccess } from './useCreatorAccess'
|
||||||
|
|
||||||
@@ -266,7 +271,9 @@ export function useDramaEdit() {
|
|||||||
uploadingId.value = row ? row.id : -1
|
uploadingId.value = row ? row.id : -1
|
||||||
uploadPercent.value = 0
|
uploadPercent.value = 0
|
||||||
try {
|
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',
|
bizType: 'DRAMA_COVER',
|
||||||
onProgress: (percent) => (uploadPercent.value = percent)
|
onProgress: (percent) => (uploadPercent.value = percent)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in new issue
Block a user