Files
ReelShortWeb/src/utils/uploader.ts
T
Vicky 0a2e3e0b81 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(先查服务端进度再续传)
2026-10-15 14:47:28 +08:00

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
})
}