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:
Vicky committed 2026-10-15 14:47:28 +08:00
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
}
}