From 0a2e3e0b81294113dc0f7baafa4091c3813a04c0 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Thu, 15 Oct 2026 14:47:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=2007=20=E5=88=9B=E4=BD=9C=E8=80=85?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E5=9B=9B=E4=B8=AA=E9=A1=B5=E9=9D=A2=EF=BC=88?= =?UTF-8?q?=E6=88=91=E7=9A=84=E4=BD=9C=E5=93=81=20/=20=E4=BD=9C=E5=93=81?= =?UTF-8?q?=E7=BC=96=E8=BE=91=20/=20=E6=94=B6=E8=B4=B9=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=20/=20=E6=95=B0=E6=8D=AE=E7=9C=8B=E6=9D=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么: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(先查服务端进度再续传) --- src/api/admin/creatorCenter.ts | 224 +++++++++++ src/api/admin/media.ts | 20 + src/styles/business/creator-center.css | 237 ++++++++++++ src/styles/business/index.css | 1 + src/utils/uploader.ts | 52 ++- src/views/creator-center/DashboardView.vue | 46 +++ src/views/creator-center/DramaEditView.vue | 354 ++++++++++++++++++ src/views/creator-center/PriceConfigView.vue | 141 +++++++ src/views/creator-center/WorkspaceView.vue | 295 +++++++++++++++ .../composables/useCreatorDashboard.js | 87 +++++ .../composables/useCreatorPrice.js | 100 +++++ .../composables/useCreatorWorkspace.js | 215 +++++++++++ .../composables/useDramaEdit.js | 342 +++++++++++++++++ 13 files changed, 2113 insertions(+), 1 deletion(-) create mode 100644 src/api/admin/creatorCenter.ts create mode 100644 src/styles/business/creator-center.css create mode 100644 src/views/creator-center/DashboardView.vue create mode 100644 src/views/creator-center/DramaEditView.vue create mode 100644 src/views/creator-center/PriceConfigView.vue create mode 100644 src/views/creator-center/WorkspaceView.vue create mode 100644 src/views/creator-center/composables/useCreatorDashboard.js create mode 100644 src/views/creator-center/composables/useCreatorPrice.js create mode 100644 src/views/creator-center/composables/useCreatorWorkspace.js create mode 100644 src/views/creator-center/composables/useDramaEdit.js diff --git a/src/api/admin/creatorCenter.ts b/src/api/admin/creatorCenter.ts new file mode 100644 index 0000000..9090be6 --- /dev/null +++ b/src/api/admin/creatorCenter.ts @@ -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 + playback: Record + interaction: Record + /** paidOrderAmount 取不到时为 null */ + revenue: Record + /** 按天趋势:creator_stat_daily 无产出,恒为 null */ + daily: null + notes: string[] +} + +export const getCreatorProfileApi = () => { + return request.get({ url: '/admin/creator-center/profile' }) +} + +export const pageMyDramasApi = (params: Record) => { + return request.get>({ url: '/admin/creator-center/dramas/page', params }) +} + +export const getMyDramaApi = (id: number) => { + return request.get({ url: `/admin/creator-center/dramas/${id}` }) +} + +export const listCreatorNoticesApi = () => { + return request.get({ url: '/admin/creator-center/notices' }) +} + +export const getCreatorDashboardApi = () => { + return request.get({ url: '/admin/creator-center/dashboard' }) +} + +export const createDramaApi = (data: CreatorDramaEditData) => { + return request.post({ url: '/admin/creator-center/dramas', data }) +} + +export const updateDramaApi = (id: number, data: CreatorDramaEditData) => { + return request.put({ url: `/admin/creator-center/dramas/${id}`, data }) +} + +export const listEpisodesApi = (dramaId: number) => { + return request.get({ url: `/admin/creator-center/dramas/${dramaId}/episodes` }) +} + +export const addEpisodeApi = (dramaId: number, data: Record) => { + return request.post({ url: `/admin/creator-center/dramas/${dramaId}/episodes`, data }) +} + +export const updateEpisodeApi = ( + dramaId: number, + episodeId: number, + data: Record +) => { + return request.put({ + url: `/admin/creator-center/dramas/${dramaId}/episodes/${episodeId}`, + data + }) +} + +export const deleteEpisodeApi = (dramaId: number, episodeId: number) => { + return request.delete({ + url: `/admin/creator-center/dramas/${dramaId}/episodes/${episodeId}` + }) +} + +export const sortEpisodesApi = (dramaId: number, ids: number[]) => { + return request.put({ + url: `/admin/creator-center/dramas/${dramaId}/episodes/sort`, + data: { ids } + }) +} + +/** 把上传好的媒资挂到分集(产物必须是本人上传的) */ +export const attachAssetApi = (dramaId: number, episodeId: number, assetId: number) => { + return request.post({ + url: `/admin/creator-center/dramas/${dramaId}/episodes/${episodeId}/attach`, + data: { assetId } + }) +} + +export const getPricesApi = (dramaId: number) => { + return request.get({ url: `/admin/creator-center/dramas/${dramaId}/prices` }) +} + +export const savePricesApi = (dramaId: number, data: Record) => { + return request.put({ url: `/admin/creator-center/dramas/${dramaId}/prices`, data }) +} + +export const submitDramaApi = (dramaId: number, type: string) => { + return request.post({ + url: `/admin/creator-center/dramas/${dramaId}/submit`, + data: { type } + }) +} + +export const withdrawSubmissionApi = (submissionId: number) => { + return request.post({ + url: `/admin/creator-center/submissions/${submissionId}/withdraw` + }) +} + +export const listSubmissionsApi = (dramaId: number) => { + return request.get({ + url: `/admin/creator-center/dramas/${dramaId}/submissions` + }) +} diff --git a/src/api/admin/media.ts b/src/api/admin/media.ts index f2d0dad..4676493 100644 --- a/src/api/admin/media.ts +++ b/src/api/admin/media.ts @@ -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({ url: `/admin/media/upload/${uploadId}` }) +} + export const getMediaApi = (id: number) => { return request.get({ url: `/admin/media/${id}` }) } diff --git a/src/styles/business/creator-center.css b/src/styles/business/creator-center.css new file mode 100644 index 0000000..bba67a6 --- /dev/null +++ b/src/styles/business/creator-center.css @@ -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); +} diff --git a/src/styles/business/index.css b/src/styles/business/index.css index 0fececf..3e58968 100644 --- a/src/styles/business/index.css +++ b/src/styles/business/index.css @@ -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'; diff --git a/src/utils/uploader.ts b/src/utils/uploader.ts index 78bcb4a..ec028ba 100644 --- a/src/utils/uploader.ts +++ b/src/utils/uploader.ts @@ -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 { + 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 { return new Promise((resolve) => { diff --git a/src/views/creator-center/DashboardView.vue b/src/views/creator-center/DashboardView.vue new file mode 100644 index 0000000..e6a6520 --- /dev/null +++ b/src/views/creator-center/DashboardView.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/views/creator-center/DramaEditView.vue b/src/views/creator-center/DramaEditView.vue new file mode 100644 index 0000000..b233030 --- /dev/null +++ b/src/views/creator-center/DramaEditView.vue @@ -0,0 +1,354 @@ + + + diff --git a/src/views/creator-center/PriceConfigView.vue b/src/views/creator-center/PriceConfigView.vue new file mode 100644 index 0000000..b815649 --- /dev/null +++ b/src/views/creator-center/PriceConfigView.vue @@ -0,0 +1,141 @@ + + + diff --git a/src/views/creator-center/WorkspaceView.vue b/src/views/creator-center/WorkspaceView.vue new file mode 100644 index 0000000..5dffc0f --- /dev/null +++ b/src/views/creator-center/WorkspaceView.vue @@ -0,0 +1,295 @@ + + + diff --git a/src/views/creator-center/composables/useCreatorDashboard.js b/src/views/creator-center/composables/useCreatorDashboard.js new file mode 100644 index 0000000..c05fa79 --- /dev/null +++ b/src/views/creator-center/composables/useCreatorDashboard.js @@ -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 } +} diff --git a/src/views/creator-center/composables/useCreatorPrice.js b/src/views/creator-center/composables/useCreatorPrice.js new file mode 100644 index 0000000..5de1592 --- /dev/null +++ b/src/views/creator-center/composables/useCreatorPrice.js @@ -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 + } +} diff --git a/src/views/creator-center/composables/useCreatorWorkspace.js b/src/views/creator-center/composables/useCreatorWorkspace.js new file mode 100644 index 0000000..b0a6f19 --- /dev/null +++ b/src/views/creator-center/composables/useCreatorWorkspace.js @@ -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 + } +} diff --git a/src/views/creator-center/composables/useDramaEdit.js b/src/views/creator-center/composables/useDramaEdit.js new file mode 100644 index 0000000..f3fcb39 --- /dev/null +++ b/src/views/creator-center/composables/useDramaEdit.js @@ -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 + } +}