视频背景上传增加封面功能

This commit is contained in:
taiyi 2026-04-15 22:33:30 +08:00
parent 7ab19ef4fa
commit e78e8feea9
5 changed files with 458 additions and 51 deletions

View File

@ -76,6 +76,7 @@ async def list_backgrounds(
"price_points": bg.price_points,
"category": getattr(bg, 'category', 'default'),
"thumbnail": bg.thumbnail,
"cover_url": getattr(bg, 'cover_url', None),
"created_at": bg.created_at.isoformat() if bg.created_at else None,
"updated_at": bg.updated_at.isoformat() if bg.updated_at else None,
"device_type": getattr(bg, 'device_type', 1),
@ -133,6 +134,7 @@ async def get_background_detail(
"price_points": bg.price_points,
"category": getattr(bg, 'category', 'default'),
"thumbnail": bg.thumbnail,
"cover_url": getattr(bg, 'cover_url', None),
"created_at": bg.created_at.isoformat() if bg.created_at else None,
"updated_at": bg.updated_at.isoformat() if bg.updated_at else None,
"device_type": getattr(bg, 'device_type', 1), # 1: 电脑背景, 2: 手机背景
@ -166,6 +168,7 @@ async def create_background(
price_points=background_data.get("price_points", 0),
category=background_data.get("category", "default"),
thumbnail=background_data.get("thumbnail", background_data.get("resource_url")),
cover_url=background_data.get("cover_url", background_data.get("thumbnail")),
device_type=background_data.get("device_type", 1) # 1: 电脑背景, 2: 手机背景
)
@ -188,6 +191,7 @@ async def create_background(
"price_points": new_background.price_points,
"category": getattr(new_background, 'category', 'default'),
"thumbnail": new_background.thumbnail,
"cover_url": getattr(new_background, 'cover_url', None),
"created_at": new_background.created_at.isoformat() if new_background.created_at else None,
"updated_at": new_background.updated_at.isoformat() if new_background.updated_at else None,
"pet_ids": []
@ -238,6 +242,8 @@ async def update_background(
setattr(bg, 'category', background_data["category"])
if "thumbnail" in background_data:
bg.thumbnail = background_data["thumbnail"]
if "cover_url" in background_data:
setattr(bg, 'cover_url', background_data["cover_url"])
if "device_type" in background_data:
bg.device_type = background_data["device_type"]
@ -261,6 +267,7 @@ async def update_background(
"price_points": bg.price_points,
"category": getattr(bg, 'category', 'default'),
"thumbnail": bg.thumbnail,
"cover_url": getattr(bg, 'cover_url', None),
"created_at": bg.created_at.isoformat() if bg.created_at else None,
"updated_at": bg.updated_at.isoformat() if bg.updated_at else None,
"device_type": getattr(bg, 'device_type', 1), # 1: 电脑背景, 2: 手机背景

View File

@ -0,0 +1,51 @@
"""
数据库迁移脚本 - 为背景表添加封面字段
运行方式: cd backend; $env:PYTHONPATH="."; python migrations/add_background_cover_url.py
"""
import asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from config.settings import settings
async def migrate():
"""执行迁移"""
db_url = settings.DATABASE_URL
if 'mysql+pymysql' in db_url:
async_db_url = db_url.replace('mysql+pymysql', 'mysql+asyncmy')
elif 'mysql' in db_url:
async_db_url = db_url.replace('mysql', 'mysql+asyncmy')
else:
async_db_url = db_url
print(f"数据库URL: {async_db_url[:50]}...")
engine = create_async_engine(async_db_url, echo=False)
async with engine.begin() as conn:
try:
result = await conn.execute(text("SHOW COLUMNS FROM t_background LIKE 'cover_url'"))
row = result.fetchone()
if row:
print("[OK] column cover_url already exists")
else:
print("[INFO] column cover_url not found, adding it now")
await conn.execute(
text("ALTER TABLE t_background ADD COLUMN cover_url VARCHAR(512) DEFAULT NULL COMMENT '视频背景封面地址'")
)
print("[OK] added column cover_url")
except Exception as e:
error_msg = str(e)
if "Duplicate column" in error_msg or "already exists" in error_msg.lower():
print("[OK] column cover_url already exists, skipped")
else:
raise
await engine.dispose()
print("migration completed")
if __name__ == "__main__":
asyncio.run(migrate())

View File

@ -71,6 +71,7 @@ class Background(Base):
category = Column(String(50), default='default')
description = Column(String(255), nullable=True)
thumbnail = Column(String(512), nullable=True)
cover_url = Column(String(512), nullable=True)
device_type = Column(Integer, nullable=True, default=1) # 1: 电脑背景, 2: 手机背景
class ChatLog(Base):

View File

@ -15,6 +15,7 @@
:alt="background.name"
@error="handlePreviewError"
/>
<div class="video-badge" v-if="background.resource_type === 2">视频背景</div>
<div class="overlay" v-if="background.is_locked && !background.is_owned">
<el-icon><Lock /></el-icon>
<span>{{ background.price_points }} 积分</span>
@ -80,6 +81,9 @@ export default {
}
return background?.thumbnail || background?.resource_url || this.defaultThumbnail
},
getBackgroundPoster(background) {
return background?.thumbnail || this.defaultThumbnail
},
handlePreviewError(event) {
if (event?.target && event.target.src !== this.defaultThumbnail) {
event.target.src = this.defaultThumbnail
@ -135,6 +139,17 @@ export default {
overflow: hidden;
}
.video-badge {
position: absolute;
left: 8px;
top: 8px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 12px;
}
.background-image img {
width: 100%;
height: 100%;

View File

@ -64,7 +64,7 @@
<video
v-if="bg.type === 'video'"
:src="bg.resource_url"
:poster="bg.thumbnail || ''"
:poster="getBackgroundPoster(bg)"
:alt="bg.name"
class="card-video"
muted
@ -121,7 +121,7 @@
</div>
<!-- 上传对话框 -->
<el-dialog v-model="showUploadDialog" title="上传背景" width="600px">
<el-dialog v-model="showUploadDialog" title="上传背景" width="640px">
<el-form :model="uploadForm" label-width="100px">
<el-form-item label="名称">
<el-input v-model="uploadForm.name" placeholder="请输入背景名称" />
@ -130,11 +130,82 @@
<el-input v-model="uploadForm.description" type="textarea" />
</el-form-item>
<el-form-item label="类型">
<el-radio-group v-model="uploadForm.type">
<el-radio-group v-model="uploadForm.type" @change="handleUploadTypeChange">
<el-radio value="image">图片</el-radio>
<el-radio value="video">视频</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="一步式上传区">
<div class="one-step-upload-box" :class="uploadForm.type">
<div class="upload-column">
<div class="upload-section-title">1. 上传资源文件</div>
<el-upload
class="upload-demo resource-upload"
drag
:auto-upload="false"
:show-file-list="false"
:accept="uploadForm.type === 'video' ? 'video/*' : 'image/*'"
:on-change="handleFileChange"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">
{{ uploadForm.type === 'video' ? '视频' : '图片' }}拖到此处<em>点击上传</em>
</div>
</el-upload>
<div v-if="uploadForm.file" class="file-hint">
已选择{{ uploadForm.file.name }}
</div>
<div class="ratio-hint">
<span class="ratio-label">推荐比例</span>
<el-tag size="small" effect="plain">{{ getRatioLabel(uploadForm.video_ratio) }}</el-tag>
<span class="ratio-desc">封面比例建议与视频比例保持一致</span>
</div>
<el-radio-group v-if="uploadForm.type === 'video'" v-model="uploadForm.video_ratio" size="small" class="ratio-group">
<el-radio-button label="16:9">16:9</el-radio-button>
<el-radio-button label="9:16">9:16</el-radio-button>
<el-radio-button label="1:1">1:1</el-radio-button>
</el-radio-group>
</div>
<div v-if="uploadForm.type === 'video'" class="upload-column cover-column">
<div class="upload-section-title">2. 上传封面图</div>
<el-upload
class="upload-demo cover-upload"
drag
:auto-upload="false"
:show-file-list="false"
accept="image/*"
:on-change="handleCoverChange"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">
将封面图片拖到此处<em>点击上传</em>
</div>
</el-upload>
<div class="cover-meta-row">
<el-select v-model="uploadForm.cover_ratio" size="small" placeholder="封面比例" style="width: 160px">
<el-option label="16:9 横屏" value="16:9" />
<el-option label="9:16 竖屏" value="9:16" />
<el-option label="1:1 方图" value="1:1" />
</el-select>
<el-button v-if="uploadForm.cover_file" size="small" @click="clearCover">清除封面</el-button>
</div>
<div class="cover-preview-wrap">
<div class="cover-preview-title">封面预览</div>
<div class="cover-preview" :class="[getAspectClass(uploadForm.cover_ratio), { empty: !uploadForm.cover_url }]">
<img v-if="uploadForm.cover_url" :src="uploadForm.cover_url" alt="视频封面预览" />
<div v-else class="cover-preview-placeholder">
未选择封面时将使用视频自动生成封面
</div>
</div>
</div>
<div class="crop-tip">
<div class="crop-tip-title">裁剪提示</div>
<div class="crop-tip-text">建议主体居中避免复杂边框若图片过长可先在外部裁剪到对应比例再上传</div>
</div>
</div>
</div>
</el-form-item>
<el-form-item label="可见性">
<el-radio-group v-model="uploadForm.visibility">
<el-radio value="public">Public</el-radio>
@ -159,19 +230,6 @@
<el-form-item label="背景音乐">
<el-input v-model="uploadForm.bgm_url" placeholder="BGM URL (可选)" />
</el-form-item>
<el-form-item label="资源文件">
<el-upload
class="upload-demo"
drag
:auto-upload="false"
:on-change="handleFileChange"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">
将文件拖到此处<em>点击上传</em>
</div>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showUploadDialog = false">取消</el-button>
@ -222,9 +280,20 @@
<el-form-item label="资源地址">
<el-input v-model="editForm.resource_url" placeholder="请输入资源 URL" />
</el-form-item>
<el-form-item label="封面地址">
<el-input v-model="editForm.cover_url" placeholder="请输入视频封面 URL可选" />
</el-form-item>
<el-form-item label="缩略图">
<el-input v-model="editForm.thumbnail" placeholder="请输入缩略图 URL可选" />
</el-form-item>
<div class="cover-crop-hint">
<el-alert
title="封面建议使用居中构图,避免上下左右过多留白;若后续需要精细裁剪,可在此处接入在线裁剪器"
type="warning"
show-icon
:closable="false"
/>
</div>
</el-form>
<template #footer>
<el-button @click="showEditDialog = false">取消</el-button>
@ -316,7 +385,7 @@
<video
v-if="previewBackground?.type === 'video'"
:src="previewBackground?.resource_url"
:poster="previewBackground?.thumbnail || ''"
:poster="previewBackground?.thumbnail || previewBackground?.resource_url"
class="preview-video"
controls
autoplay
@ -410,6 +479,7 @@ export default {
category: bg.category || 'default',
device_type: bg.device_type || 1,
thumbnail: bg.thumbnail || (bg.resource_type === 2 ? '' : bg.resource_url) || '',
cover_url: bg.cover_url || bg.thumbnail || '',
bgm_url: bg.bgm_url || '',
created_at: bg.created_at,
user_ids: [],
@ -449,7 +519,11 @@ export default {
category: '',
device_type: 1, // 1: , 2:
bgm_url: '',
file: null
file: null,
cover_file: null,
cover_url: '',
video_ratio: '16:9',
cover_ratio: '16:9'
})
const editForm = reactive({
@ -461,7 +535,8 @@ export default {
category: '',
device_type: 1,
resource_url: '',
thumbnail: ''
thumbnail: '',
cover_url: ''
})
//
@ -542,6 +617,7 @@ export default {
editForm.device_type = bg.device_type || 1
editForm.resource_url = bg.resource_url || ''
editForm.thumbnail = bg.thumbnail || ''
editForm.cover_url = bg.cover_url || bg.thumbnail || ''
showEditDialog.value = true
}
@ -577,7 +653,8 @@ export default {
price_points: editForm.visibility === 'vip' ? 100 : 0,
category: editForm.category,
device_type: editForm.device_type,
thumbnail: editForm.thumbnail || editForm.resource_url
thumbnail: editForm.thumbnail || editForm.resource_url,
cover_url: editForm.cover_url || editForm.thumbnail || ''
}
const response = await adminBackgroundAPI.update(editForm.id, payload)
@ -887,6 +964,44 @@ export default {
uploadForm.file = file.raw
}
const handleCoverChange = (file) => {
uploadForm.cover_file = file.raw
uploadForm.cover_url = URL.createObjectURL(file.raw)
}
const clearCover = () => {
uploadForm.cover_file = null
uploadForm.cover_url = ''
}
const getRatioLabel = (ratio) => {
const map = {
'16:9': '16:9 横屏,适合电脑端',
'9:16': '9:16 竖屏,适合手机端',
'1:1': '1:1 方图,通用展示'
}
return map[ratio] || ratio
}
const getAspectClass = (ratio) => {
const map = {
'16:9': 'ratio-16-9',
'9:16': 'ratio-9-16',
'1:1': 'ratio-1-1'
}
return map[ratio] || 'ratio-16-9'
}
const handleUploadTypeChange = (type) => {
if (type !== 'video') {
clearCover()
}
}
const getBackgroundPoster = (bg) => {
return bg.cover_url || bg.thumbnail || bg.resource_url
}
//
const handleSizeChange = (size) => {
pagination.size = size
@ -908,7 +1023,6 @@ export default {
//
const uploadBackground = async () => {
try {
//
if (!uploadForm.name) {
ElMessage.error('请输入背景名称')
return
@ -921,67 +1035,70 @@ export default {
ElMessage.error('请选择分类')
return
}
if (uploadForm.type === 'video' && uploadForm.cover_file && !uploadForm.cover_file.type.startsWith('image/')) {
ElMessage.error('视频封面必须是图片文件')
return
}
uploading.value = true
//
const formData = new FormData()
formData.append('file', uploadForm.file)
const uploadResponse = await storageAPI.upload(formData)
const resourceFormData = new FormData()
resourceFormData.append('file', uploadForm.file)
const uploadResponse = await storageAPI.upload(resourceFormData)
if (!uploadResponse.data || !uploadResponse.data.success) {
throw new Error(uploadResponse.data?.message || '文件上传失败')
}
//
let coverUrl = uploadResponse.data?.thumbnail_url || ''
if (uploadForm.type === 'video' && uploadForm.cover_file) {
const coverFormData = new FormData()
coverFormData.append('file', uploadForm.cover_file)
const coverUploadResponse = await storageAPI.upload(coverFormData)
if (!coverUploadResponse.data || !coverUploadResponse.data.success) {
throw new Error(coverUploadResponse.data?.message || '封面上传失败')
}
coverUrl = coverUploadResponse.data?.file_url || coverUrl
}
const fileUrl = uploadResponse.data?.file_url
const thumbnailUrl = uploadResponse.data?.thumbnail_url || (uploadForm.type === 'video' ? '' : fileUrl)
const backgroundData = {
name: uploadForm.name,
description: uploadForm.description,
resource_url: fileUrl,
resource_type: uploadForm.type === 'video' ? 2 : 1, // 12
visibility: uploadForm.visibility === 'public' ? 1 : (uploadForm.visibility === 'vip' ? 2 : 0), // 0=private, 1=public, 2=vip
resource_type: uploadForm.type === 'video' ? 2 : 1,
visibility: uploadForm.visibility === 'public' ? 1 : (uploadForm.visibility === 'vip' ? 2 : 0),
is_locked: uploadForm.visibility !== 'public',
lock_desc: uploadForm.visibility === 'vip' ? 'VIP专享背景' : '',
price_points: uploadForm.visibility === 'vip' ? 100 : 0,
category: uploadForm.category,
device_type: uploadForm.device_type, // 1: , 2:
thumbnail: thumbnailUrl
device_type: uploadForm.device_type,
thumbnail: uploadForm.type === 'video' ? coverUrl : (uploadResponse.data?.thumbnail_url || fileUrl),
bgm_url: uploadForm.bgm_url || ''
}
try {
const createResponse = await adminBackgroundAPI.create(backgroundData)
const createResponse = await adminBackgroundAPI.create(backgroundData)
if (createResponse.data && createResponse.data.success) {
ElMessage.success('上传成功')
} else {
// 使
ElMessage.warning('文件上传成功,但数据库保存失败,请稍后手动刷新')
}
} catch (dbError) {
console.error('Database error:', dbError)
//
ElMessage.warning('文件上传成功,请手动刷新页面查看最新列表')
if (createResponse.data && createResponse.data.success) {
ElMessage.success('上传成功')
} else {
ElMessage.warning('文件上传成功,但数据库保存失败,请稍后手动刷新')
}
//
showUploadDialog.value = false
//
Object.assign(uploadForm, {
name: '',
description: '',
type: 'image',
visibility: 'public',
category: '',
device_type: 1, //
device_type: 1,
bgm_url: '',
file: null
file: null,
cover_file: null,
cover_url: ''
})
//
await loadBackgrounds()
} catch (error) {
console.error('Upload background error:', error)
@ -1039,7 +1156,14 @@ export default {
handleSizeChange,
handleSearchUser,
pagination,
previewBackground
previewBackground,
handleCoverChange,
handleUploadTypeChange,
clearCover,
getBackgroundPoster,
getRatioLabel,
getAspectClass,
editForm
}
}
}
@ -1068,6 +1192,72 @@ export default {
margin-bottom: 20px;
}
.resource-upload-panel {
width: 100%;
}
.upload-tip {
margin-top: 8px;
color: #909399;
font-size: 12px;
}
.cover-guidance {
display: flex;
flex-direction: column;
gap: 12px;
}
.cover-actions {
display: flex;
gap: 12px;
align-items: center;
}
.cover-preview-wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.cover-preview-title {
font-size: 13px;
color: #606266;
}
.cover-preview {
width: 260px;
aspect-ratio: 16 / 9;
border-radius: 10px;
overflow: hidden;
border: 1px dashed #dcdfe6;
background: #f5f7fa;
}
.cover-preview.empty {
display: flex;
align-items: center;
justify-content: center;
}
.cover-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-preview-placeholder {
padding: 16px;
text-align: center;
color: #909399;
font-size: 13px;
line-height: 1.6;
}
.cover-crop-hint {
margin: 8px 0 20px;
}
.background-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@ -1172,6 +1362,149 @@ export default {
align-items: center;
}
.one-step-upload-box {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
width: 100%;
}
.one-step-upload-box.video {
grid-template-columns: 1fr 1fr;
}
.upload-column {
padding: 16px;
border: 1px solid #ebeef5;
border-radius: 12px;
background: #fafcff;
}
.upload-section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
}
.resource-upload,
.cover-upload {
width: 100%;
}
.ratio-hint {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
color: #606266;
font-size: 13px;
}
.ratio-label {
font-weight: 600;
}
.ratio-desc {
color: #909399;
}
.ratio-group {
margin-top: 12px;
}
.cover-column {
display: flex;
flex-direction: column;
gap: 12px;
}
.cover-meta-row {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.cover-preview-wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.cover-preview-title {
font-size: 13px;
color: #606266;
}
.cover-preview {
position: relative;
width: 100%;
border-radius: 10px;
overflow: hidden;
border: 1px solid #dcdfe6;
background: #f5f7fa;
}
.cover-preview.ratio-16-9 {
aspect-ratio: 16 / 9;
}
.cover-preview.ratio-9-16 {
aspect-ratio: 9 / 16;
}
.cover-preview.ratio-1-1 {
aspect-ratio: 1 / 1;
}
.cover-preview.empty {
display: flex;
align-items: center;
justify-content: center;
}
.cover-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-preview-placeholder {
padding: 24px;
text-align: center;
color: #909399;
font-size: 13px;
line-height: 1.6;
}
.crop-tip {
padding: 12px;
border-radius: 10px;
background: #fff7e6;
border: 1px solid #ffd591;
}
.crop-tip-title {
font-size: 13px;
font-weight: 600;
color: #ad6800;
margin-bottom: 4px;
}
.crop-tip-text {
font-size: 12px;
color: #8c6d1f;
line-height: 1.6;
}
.file-hint {
margin-top: 8px;
color: #606266;
font-size: 13px;
}
.preview-image,
.preview-video {
max-width: 100%;