// 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 } }