diff --git a/src/api/admin/creatorCenter.ts b/src/api/admin/creatorCenter.ts index 3dc2513..d57ae23 100644 --- a/src/api/admin/creatorCenter.ts +++ b/src/api/admin/creatorCenter.ts @@ -109,6 +109,35 @@ export interface CreatorDramaEditData { language?: string } +/** 一键发布的分集入参:assetId 来自已完成的分片上传 */ +export interface PublishEpisodeItem { + assetId: number + title?: string + durationSec?: number + cover?: string +} + +export interface PublishDramaPayload { + title: string + subtitle?: string + /** 短剧分类 id(下拉选择) */ + categoryId?: number + /** 题材标签(自由文本,可选) */ + genre?: string + description?: string + cover?: string + language?: string + /** 默认 true:创建后立即提交审核 */ + submit?: boolean + episodes: PublishEpisodeItem[] +} + +export interface PublishDramaResult { + dramaId: number + submissionId?: number | null + status: string +} + export interface PriceEpisodeConfig { episodeId: number number: number @@ -176,6 +205,11 @@ export const createDramaApi = (data: CreatorDramaEditData) => { return request.post({ url: '/admin/creator-center/dramas', data }) } +/** 一键发布:一个事务里建作品 + 建分集 + 绑定视频(可选提交审核) */ +export const publishDramaApi = (data: PublishDramaPayload) => { + return request.post({ url: '/admin/creator-center/publish', data }) +} + export const updateDramaApi = (id: number, data: CreatorDramaEditData) => { return request.put({ url: `/admin/creator-center/dramas/${id}`, data }) } @@ -205,6 +239,11 @@ export const deleteEpisodeApi = (dramaId: number, episodeId: number) => { }) } +/** 删除作品(草稿 / 已下线 / 审核中可删,已上线需先下线) */ +export const deleteDramaApi = (dramaId: number) => { + return request.delete({ url: `/admin/creator-center/dramas/${dramaId}` }) +} + export const sortEpisodesApi = (dramaId: number, ids: number[]) => { return request.put({ url: `/admin/creator-center/dramas/${dramaId}/episodes/sort`, diff --git a/src/styles/business/creator-center.css b/src/styles/business/creator-center.css index 720c7bf..fc10b0a 100644 --- a/src/styles/business/creator-center.css +++ b/src/styles/business/creator-center.css @@ -298,6 +298,11 @@ html.dark .creator-guide-title { margin-bottom: 14px; } +.creator-switch-hint { + margin-left: 10px; + font-size: 12px; +} + html.dark .creator-profile-name, html.dark .creator-profile-meta b, html.dark .creator-drama-title, @@ -316,3 +321,144 @@ html.dark .creator-episode-cover { border-color: var(--el-border-color-light); background: var(--el-fill-color-light); } + +/* ---------- 移动端(创作者多手机上传) ---------- */ +.creator-mobile-only { + display: none; +} + +/* 作品表格:列宽合计超过容器时由外层横向滚动, + 不依赖 el-table 内部的 scrollbar(其 header/body 会被各自裁切)。 */ +.creator-desktop-only { + overflow-x: auto; +} + +.creator-desktop-only .el-table.drama-el-table { + min-width: 1150px; +} + +/* 作品编辑页:分集 / 提交记录表格由外层容器横向滚动(宽度见各表格 inline min-width) */ +.creator-table-scroll { + overflow-x: auto; +} + +/* 操作列按钮放不下时换行,避免「删除」被表格裁掉 */ +.creator-table-scroll .table-actions { + flex-wrap: wrap; + white-space: normal; + row-gap: 6px; +} + +.creator-video-preview { + display: block; + width: 100%; + max-height: 70vh; + background: #000; +} + +@media (width <= 768px) { + .creator-desktop-only { + display: none; + } + + .creator-mobile-only { + display: block; + } + + .creator-profile-meta { + flex-wrap: wrap; + gap: 8px 16px; + } + + .console-toolbar { + flex-wrap: wrap; + } + + .console-toolbar .console-keyword, + .console-toolbar .console-select { + width: 100%; + } + + .creator-drama-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .creator-drama-card { + display: flex; + gap: 12px; + padding: 12px; + border: 1px solid #e6e8eb; + border-radius: var(--app-radius-md); + background: #fff; + } + + .creator-drama-card-cover { + display: grid; + flex-shrink: 0; + place-content: center; + width: 60px; + height: 80px; + overflow: hidden; + color: #9a97a8; + font-size: 16px; + background: #eef0f5; + border-radius: var(--app-radius-sm); + } + + .creator-drama-card-cover img, + .creator-drama-card-cover .creator-drama-image { + width: 100%; + height: 100%; + object-fit: cover; + } + + .creator-drama-card-body { + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + .creator-drama-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .creator-drama-card-head .creator-drama-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .creator-drama-card-meta { + display: flex; + gap: 12px; + color: #727983; + font-size: 12px; + } + + .creator-drama-card-actions { + display: flex; + gap: 8px; + margin-top: 6px; + } + + .pagination { + justify-content: center; + } + + html.dark .creator-drama-card { + border-color: var(--el-border-color-light); + background: var(--el-bg-color-overlay); + } + + html.dark .creator-drama-card-cover { + background: var(--el-fill-color-light); + } +} + diff --git a/src/utils/videoCover.ts b/src/utils/videoCover.ts new file mode 100644 index 0000000..bf2325e --- /dev/null +++ b/src/utils/videoCover.ts @@ -0,0 +1,78 @@ +// Author: fruit.com.cn +/** 从本地视频文件截取封面(3:4 居中裁切),抖音式自动封面用 */ + +export interface CapturedCover { + file: File + previewUrl: string +} + +function loadVideo(file: File): Promise { + return new Promise((resolve, reject) => { + const video = document.createElement('video') + video.preload = 'metadata' + video.muted = true + video.playsInline = true + const src = URL.createObjectURL(file) + const fail = (message: string) => { + URL.revokeObjectURL(src) + reject(new Error(message)) + } + video.onloadedmetadata = () => { + URL.revokeObjectURL(src) + resolve(video) + } + video.onerror = () => fail('当前浏览器无法解析该视频格式') + video.src = src + }) +} + +/** 截取第 atSeconds 秒画面,按 3:4 居中裁切输出 720×960 JPEG */ +export async function captureVideoCover(file: File, atSeconds = 1): Promise { + const video = await loadVideo(file) + const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0 + // 太短的视频从开头截,正常视频取第 1 秒,避免黑场 + const target = duration ? Math.min(atSeconds, Math.max(0.1, duration * 0.2)) : 0 + await new Promise((resolve, reject) => { + const timer = window.setTimeout(() => reject(new Error('截取封面超时')), 8000) + video.onseeked = () => { + window.clearTimeout(timer) + resolve() + } + video.currentTime = target + }) + + const width = video.videoWidth + const height = video.videoHeight + if (!width || !height) throw new Error('无法读取视频尺寸') + + const ratio = 3 / 4 + let sw = width + let sh = height + if (width / height > ratio) { + sw = height * ratio + } else { + sh = width / ratio + } + const sx = (width - sw) / 2 + const sy = (height - sh) / 2 + + const canvas = document.createElement('canvas') + canvas.width = 720 + canvas.height = 960 + const context = canvas.getContext('2d') + if (!context) throw new Error('当前浏览器不支持 canvas') + context.drawImage(video, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height) + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (result) => (result ? resolve(result) : reject(new Error('封面生成失败'))), + 'image/jpeg', + 0.85 + ) + }) + const baseName = file.name.replace(/\.[^.]+$/, '') || 'cover' + return { + file: new File([blob], `${baseName}-cover.jpg`, { type: 'image/jpeg' }), + previewUrl: URL.createObjectURL(blob) + } +} diff --git a/src/views/creator-center/PublishView.vue b/src/views/creator-center/PublishView.vue new file mode 100644 index 0000000..bab8949 --- /dev/null +++ b/src/views/creator-center/PublishView.vue @@ -0,0 +1,1072 @@ + + + + + + + diff --git a/src/views/creator-center/WorkspaceView.vue b/src/views/creator-center/WorkspaceView.vue index c37852a..2b19185 100644 --- a/src/views/creator-center/WorkspaceView.vue +++ b/src/views/creator-center/WorkspaceView.vue @@ -30,16 +30,12 @@ const { profileLoading, dramas, notices, - createVisible, - createSaving, - createForm, queryDramas, loadDramas, loadNotices, submit, withdraw, - openCreate, - confirmCreate + remove } = useCreatorWorkspace() const activeTab = ref('dramas') @@ -59,6 +55,11 @@ function onTabChange(name) { function openEdit(row) { router.push(`/creator-center/dramas/${row.id}`) } + +/** 抖音式发布:直接进发布页选视频,不再先建空草稿 */ +function openPublish() { + router.push('/creator-center/publish') +} diff --git a/src/views/creator-center/composables/useCreatorWorkspace.js b/src/views/creator-center/composables/useCreatorWorkspace.js index 88f612c..5c73ebd 100644 --- a/src/views/creator-center/composables/useCreatorWorkspace.js +++ b/src/views/creator-center/composables/useCreatorWorkspace.js @@ -4,6 +4,7 @@ import { onMounted, reactive, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { createDramaApi, + deleteDramaApi, getCreatorProfileApi, listCreatorNoticesApi, pageMyDramasApi, @@ -168,6 +169,32 @@ export function useCreatorWorkspace() { await loadDramas() } + /** 删作品。已上线的后端会拦,前端按状态给提示。 */ + async function remove(row) { + const label = row.title || '该作品' + try { + await ElMessageBox.confirm( + `《${label}》将被永久删除(分集、封面、审核记录一并清理),且不可恢复。确认删除?`, + '删除作品', + { + confirmButtonText: '确认删除', + cancelButtonText: '取消', + type: 'warning', + confirmButtonClass: 'el-button--danger' + } + ) + } catch { + return + } + try { + await deleteDramaApi(row.id) + ElMessage.success('作品已删除') + await loadDramas() + } catch (e) { + // 后端可能抛"已上线,请先下线"之类的业务错误;ElMessage 全局拦截器会弹 message + } + } + const createVisible = ref(false) const createSaving = ref(false) const createForm = reactive({ title: '', genre: '', description: '' }) @@ -216,6 +243,7 @@ export function useCreatorWorkspace() { loadNotices, submit, withdraw, + remove, openCreate, confirmCreate }