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

+224
View File
@@ -0,0 +1,224 @@
import request from '@/axios'
import type { PageResult } from './types'
/** 创作者主体:登录账号绑定的创作者信息 */
export interface CreatorProfile {
id: number
code: string
name: string
/** INDIVIDUAL / COMPANY */
type: string
/** pending / approved / rejected / frozen */
status: string
statusText: string
auditNote: string
auditAt?: string
contactName: string
contactEmail: string
countryCode: string
dramaTotal: number
}
/** 我的作品 + 最近一次提交的审核进度 */
export interface CreatorDrama {
id: number
code: string
title: string
subtitle?: string
genre?: string
description?: string
cover: string
trailerUrl?: string
language?: string
status: string
statusText: string
episodes: number
views: number
updatedAt?: string
submissionId?: number | null
submissionType?: string | null
submissionStatus?: string | null
submissionStatusText?: string | null
reviewNote?: string | null
rejectCode?: string | null
submitAt?: string | null
reviewAt?: string | null
}
export interface CreatorNotice {
id: number
/** CREATOR(入驻审核)/ SUBMISSION(内容提交审核) */
bizType: string
title: string
content: string
result: string
time?: string
}
export interface CreatorSubmission {
id: number
creatorId: number
dramaId: number
/** CREATE / UPDATE / PUBLISH / OFFLINE */
type: string
/** DRAFT / SUBMITTED / REVIEWING / APPROVED / REJECTED / WITHDRAWN */
status: string
version: number
submitAt?: string
reviewAt?: string
reviewNote: string
rejectCode: string
}
export interface CreatorEpisode {
id: number
dramaId: number
number: number
title: string
duration: string
durationSec: number
cover: string
videoUrl: string
isFree: boolean
unlockCoin: number
status: string
statusText: string
views: number
sort: number
}
export interface CreatorDramaEditData {
title: string
subtitle?: string
categoryId?: number
genre?: string
description?: string
cover?: string
trailerUrl?: string
language?: string
}
export interface PriceEpisodeConfig {
episodeId: number
number: number
title: string
free: boolean
unlockCoin: number
previewSeconds: number
}
export interface PriceConfig {
dramaId: number
freeEpisodes: number
unlockCoin: number
episodeCount: number
changedCount: number
episodes: PriceEpisodeConfig[]
}
export interface CreatorDashboard {
generatedAt: string
drama: Record<string, number>
playback: Record<string, number>
interaction: Record<string, number>
/** paidOrderAmount 取不到时为 null */
revenue: Record<string, number | null>
/** 按天趋势:creator_stat_daily 无产出,恒为 null */
daily: null
notes: string[]
}
export const getCreatorProfileApi = () => {
return request.get<CreatorProfile>({ url: '/admin/creator-center/profile' })
}
export const pageMyDramasApi = (params: Record<string, unknown>) => {
return request.get<PageResult<CreatorDrama>>({ url: '/admin/creator-center/dramas/page', params })
}
export const getMyDramaApi = (id: number) => {
return request.get<CreatorDrama>({ url: `/admin/creator-center/dramas/${id}` })
}
export const listCreatorNoticesApi = () => {
return request.get<CreatorNotice[]>({ url: '/admin/creator-center/notices' })
}
export const getCreatorDashboardApi = () => {
return request.get<CreatorDashboard>({ url: '/admin/creator-center/dashboard' })
}
export const createDramaApi = (data: CreatorDramaEditData) => {
return request.post<number>({ url: '/admin/creator-center/dramas', data })
}
export const updateDramaApi = (id: number, data: CreatorDramaEditData) => {
return request.put<void>({ url: `/admin/creator-center/dramas/${id}`, data })
}
export const listEpisodesApi = (dramaId: number) => {
return request.get<CreatorEpisode[]>({ url: `/admin/creator-center/dramas/${dramaId}/episodes` })
}
export const addEpisodeApi = (dramaId: number, data: Record<string, unknown>) => {
return request.post<number>({ url: `/admin/creator-center/dramas/${dramaId}/episodes`, data })
}
export const updateEpisodeApi = (
dramaId: number,
episodeId: number,
data: Record<string, unknown>
) => {
return request.put<void>({
url: `/admin/creator-center/dramas/${dramaId}/episodes/${episodeId}`,
data
})
}
export const deleteEpisodeApi = (dramaId: number, episodeId: number) => {
return request.delete<void>({
url: `/admin/creator-center/dramas/${dramaId}/episodes/${episodeId}`
})
}
export const sortEpisodesApi = (dramaId: number, ids: number[]) => {
return request.put<void>({
url: `/admin/creator-center/dramas/${dramaId}/episodes/sort`,
data: { ids }
})
}
/** 把上传好的媒资挂到分集(产物必须是本人上传的) */
export const attachAssetApi = (dramaId: number, episodeId: number, assetId: number) => {
return request.post<void>({
url: `/admin/creator-center/dramas/${dramaId}/episodes/${episodeId}/attach`,
data: { assetId }
})
}
export const getPricesApi = (dramaId: number) => {
return request.get<PriceConfig>({ url: `/admin/creator-center/dramas/${dramaId}/prices` })
}
export const savePricesApi = (dramaId: number, data: Record<string, unknown>) => {
return request.put<PriceConfig>({ url: `/admin/creator-center/dramas/${dramaId}/prices`, data })
}
export const submitDramaApi = (dramaId: number, type: string) => {
return request.post<CreatorSubmission>({
url: `/admin/creator-center/dramas/${dramaId}/submit`,
data: { type }
})
}
export const withdrawSubmissionApi = (submissionId: number) => {
return request.post<CreatorSubmission>({
url: `/admin/creator-center/submissions/${submissionId}/withdraw`
})
}
export const listSubmissionsApi = (dramaId: number) => {
return request.get<CreatorSubmission[]>({
url: `/admin/creator-center/dramas/${dramaId}/submissions`
})
}
+20
View File
@@ -36,6 +36,26 @@ export const completeUploadApi = (uploadId: string, durationSec?: number) => {
})
}
export interface UploadProgress {
uploadId: string
fileName: string
bizType: string
fileSize: number
chunkSize: number
totalChunks: number
receivedChunks: number
percent: number
/** uploading / completed */
status: string
assetId: number
url: string
}
/** 断点续传回显:传到第几片、还剩多少(服务端说了算,客户端不自己记账) */
export const getUploadProgressApi = (uploadId: string) => {
return request.get<UploadProgress>({ url: `/admin/media/upload/${uploadId}` })
}
export const getMediaApi = (id: number) => {
return request.get<MediaAsset>({ url: `/admin/media/${id}` })
}
+237
View File
@@ -0,0 +1,237 @@
/**
* 创作者中心(我的作品 / 作品编辑 / 收费配置 / 数据看板)。
* 只放这四个页面专属的类名;工具条、抽屉、看板卡片沿用 console.css。
*/
.creator-profile-main {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.creator-profile-name {
color: #302d44;
font-size: 16px;
font-weight: 650;
}
.creator-profile-meta {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
margin-top: 10px;
color: #858b95;
font-size: 13px;
}
.creator-profile-meta b {
color: #302d44;
font-weight: 600;
}
.creator-profile-alert,
.creator-edit-alert {
margin-top: 12px;
}
/* 作品单元格:封面固定尺寸,标题块 min-width:0 才能让省略号生效 */
.creator-drama-cell {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.creator-drama-cover {
display: grid;
place-content: center;
flex: 0 0 34px;
width: 34px;
height: 46px;
overflow: hidden;
border-radius: 4px;
background: #eef0f5;
color: #9a97a8;
font-size: 14px;
font-weight: 600;
}
.creator-drama-cover img,
.creator-cover-preview img,
.creator-episode-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.creator-drama-meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.creator-drama-title,
.creator-drama-code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.creator-drama-title {
color: #302d44;
font-size: 13px;
font-weight: 600;
}
.creator-drama-code {
color: #9a97a8;
font-size: 12px;
}
.creator-progress {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.creator-progress .console-muted {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 通知列表 */
.creator-notice-list {
min-height: 120px;
}
.creator-notice {
padding: 12px 0;
border-bottom: 1px solid #f0eef4;
}
.creator-notice:last-child {
border-bottom: none;
}
.creator-notice-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.creator-notice-title {
color: #302d44;
font-size: 13px;
font-weight: 600;
}
.creator-notice-body {
margin: 6px 0 0;
color: #727983;
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
/* 作品编辑页 */
.creator-edit-head {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.creator-edit-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
flex-wrap: wrap;
}
.creator-section-title {
margin: 0 0 12px;
color: #302d44;
font-size: 14px;
font-weight: 650;
}
.creator-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.creator-section-head .creator-section-title {
margin: 0;
}
.creator-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0 16px;
}
.creator-cover-row {
display: flex;
align-items: center;
gap: 10px;
}
.creator-cover-preview {
display: grid;
place-content: center;
width: 56px;
height: 76px;
overflow: hidden;
border: 1px solid #e6e8eb;
border-radius: 4px;
background: #fafbfc;
color: #9a97a8;
font-size: 12px;
}
.creator-episode-cover {
display: grid;
place-content: center;
width: 48px;
height: 30px;
overflow: hidden;
border-radius: 3px;
background: #eef0f5;
color: #9a97a8;
font-size: 12px;
}
.creator-upload-hint {
margin: 10px 0 0;
color: #727983;
font-size: 13px;
}
html.dark .creator-profile-name,
html.dark .creator-profile-meta b,
html.dark .creator-drama-title,
html.dark .creator-notice-title,
html.dark .creator-section-title {
color: var(--el-text-color-primary);
}
html.dark .creator-notice {
border-bottom-color: var(--el-border-color-light);
}
html.dark .creator-cover-preview,
html.dark .creator-drama-cover,
html.dark .creator-episode-cover {
border-color: var(--el-border-color-light);
background: var(--el-fill-color-light);
}
+1
View File
@@ -6,5 +6,6 @@
@import './operation-log.css';
@import './app-user-management.css';
@import './console.css';
@import './creator-center.css';
@import './layout.css';
@import './dark.css';
+51 -1
View File
@@ -1,4 +1,9 @@
import { completeUploadApi, initUploadApi, uploadChunkApi } from '@/api/admin/media'
import {
completeUploadApi,
getUploadProgressApi,
initUploadApi,
uploadChunkApi
} from '@/api/admin/media'
import type { MediaAsset } from '@/api/admin/types'
export interface UploadFileOptions {
@@ -9,6 +14,8 @@ export interface UploadFileOptions {
/** 视频时长(秒),由调用方探测后传入 */
durationSec?: number
onProgress?: (percent: number) => void
/** init 建好会话就回调一次:调用方可以把它记下来,失败/刷新后走 resumeUpload 续传 */
onSession?: (uploadId: string) => void
}
/**
@@ -23,6 +30,7 @@ export async function uploadFile(file: File, options: UploadFileOptions = {}): P
bizType
})
const info = initRes.data
options.onSession?.(info.uploadId)
const total = Math.max(info.totalChunks, 1)
const pending: number[] = Array.from({ length: total }, (_, index) => index)
let finished = 0
@@ -45,6 +53,48 @@ export async function uploadFile(file: File, options: UploadFileOptions = {}): P
return doneRes.data
}
/**
* 断点续传:先问服务端传到第几片,从断点接着传,再合并。
*
* 服务端只记账"已收到多少片",不回传具体是哪几片,所以这里按"已收片数之后的分片全部重传"处理 ——
* 重传同一片是幂等覆盖,不会产生脏数据;片数落后于实际时最多多传几片。
*/
export async function resumeUpload(
file: File,
uploadId: string,
options: UploadFileOptions = {}
): Promise<MediaAsset> {
const { concurrency = 3, onProgress } = options
const progress = (await getUploadProgressApi(uploadId)).data
const total = Math.max(progress.totalChunks, 1)
const received = Math.min(progress.receivedChunks || 0, total)
const pending: number[] = []
for (let index = received; index < total; index += 1) {
pending.push(index)
}
let finished = received
onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
const worker = async () => {
for (;;) {
const index = pending.shift()
if (index === undefined) return
const start = index * progress.chunkSize
const chunk = file.slice(start, Math.min(start + progress.chunkSize, file.size))
await uploadChunkApi(uploadId, index, chunk, file.name)
finished += 1
onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, Math.max(pending.length, 1)) }, worker)
)
const doneRes = await completeUploadApi(uploadId, options.durationSec)
onProgress?.(100)
return doneRes.data
}
/** 读取本地视频时长,取不到时返回 0。 */
export function probeVideoDuration(file: File): Promise<number> {
return new Promise((resolve) => {
@@ -0,0 +1,46 @@
<script setup>
import PageHeading from '@/views/components/PageHeading.vue'
import { DASHBOARD_GROUPS, useCreatorDashboard } from './composables/useCreatorDashboard'
const { loading, board, lastUpdated, load, display, groupValue } = useCreatorDashboard()
</script>
<template>
<PageHeading
eyebrow="创作者中心"
title="数据看板"
description="我的作品在播放、互动与解锁收入上的表现。"
>
<el-button :loading="loading" @click="load">刷新</el-button>
</PageHeading>
<section v-loading="loading" class="panel">
<p class="console-hint">数据时间:{{ lastUpdated }}(统计范围仅限本人作品)</p>
<div v-for="group in DASHBOARD_GROUPS" :key="group.key" class="board-group">
<h3 class="board-title">{{ group.title }}</h3>
<div class="board-grid">
<div v-for="metric in group.metrics" :key="metric.key" class="board-card">
<span class="board-label">{{ metric.label }}</span>
<span
class="board-value"
:class="{ 'board-value-empty': groupValue(group.key, metric.key) === null }"
>
{{ display(groupValue(group.key, metric.key)) }}
</span>
</div>
</div>
</div>
<el-alert
v-if="board?.notes?.length"
class="board-notes"
type="info"
:closable="false"
show-icon
title="口径与缺失数据源说明"
>
<ul>
<li v-for="note in board.notes" :key="note">{{ note }}</li>
</ul>
</el-alert>
</section>
</template>
+354
View File
@@ -0,0 +1,354 @@
<script setup>
import { ArrowLeft, Upload } from '@element-plus/icons-vue'
import PageHeading from '@/views/components/PageHeading.vue'
import { useRbac } from '@/stores/rbac'
import { useDramaEdit } from './composables/useDramaEdit'
import { dramaStatusMeta } from './composables/useCreatorWorkspace'
const { can } = useRbac()
const canEdit = can('creator-center:drama:edit')
const canUpload = can('creator-center:upload')
const {
loading,
drama,
episodes,
submissions,
editable,
readonlyReason,
infoForm,
savingInfo,
episodeVisible,
episodeSaving,
episodeEditingId,
episodeForm,
uploadingId,
uploadPercent,
resumeHint,
submitOptions,
submissionStatusMeta,
submissionTypeText,
dateCell,
saveInfo,
openEpisode,
saveEpisode,
removeEpisode,
moveEpisode,
uploadVideo,
uploadCover,
hasResumeSession,
submit,
withdraw
} = useDramaEdit()
function back() {
window.history.back()
}
/** el-upload 的 http-request 回调:拿到原始 File 后交给统一的(可续传)上传逻辑 */
function requestVideo(row, options) {
return uploadVideo(row, options.file)
}
function requestEpisodeCover(row, options) {
return uploadCover(row, options.file)
}
function requestDramaCover(options) {
return uploadCover(null, options.file)
}
</script>
<template>
<PageHeading
eyebrow="创作者中心"
title="作品编辑"
:description="drama ? `${drama.title}(${drama.code})` : ''"
>
<el-button :icon="ArrowLeft" @click="back">返回</el-button>
</PageHeading>
<section v-loading="loading" class="panel">
<div class="creator-edit-head">
<el-tag :type="dramaStatusMeta(drama?.status).tag" effect="light">
{{ dramaStatusMeta(drama?.status).label }}
</el-tag>
<span class="console-muted"
>分集 {{ drama?.episodes ?? 0 }} 集 · 播放 {{ drama?.views ?? 0 }}</span
>
<div class="creator-edit-actions">
<el-button
v-for="type in canEdit ? submitOptions(drama || {}) : []"
:key="type"
type="primary"
plain
size="small"
@click="submit(type)"
>{{ submissionTypeText(type) }}</el-button
>
</div>
</div>
<el-alert
v-if="readonlyReason"
class="creator-edit-alert"
type="warning"
:closable="false"
show-icon
:title="readonlyReason"
/>
</section>
<section class="panel">
<h3 class="creator-section-title">作品信息</h3>
<el-form class="creator-form" label-position="top" :disabled="!editable">
<div class="creator-form-grid">
<el-form-item label="作品名称" required>
<el-input v-model="infoForm.title" maxlength="64" />
</el-form-item>
<el-form-item label="副标题">
<el-input v-model="infoForm.subtitle" maxlength="64" />
</el-form-item>
<el-form-item label="题材">
<el-input v-model="infoForm.genre" maxlength="32" placeholder="例如:都市情感" />
</el-form-item>
<el-form-item label="语言">
<el-input v-model="infoForm.language" maxlength="16" placeholder="zh-CN / id-ID" />
</el-form-item>
</div>
<el-form-item label="简介">
<el-input
v-model="infoForm.description"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
/>
</el-form-item>
<div class="creator-form-grid">
<el-form-item label="作品封面">
<div class="creator-cover-row">
<div class="creator-cover-preview">
<img v-if="infoForm.cover" :src="infoForm.cover" alt="" />
<span v-else>无</span>
</div>
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="image/*"
:http-request="requestDramaCover"
:disabled="uploadingId !== null"
>
<el-button size="small" :icon="Upload">上传封面</el-button>
</el-upload>
</div>
</el-form-item>
<el-form-item label="预告片地址">
<el-input
v-model="infoForm.trailerUrl"
maxlength="512"
placeholder="整剧一支预告片,可留空"
/>
</el-form-item>
</div>
<el-button
v-if="canEdit"
type="primary"
:loading="savingInfo"
:disabled="!editable"
@click="saveInfo"
>保存作品信息</el-button
>
</el-form>
</section>
<section class="panel">
<div class="creator-section-head">
<h3 class="creator-section-title">分集管理</h3>
<el-button
v-if="canEdit"
type="primary"
size="small"
:disabled="!editable"
@click="openEpisode(null)"
>新增分集</el-button
>
</div>
<el-alert
v-if="resumeHint"
class="creator-edit-alert"
type="info"
:closable="false"
show-icon
:title="resumeHint"
/>
<el-table :data="episodes" row-key="id" border class="drama-el-table" empty-text="还没有分集">
<el-table-column prop="number" label="集号" width="70" />
<el-table-column label="封面" width="88">
<template #default="{ row }">
<div class="creator-episode-cover">
<img v-if="row.cover" :src="row.cover" alt="" />
<span v-else>无</span>
</div>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
<el-table-column prop="duration" label="时长" width="90" />
<el-table-column label="片源" min-width="120">
<template #default="{ row }">
<span class="console-muted">{{ row.videoUrl ? '已上传' : '未上传' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag
size="small"
effect="light"
:type="row.status === 'published' ? 'success' : 'info'"
>
{{ row.statusText }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row, $index }">
<div class="table-actions">
<el-button
size="small"
:disabled="!editable || $index === 0"
@click="moveEpisode(row, -1)"
>上移</el-button
>
<el-button
size="small"
:disabled="!editable || $index === episodes.length - 1"
@click="moveEpisode(row, 1)"
>下移</el-button
>
<el-button size="small" :disabled="!editable" @click="openEpisode(row)">编辑</el-button>
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="video/*"
:http-request="(options) => requestVideo(row, options)"
:disabled="!editable || uploadingId !== null"
>
<el-button size="small">{{ hasResumeSession ? '继续上传' : '上传视频' }}</el-button>
</el-upload>
<el-button
size="small"
class="danger-action"
:disabled="!editable"
@click="removeEpisode(row)"
>删除</el-button
>
</div>
</template>
</el-table-column>
</el-table>
<p v-if="uploadingId !== null" class="creator-upload-hint"
>上传中 {{ uploadPercent }}%(分片上传,可断点续传)</p
>
</section>
<section class="panel">
<h3 class="creator-section-title">提交记录</h3>
<el-table
:data="submissions"
row-key="id"
border
class="drama-el-table"
empty-text="还没有提交记录"
>
<el-table-column prop="version" label="版本" width="70" />
<el-table-column label="类型" width="100">
<template #default="{ row }">{{ submissionTypeText(row.type) }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag size="small" :type="submissionStatusMeta(row.status).tag" effect="light">
{{ submissionStatusMeta(row.status).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="submitAt"
label="提交时间"
min-width="165"
:formatter="dateCell"
show-overflow-tooltip
/>
<el-table-column
prop="reviewAt"
label="审核时间"
min-width="165"
:formatter="dateCell"
show-overflow-tooltip
/>
<el-table-column prop="reviewNote" label="审核意见" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button
v-if="canEdit && ['SUBMITTED', 'REVIEWING'].includes(row.status)"
size="small"
class="danger-action"
@click="withdraw(row.id)"
>撤回</el-button
>
<span v-else class="console-muted">—</span>
</div>
</template>
</el-table-column>
</el-table>
</section>
<el-dialog
v-model="episodeVisible"
:title="episodeEditingId ? '编辑分集' : '新增分集'"
width="480px"
>
<el-form label-position="top">
<el-form-item label="分集标题">
<el-input v-model="episodeForm.title" maxlength="64" />
</el-form-item>
<el-form-item label="时长(秒)">
<el-input-number
v-model="episodeForm.durationSec"
:min="0"
:max="36000"
class="console-number"
/>
<span class="console-muted">上传视频后会自动带出时长</span>
</el-form-item>
<el-form-item v-if="episodeEditingId" label="封面">
<div class="creator-cover-row">
<div class="creator-cover-preview">
<img v-if="episodeForm.cover" :src="episodeForm.cover" alt="" />
<span v-else>无</span>
</div>
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="image/*"
:http-request="(options) => requestEpisodeCover({ id: episodeEditingId }, options)"
>
<el-button size="small" :icon="Upload">上传封面</el-button>
</el-upload>
</div>
</el-form-item>
<el-form-item v-if="episodeEditingId" label="集号">
<el-input-number v-model="episodeForm.epNo" :min="1" :max="9999" class="console-number" />
</el-form-item>
<el-alert
type="info"
:closable="false"
show-icon
title="免费 / 收费与试看秒数在「收费配置」页设置;新分集默认免费"
/>
</el-form>
<template #footer>
<el-button @click="episodeVisible = false">取消</el-button>
<el-button type="primary" :loading="episodeSaving" @click="saveEpisode">保存</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,141 @@
<script setup>
import PageHeading from '@/views/components/PageHeading.vue'
import { useRbac } from '@/stores/rbac'
import { useCreatorPrice } from './composables/useCreatorPrice'
import { dramaStatusMeta } from './composables/useCreatorWorkspace'
const { can } = useRbac()
const canUpdate = can('creator-center:price:update')
const {
dramas,
dramaId,
config,
loading,
saving,
current,
priceEditable,
readonlyReason,
changeDrama,
save
} = useCreatorPrice()
</script>
<template>
<PageHeading
eyebrow="创作者中心"
title="收费配置"
description="设置免费集数、解锁金币与试看秒数;改完立即影响播放与下载的付费判定。"
>
<el-button
type="primary"
:loading="saving"
:disabled="!canUpdate || !priceEditable"
@click="save"
>保存配置</el-button
>
</PageHeading>
<section class="panel">
<div class="console-toolbar">
<el-select
v-model="dramaId"
class="console-drama"
placeholder="选择作品"
:loading="dramas.loading"
@change="changeDrama"
>
<el-option
v-for="item in dramas.rows"
:key="item.id"
:label="item.title"
:value="item.id"
/>
</el-select>
<el-tag v-if="current" :type="dramaStatusMeta(current.status).tag" effect="light">
{{ dramaStatusMeta(current.status).label }}
</el-tag>
<span class="console-count">{{ config.episodes.length }} 集</span>
</div>
<el-alert
v-if="readonlyReason"
class="creator-edit-alert"
type="warning"
:closable="false"
show-icon
:title="readonlyReason"
/>
<el-empty v-if="!dramas.rows.length" description="还没有作品,先去「我的作品」新建一部" />
</section>
<section v-if="dramaId" v-loading="loading" class="panel">
<h3 class="creator-section-title">整剧默认</h3>
<el-form class="creator-form" label-position="top" :disabled="!canUpdate || !priceEditable">
<div class="creator-form-grid">
<el-form-item label="免费集数">
<el-input-number
v-model="config.freeEpisodes"
:min="0"
:max="999"
class="console-number"
/>
<span class="console-muted">前 N 集免费,其余按解锁金币收费</span>
</el-form-item>
<el-form-item label="整剧解锁金币">
<el-input-number
v-model="config.unlockCoin"
:min="0"
:max="9999"
class="console-number"
/>
<span class="console-muted">逐集未单独指定时生效</span>
</el-form-item>
</div>
</el-form>
<h3 class="creator-section-title">分集明细</h3>
<el-table
:data="config.episodes"
row-key="episodeId"
border
class="drama-el-table"
empty-text="还没有分集"
>
<el-table-column prop="number" label="集号" width="70" />
<el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip />
<el-table-column label="免费" width="90">
<template #default="{ row }">
<el-switch v-model="row.free" :disabled="!canUpdate || !priceEditable" />
</template>
</el-table-column>
<el-table-column label="解锁金币" width="160">
<template #default="{ row }">
<el-input-number
v-model="row.unlockCoin"
:min="0"
:max="9999"
size="small"
:disabled="!canUpdate || !priceEditable || row.free"
/>
</template>
</el-table-column>
<el-table-column label="试看秒数" width="170">
<template #default="{ row }">
<el-input-number
v-model="row.previewSeconds"
:min="0"
:max="600"
size="small"
:disabled="!canUpdate || !priceEditable || row.free"
/>
</template>
</el-table-column>
<el-table-column label="说明" min-width="200">
<template #default="{ row }">
<span class="console-muted">{{ row.free ? '免费观看' : '0 秒 = 不限制试看' }}</span>
</template>
</el-table-column>
</el-table>
</section>
</template>
+295
View File
@@ -0,0 +1,295 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import PageHeading from '@/views/components/PageHeading.vue'
import { useRbac } from '@/stores/rbac'
import { dateCell } from '@/utils/format'
import {
DRAMA_STATUS,
dramaStatusMeta,
submissionStatusMeta,
submissionTypeText,
submitOptions,
useCreatorWorkspace
} from './composables/useCreatorWorkspace'
const router = useRouter()
const { can } = useRbac()
const canEdit = can('creator-center:drama:edit')
const {
profile,
profileLoading,
dramas,
notices,
createVisible,
createSaving,
createForm,
queryDramas,
loadDramas,
loadNotices,
submit,
withdraw,
openCreate,
confirmCreate
} = useCreatorWorkspace()
const activeTab = ref('dramas')
function onTabChange(name) {
if (name === 'notices' && !notices.loaded) loadNotices()
}
function openEdit(row) {
router.push(`/creator-center/dramas/${row.id}`)
}
</script>
<template>
<PageHeading
eyebrow="创作者中心"
title="我的作品"
description="查看作品与审核进度,提交上线 / 下线申请。"
>
<el-button v-if="canEdit" type="primary" @click="openCreate">新建作品</el-button>
</PageHeading>
<section v-loading="profileLoading" class="panel creator-profile">
<div class="creator-profile-main">
<span class="creator-profile-name">{{ profile?.name || '—' }}</span>
<el-tag
size="small"
effect="light"
:type="profile?.status === 'approved' ? 'success' : 'warning'"
>
{{ profile?.statusText || '—' }}
</el-tag>
<span class="console-muted">{{ profile?.code || '' }}</span>
</div>
<div class="creator-profile-meta">
<span
>作品数 <b>{{ profile?.dramaTotal ?? 0 }}</b></span
>
<span
>联系人 <b>{{ profile?.contactName || '—' }}</b></span
>
<span
>联系邮箱 <b>{{ profile?.contactEmail || '—' }}</b></span
>
</div>
<el-alert
v-if="profile && profile.status === 'rejected'"
class="creator-profile-alert"
type="error"
:closable="false"
show-icon
:title="`入驻审核未通过:${profile.auditNote || '未填写原因'}`"
/>
<el-alert
v-else-if="profile && profile.status !== 'approved'"
class="creator-profile-alert"
type="warning"
:closable="false"
show-icon
title="入驻审核通过后才能创建作品与提交审核"
/>
</section>
<section class="panel">
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane label="我的作品" name="dramas">
<div class="console-toolbar">
<el-input
v-model="dramas.keyword"
class="console-keyword"
:prefix-icon="Search"
clearable
placeholder="搜索作品名称"
@keyup.enter="queryDramas"
@clear="queryDramas"
/>
<el-select
v-model="dramas.status"
class="console-select"
clearable
placeholder="全部状态"
@change="queryDramas"
>
<el-option
v-for="item in DRAMA_STATUS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button type="primary" @click="queryDramas">查询</el-button>
<span class="console-count">共 {{ dramas.total }} 部作品</span>
</div>
<el-table
v-loading="dramas.loading"
:data="dramas.rows"
row-key="id"
border
class="drama-el-table"
empty-text="还没有作品,点右上角「新建作品」开始"
>
<el-table-column label="作品" min-width="220">
<template #default="{ row }">
<div class="creator-drama-cell">
<div class="creator-drama-cover">
<img v-if="row.cover" :src="row.cover" alt="" />
<span v-else>{{ (row.title || '?').slice(0, 1) }}</span>
</div>
<div class="creator-drama-meta">
<span class="creator-drama-title">{{ row.title }}</span>
<span class="creator-drama-code">{{ row.code }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="dramaStatusMeta(row.status).tag" effect="light">{{
dramaStatusMeta(row.status).label
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="episodes" label="分集" width="80" />
<el-table-column prop="views" label="播放" width="100" />
<el-table-column label="审核进度" min-width="200">
<template #default="{ row }">
<div v-if="row.submissionStatus" class="creator-progress">
<el-tag
size="small"
:type="submissionStatusMeta(row.submissionStatus).tag"
effect="plain"
>
{{ submissionTypeText(row.submissionType) }}·{{
submissionStatusMeta(row.submissionStatus).label
}}
</el-tag>
<span v-if="row.reviewNote" class="console-muted" :title="row.reviewNote">{{
row.reviewNote
}}</span>
</div>
<span v-else class="console-muted">尚未提交</span>
</template>
</el-table-column>
<el-table-column
prop="updatedAt"
label="更新时间"
min-width="165"
:formatter="dateCell"
show-overflow-tooltip
/>
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button v-if="canEdit" type="primary" size="small" @click="openEdit(row)"
>编辑</el-button
>
<el-dropdown
v-if="canEdit && submitOptions(row).length"
trigger="click"
@command="(type) => submit(row, type)"
>
<el-button size="small">提交审核</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="type in submitOptions(row)"
:key="type"
:command="type"
>
{{ submissionTypeText(type) }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
v-if="
canEdit &&
row.submissionId &&
['SUBMITTED', 'REVIEWING'].includes(row.submissionStatus)
"
size="small"
class="danger-action"
@click="withdraw(row)"
>撤回</el-button
>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="dramas.page"
v-model:page-size="dramas.size"
:page-sizes="[10, 20, 50]"
:total="dramas.total"
layout="total, prev, pager, next, sizes"
background
@current-change="loadDramas"
@size-change="queryDramas"
/>
</div>
</el-tab-pane>
<el-tab-pane label="通知" name="notices">
<div v-loading="notices.loading" class="creator-notice-list">
<el-empty v-if="!notices.rows.length" description="还没有审核结果通知" />
<div
v-for="item in notices.rows"
:key="`${item.bizType}-${item.id}`"
class="creator-notice"
>
<div class="creator-notice-head">
<span class="creator-notice-title">{{ item.title }}</span>
<span class="console-muted">{{
item.time ? String(item.time).replace('T', ' ').slice(0, 19) : ''
}}</span>
</div>
<p class="creator-notice-body">{{ item.content || '—' }}</p>
</div>
</div>
</el-tab-pane>
</el-tabs>
</section>
<el-dialog v-model="createVisible" title="新建作品" width="480px">
<el-form label-position="top">
<el-form-item label="作品名称" required>
<el-input
v-model="createForm.title"
maxlength="64"
placeholder="例如:闪婚后,傅先生每天都在撒糖"
/>
</el-form-item>
<el-form-item label="题材">
<el-input v-model="createForm.genre" maxlength="32" placeholder="例如:都市情感" />
</el-form-item>
<el-form-item label="简介">
<el-input
v-model="createForm.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-alert
type="info"
:closable="false"
show-icon
title="先建草稿,再去作品编辑页完善信息、分集与视频,然后提交审核"
/>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createSaving" @click="confirmCreate">创建草稿</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,87 @@
import { onMounted, ref } from 'vue'
import { getCreatorDashboardApi } from '@/api/admin/creatorCenter'
/**
* 指标展示配置:key 与后端 /admin/creator-center/dashboard 的字段一一对应,
* 不在前端编字段;口径见后端 CreatorDashboardService 注释与 docs/api-phase1.md「2.10」。
*/
export const DASHBOARD_GROUPS = [
{
key: 'drama',
title: '我的作品',
metrics: [
{ key: 'total', label: '作品总数' },
{ key: 'draft', label: '草稿' },
{ key: 'reviewing', label: '审核中' },
{ key: 'approved', label: '待发布' },
{ key: 'published', label: '已上线' },
{ key: 'offline', label: '已下线' },
{ key: 'episodeTotal', label: '分集总数' },
{ key: 'episodeWithMedia', label: '有片源分集' }
]
},
{
key: 'playback',
title: '播放',
metrics: [
{ key: 'viewTotal', label: '累计观看' },
{ key: 'playTotal', label: '累计播放' },
{ key: 'watchedDramas', label: '看过我的剧(用户×剧)' },
{ key: 'finishedDramas', label: '其中标记完播' },
{ key: 'finishRate', label: '完播率' },
{ key: 'playEvents7d', label: '近 7 天播放流水' },
{ key: 'playEventsTotal', label: '播放流水总量' },
{ key: 'finishEvents', label: '完播事件' }
]
},
{
key: 'interaction',
title: '互动',
metrics: [
{ key: 'favoriteTotal', label: '收藏' },
{ key: 'commentTotal', label: '评论' },
{ key: 'commentPending', label: '待审评论' }
]
},
{
key: 'revenue',
title: '解锁收入',
metrics: [
{ key: 'unlockCount', label: '解锁笔数' },
{ key: 'coinIncome', label: '金币收入' },
{ key: 'coinIncome7d', label: '近 7 天金币收入' },
{ key: 'paidOrderAmount', label: '法币收入金额' }
]
}
]
export function useCreatorDashboard() {
const loading = ref(false)
const board = ref(null)
const lastUpdated = ref('')
async function load() {
loading.value = true
try {
board.value = (await getCreatorDashboardApi()).data || null
lastUpdated.value = new Date().toLocaleString()
} finally {
loading.value = false
}
}
/** 取不到的指标后端给 null,这里显示「暂无数据源」而不是 0 */
function display(value) {
if (value === null || value === undefined) return '暂无数据源'
if (typeof value === 'number' && !Number.isInteger(value)) return value.toFixed(3)
return value
}
function groupValue(groupKey, metricKey) {
return board.value?.[groupKey]?.[metricKey]
}
onMounted(load)
return { loading, board, lastUpdated, load, display, groupValue }
}
@@ -0,0 +1,100 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getPricesApi, pageMyDramasApi, savePricesApi } from '@/api/admin/creatorCenter'
import { dramaStatusMeta } from './useCreatorWorkspace'
export function useCreatorPrice() {
const dramas = reactive({ rows: [], loading: false })
const dramaId = ref(null)
const config = reactive({ freeEpisodes: 0, unlockCoin: 0, episodes: [] })
const loading = ref(false)
const saving = ref(false)
const current = computed(() => dramas.rows.find((item) => item.id === dramaId.value) || null)
/**
* 价格只能改草稿 / 已下线(后端 requireEditable 同一口径):
* 已上线要改价得先申请下线,避免创作者无审核地改已上线内容的价格。
*/
const priceEditable = computed(() => {
const status = current.value?.status
return status === 'draft' || status === 'offline'
})
const readonlyReason = computed(() => {
if (!current.value) return ''
if (priceEditable.value) return ''
return `「${dramaStatusMeta(current.value.status).label}」状态不可改价:请先「申请下线」,改完再提交上线`
})
async function loadDramas() {
dramas.loading = true
try {
const res = await pageMyDramasApi({ pageIndex: 1, pageSize: 100 })
dramas.rows = res.data?.list || []
if (!dramaId.value && dramas.rows.length) {
dramaId.value = dramas.rows[0].id
}
} finally {
dramas.loading = false
}
}
async function loadPrices() {
if (!dramaId.value) return
loading.value = true
try {
const res = await getPricesApi(dramaId.value)
config.freeEpisodes = res.data?.freeEpisodes ?? 0
config.unlockCoin = res.data?.unlockCoin ?? 0
config.episodes = res.data?.episodes || []
} finally {
loading.value = false
}
}
async function changeDrama() {
await loadPrices()
}
async function save() {
if (!dramaId.value) return
saving.value = true
try {
const res = await savePricesApi(dramaId.value, {
freeEpisodes: config.freeEpisodes,
unlockCoin: config.unlockCoin,
episodes: config.episodes.map((item) => ({
episodeId: item.episodeId,
isFree: item.free ? 1 : 0,
unlockCoin: item.unlockCoin,
previewSeconds: item.previewSeconds
}))
})
config.freeEpisodes = res.data?.freeEpisodes ?? config.freeEpisodes
config.unlockCoin = res.data?.unlockCoin ?? config.unlockCoin
config.episodes = res.data?.episodes || config.episodes
ElMessage.success(`已保存,本次调整 ${res.data?.changedCount ?? 0} 集;改价立即影响付费判定`)
} finally {
saving.value = false
}
}
onMounted(async () => {
await loadDramas()
await loadPrices()
})
return {
dramas,
dramaId,
config,
loading,
saving,
current,
priceEditable,
readonlyReason,
changeDrama,
loadPrices,
save
}
}
@@ -0,0 +1,215 @@
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createDramaApi,
getCreatorProfileApi,
listCreatorNoticesApi,
pageMyDramasApi,
submitDramaApi,
withdrawSubmissionApi
} from '@/api/admin/creatorCenter'
/** 作品状态:与后端 ContentStatus 一一对应 */
export const DRAMA_STATUS = [
{ value: 'draft', label: '草稿', tag: 'info' },
{ value: 'reviewing', label: '审核中', tag: 'warning' },
{ value: 'approved', label: '待发布', tag: 'warning' },
{ value: 'published', label: '已上线', tag: 'success' },
{ value: 'offline', label: '已下线', tag: 'info' }
]
/** 提交状态:与后端 content_submission.status 一一对应 */
export const SUBMISSION_STATUS = [
{ value: 'DRAFT', label: '草稿', tag: 'info' },
{ value: 'SUBMITTED', label: '已提交', tag: 'warning' },
{ value: 'REVIEWING', label: '审核中', tag: 'warning' },
{ value: 'APPROVED', label: '已通过', tag: 'success' },
{ value: 'REJECTED', label: '已驳回', tag: 'danger' },
{ value: 'WITHDRAWN', label: '已撤回', tag: 'info' }
]
export const SUBMISSION_TYPE = [
{ value: 'CREATE', label: '新建' },
{ value: 'UPDATE', label: '编辑' },
{ value: 'PUBLISH', label: '申请上线' },
{ value: 'OFFLINE', label: '申请下线' }
]
export function dramaStatusMeta(status) {
return DRAMA_STATUS.find((item) => item.value === status) || { label: status || '-', tag: 'info' }
}
export function submissionStatusMeta(status) {
return (
SUBMISSION_STATUS.find((item) => item.value === status) || { label: status || '-', tag: 'info' }
)
}
export function submissionTypeText(type) {
return SUBMISSION_TYPE.find((item) => item.value === type)?.label || type || '-'
}
/**
* 可提交的类型:与后端 submit 的状态矩阵一一对应,前端只做"给不给出这个按钮",
* 真正的校验仍在服务端(前端不复制规则,只减少无效点击)。
*/
export function submitOptions(row) {
const firstSubmit = !row.submissionId
const options = []
if (row.status === 'draft') {
options.push(firstSubmit ? 'CREATE' : 'UPDATE')
options.push('PUBLISH')
} else if (row.status === 'offline') {
options.push('UPDATE', 'PUBLISH')
} else if (row.status === 'reviewing' || row.status === 'approved') {
options.push('PUBLISH')
} else if (row.status === 'published') {
options.push('OFFLINE')
}
return options
}
export function useCreatorWorkspace() {
const profileLoading = ref(false)
const profile = ref(null)
const dramas = reactive({
rows: [],
total: 0,
loading: false,
page: 1,
size: 10,
keyword: '',
status: undefined
})
const notices = reactive({ rows: [], loading: false, loaded: false })
async function loadProfile() {
profileLoading.value = true
try {
profile.value = (await getCreatorProfileApi()).data || null
} finally {
profileLoading.value = false
}
}
async function loadDramas() {
dramas.loading = true
try {
const res = await pageMyDramasApi({
pageIndex: dramas.page,
pageSize: dramas.size,
keyword: dramas.keyword.trim() || undefined,
status: dramas.status || undefined
})
dramas.rows = res.data?.list || []
dramas.total = res.data?.total || 0
} finally {
dramas.loading = false
}
}
function queryDramas() {
dramas.page = 1
loadDramas()
}
async function loadNotices() {
notices.loading = true
try {
notices.rows = (await listCreatorNoticesApi()).data || []
notices.loaded = true
} finally {
notices.loading = false
}
}
async function submit(row, type) {
const label = submissionTypeText(type)
if (type === 'OFFLINE') {
try {
await ElMessageBox.confirm(
`提交「申请下线」后由后台审核,审核通过前内容仍在线。确认提交?`,
label,
{ confirmButtonText: '确认提交', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
}
await submitDramaApi(row.id, type)
ElMessage.success(`已提交「${label}」,等待后台审核`)
await loadDramas()
if (notices.loaded) await loadNotices()
}
async function withdraw(row) {
if (!row.submissionId) return
try {
await ElMessageBox.confirm(
'撤回后作品回到草稿、可以继续编辑,确认撤回这次提交?',
'撤回提交',
{
confirmButtonText: '确认撤回',
cancelButtonText: '取消'
}
)
} catch {
return
}
await withdrawSubmissionApi(row.submissionId)
ElMessage.success('已撤回,作品回到草稿')
await loadDramas()
}
const createVisible = ref(false)
const createSaving = ref(false)
const createForm = reactive({ title: '', genre: '', description: '' })
function openCreate() {
Object.assign(createForm, { title: '', genre: '', description: '' })
createVisible.value = true
}
async function confirmCreate() {
if (!createForm.title.trim()) return ElMessage.warning('请填写作品名称')
createSaving.value = true
try {
const res = await createDramaApi({
title: createForm.title.trim(),
genre: createForm.genre.trim(),
description: createForm.description.trim()
})
ElMessage.success('草稿已创建,可以继续编辑分集')
createVisible.value = false
await loadDramas()
return res.data
} finally {
createSaving.value = false
}
}
onMounted(() => {
loadProfile()
loadDramas()
})
return {
profile,
profileLoading,
dramas,
notices,
createVisible,
createSaving,
createForm,
loadProfile,
loadDramas,
queryDramas,
loadNotices,
submit,
withdraw,
openCreate,
confirmCreate
}
}
@@ -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
}
}