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,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`
|
||||
})
|
||||
}
|
||||
@@ -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}` })
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user