feat(web): 07 创作者中心四个页面(我的作品 / 作品编辑 / 收费配置 / 数据看板)
为什么:07 的接口五批都做完了,但创作者在界面上一个都点不到 —— 只能靠 curl。 这批把创作者中心做进后台管理(沿用 AppUsersView 那套 api 封装 + composable + 视图 + 样式与 console.css), 创作者用自己的后台账号登录就能完成"建草稿 → 编辑分集传片 → 提交审核 → 配置收费 → 看数据"的闭环。 - WorkspaceView:主体状态 / 我的作品(含最近一次提交的审核进度)/ 通知列表;新建草稿、按状态给出可提交的动作、撤回 - DramaEditView:作品信息(封面、预告片)、分集增删与上移下移、分片上传(复用 /admin/media)+ 断点续传 (会话存 localStorage,中断后「继续上传」接着传)、产物挂载、提交记录与撤回;非草稿/已下线状态整页转只读 - PriceConfigView:整剧免费集数与解锁金币 + 逐集免费/收费/试看秒数;已上线只读(需先申请下线) - DashboardView:作品 / 播放 / 互动 / 解锁收入四组指标,取不到的显示「暂无数据源」+ notes 口径说明 - 时间列统一 yyyy-MM-dd HH:mm:ss;表格列与后端字段一一对应,不在前端编字段 - media.ts 补上传进度接口;uploader 补 resumeUpload(先查服务端进度再续传)
This commit is contained in:
1 parent
f037d243cd
commit
0a2e3e0b81
13 files changed
+2113
-1
No files matched your search
@@ -0,0 +1,342 @@
|
||||
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 { submissionStatusMeta, submissionTypeText, submitOptions } from './useCreatorWorkspace'
|
||||
|
||||
/** 中断的上传会话按「作品 + 文件名 + 大小」记住,刷新后还能接着传 */
|
||||
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 route = useRoute()
|
||||
const dramaId = Number(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(
|
||||
() => drama.value?.status === 'draft' || drama.value?.status === 'offline'
|
||||
)
|
||||
const readonlyReason = computed(() => {
|
||||
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 asset = await uploadFile(file, {
|
||||
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(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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user