Files
ReelShortWeb/src/views/creator-center/composables/useDramaEdit.js
T

361 lines
11 KiB
JavaScript

// Author: fruit.com.cn
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
addEpisodeApi,
attachAssetApi,
deleteEpisodeApi,
getMyDramaApi,
listEpisodesApi,
listSubmissionsApi,
sortEpisodesApi,
submitDramaApi,
updateDramaApi,
updateEpisodeApi,
withdrawSubmissionApi
} 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'
/** 中断的上传会话按「作品 + 文件名 + 大小」记住,刷新后还能接着传 */
const RESUME_KEY = 'creator-upload-sessions'
function readSessions() {
try {
return JSON.parse(localStorage.getItem(RESUME_KEY) || '{}')
} catch {
return {}
}
}
function writeSession(key, value) {
const all = readSessions()
if (value) all[key] = value
else delete all[key]
localStorage.setItem(RESUME_KEY, JSON.stringify(all))
}
export function useDramaEdit() {
const { ensureAccess, impersonating } = useCreatorAccess()
const route = useRoute()
// 作品 id 是字符串(雪花 id 过 Number 会被四舍五入),按字符串比对
const dramaId = route.params.id ? String(route.params.id) : ''
const loading = ref(false)
const drama = ref(null)
const episodes = ref([])
const submissions = ref([])
const savingInfo = ref(false)
const infoForm = reactive({
title: '',
subtitle: '',
genre: '',
description: '',
cover: '',
trailerUrl: '',
language: 'zh-CN'
})
/** 只有草稿与已下线能改(与后端 requireEditable 一致),其余状态页面转只读 */
const editable = computed(() => {
// 代看只读:整页禁用,与后端 requireWritable 的边界保持一致
if (impersonating.value) return false
return drama.value?.status === 'draft' || drama.value?.status === 'offline'
})
const readonlyReason = computed(() => {
if (impersonating.value) return '代看模式只读:可以查看作品与分集,但不能编辑、上传或提交'
switch (drama.value?.status) {
case 'published':
return '已上线的作品只读,要改内容请先提交「申请下线」'
case 'reviewing':
return '提交正在审核中,撤回后即可继续编辑'
case 'approved':
return '作品已通过审核待发布,暂不可编辑'
default:
return ''
}
})
async function loadAll() {
loading.value = true
try {
const [detail, episodeList, submissionList] = await Promise.all([
getMyDramaApi(dramaId),
listEpisodesApi(dramaId),
listSubmissionsApi(dramaId)
])
drama.value = detail.data || null
episodes.value = episodeList.data || []
submissions.value = submissionList.data || []
Object.assign(infoForm, {
title: drama.value?.title || '',
subtitle: drama.value?.subtitle || '',
genre: drama.value?.genre || '',
description: drama.value?.description || '',
cover: drama.value?.cover || '',
trailerUrl: drama.value?.trailerUrl || '',
language: drama.value?.language || 'zh-CN'
})
} finally {
loading.value = false
}
}
async function loadEpisodes() {
episodes.value = (await listEpisodesApi(dramaId)).data || []
const detail = await getMyDramaApi(dramaId)
drama.value = detail.data || drama.value
}
async function saveInfo() {
if (!infoForm.title.trim()) return ElMessage.warning('请填写作品名称')
savingInfo.value = true
try {
await updateDramaApi(dramaId, {
title: infoForm.title.trim(),
subtitle: infoForm.subtitle,
genre: infoForm.genre,
description: infoForm.description,
cover: infoForm.cover,
trailerUrl: infoForm.trailerUrl,
language: infoForm.language
})
ElMessage.success('作品信息已保存')
await loadAll()
} finally {
savingInfo.value = false
}
}
// ---------- 分集 ----------
const episodeVisible = ref(false)
const episodeSaving = ref(false)
const episodeEditingId = ref(null)
const episodeForm = reactive({ title: '', durationSec: 0, cover: '', epNo: undefined })
function openEpisode(row) {
episodeEditingId.value = row?.id ?? null
Object.assign(episodeForm, {
title: row?.title ?? '',
durationSec: row?.durationSec ?? 0,
cover: row?.cover ?? '',
epNo: row?.number ?? undefined
})
episodeVisible.value = true
}
async function saveEpisode() {
episodeSaving.value = true
try {
if (episodeEditingId.value) {
await updateEpisodeApi(dramaId, episodeEditingId.value, {
title: episodeForm.title.trim(),
durationSec: episodeForm.durationSec || 0,
cover: episodeForm.cover,
epNo: episodeForm.epNo
})
} else {
// 新增不传集号:后端按当前最大集号往后排
await addEpisodeApi(dramaId, {
title: episodeForm.title.trim(),
durationSec: episodeForm.durationSec || 0,
cover: episodeForm.cover
})
}
ElMessage.success('分集已保存')
episodeVisible.value = false
await loadEpisodes()
} finally {
episodeSaving.value = false
}
}
async function removeEpisode(row) {
try {
await ElMessageBox.confirm(
`删除第 ${row.number} 集「${row.title || '未命名'}」?`,
'删除分集',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
}
)
} catch {
return
}
await deleteEpisodeApi(dramaId, row.id)
ElMessage.success('已删除')
await loadEpisodes()
}
/** 上移 / 下移:排序接口收的是"排好序的完整 id 列表",本地换位后整体提交 */
async function moveEpisode(row, offset) {
const list = [...episodes.value]
const index = list.findIndex((item) => item.id === row.id)
const target = index + offset
if (index < 0 || target < 0 || target >= list.length) return
const [moved] = list.splice(index, 1)
list.splice(target, 0, moved)
episodes.value = list
await sortEpisodesApi(
dramaId,
list.map((item) => item.id)
)
ElMessage.success('顺序已调整')
await loadEpisodes()
}
// ---------- 上传(复用分片上传,支持断点续传) ----------
const uploadingId = ref(null)
const uploadPercent = ref(0)
const resumeHint = ref('')
function sessionKey(file) {
return `${dramaId}:${file.name}:${file.size}`
}
function findResumeSession(file) {
return readSessions()[sessionKey(file)]?.uploadId || ''
}
function hasResumeSession(file) {
return Boolean(findResumeSession(file))
}
/** 上传视频并挂到该分集:有中断会话就接着传,否则新起一个会话 */
async function uploadVideo(row, file) {
uploadingId.value = row.id
uploadPercent.value = 0
const key = sessionKey(file)
const uploadId = findResumeSession(file)
try {
const durationSec = await probeVideoDuration(file)
const asset = uploadId
? await resumeUpload(file, uploadId, {
durationSec,
onProgress: (percent) => (uploadPercent.value = percent)
})
: await uploadFile(file, {
bizType: 'EPISODE',
durationSec,
onProgress: (percent) => (uploadPercent.value = percent),
// 会话一建立就记下来:中途断网 / 关页面后还能接着传
onSession: (id) => writeSession(key, { uploadId: id })
})
writeSession(key, null)
await attachAssetApi(dramaId, row.id, asset.id)
resumeHint.value = ''
ElMessage.success('视频已上传并挂到该分集')
await loadEpisodes()
} catch {
resumeHint.value = `「${file.name}」上传中断,进度已记住;重新选择同一个文件即可续传`
} finally {
uploadingId.value = null
uploadPercent.value = 0
}
}
/** 上传封面(作品封面或分集封面):图片走同一个分片上传接口,bizType=DRAMA_COVER */
async function uploadCover(row, file) {
uploadingId.value = row ? row.id : -1
uploadPercent.value = 0
try {
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)
})
if (row) {
await updateEpisodeApi(dramaId, row.id, { cover: asset.url })
ElMessage.success('分集封面已更新')
await loadEpisodes()
} else {
infoForm.cover = asset.url
ElMessage.success('封面已上传,保存作品信息后生效')
}
} finally {
uploadingId.value = null
uploadPercent.value = 0
}
}
// ---------- 提交审核 ----------
async function submit(type) {
if (type === 'OFFLINE') {
try {
await ElMessageBox.confirm(
'申请下线后由后台审核,通过后内容才下线。确认提交?',
'申请下线',
{
confirmButtonText: '确认提交',
cancelButtonText: '取消',
type: 'warning'
}
)
} catch {
return
}
}
await submitDramaApi(dramaId, type)
ElMessage.success('已提交,等待后台审核')
await loadAll()
}
async function withdraw(submissionId) {
await withdrawSubmissionApi(submissionId)
ElMessage.success('已撤回,作品回到草稿')
await loadAll()
}
onMounted(async () => {
if (!(await ensureAccess())) return
await loadAll()
})
return {
dramaId,
loading,
drama,
episodes,
submissions,
editable,
readonlyReason,
infoForm,
savingInfo,
episodeVisible,
episodeSaving,
episodeEditingId,
episodeForm,
uploadingId,
uploadPercent,
resumeHint,
submitOptions,
submissionStatusMeta,
submissionTypeText,
dateCell,
loadAll,
saveInfo,
openEpisode,
saveEpisode,
removeEpisode,
moveEpisode,
uploadVideo,
uploadCover,
hasResumeSession,
submit,
withdraw
}
}