为什么: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(先查服务端进度再续传)
115 lines
3.9 KiB
TypeScript
115 lines
3.9 KiB
TypeScript
import {
|
|
completeUploadApi,
|
|
getUploadProgressApi,
|
|
initUploadApi,
|
|
uploadChunkApi
|
|
} from '@/api/admin/media'
|
|
import type { MediaAsset } from '@/api/admin/types'
|
|
|
|
export interface UploadFileOptions {
|
|
/** DRAMA_COVER / EPISODE / AVATAR */
|
|
bizType?: string
|
|
/** 并发分片数,默认 3 */
|
|
concurrency?: number
|
|
/** 视频时长(秒),由调用方探测后传入 */
|
|
durationSec?: number
|
|
onProgress?: (percent: number) => void
|
|
/** init 建好会话就回调一次:调用方可以把它记下来,失败/刷新后走 resumeUpload 续传 */
|
|
onSession?: (uploadId: string) => void
|
|
}
|
|
|
|
/**
|
|
* 分片上传:init 建会话 → 并发传分片 → complete 合并。
|
|
* 服务端返回合并后的媒资(含可访问 URL)。
|
|
*/
|
|
export async function uploadFile(file: File, options: UploadFileOptions = {}): Promise<MediaAsset> {
|
|
const { bizType = 'EPISODE', concurrency = 3, onProgress } = options
|
|
const initRes = await initUploadApi({
|
|
fileName: file.name,
|
|
fileSize: file.size,
|
|
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
|
|
|
|
const worker = async () => {
|
|
for (;;) {
|
|
const index = pending.shift()
|
|
if (index === undefined) return
|
|
const start = index * info.chunkSize
|
|
const chunk = file.slice(start, Math.min(start + info.chunkSize, file.size))
|
|
await uploadChunkApi(info.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, total) }, worker))
|
|
const doneRes = await completeUploadApi(info.uploadId, options.durationSec)
|
|
onProgress?.(100)
|
|
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) => {
|
|
const url = URL.createObjectURL(file)
|
|
const video = document.createElement('video')
|
|
video.preload = 'metadata'
|
|
const cleanup = (value: number) => {
|
|
URL.revokeObjectURL(url)
|
|
resolve(value)
|
|
}
|
|
video.onloadedmetadata = () => {
|
|
cleanup(Number.isFinite(video.duration) ? Math.round(video.duration) : 0)
|
|
}
|
|
video.onerror = () => cleanup(0)
|
|
video.src = url
|
|
})
|
|
}
|