add:新增验证视频可选上传功能
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { VideoCameraOutlined } from '@ant-design/icons'
|
||||
import { Typography } from 'antd'
|
||||
|
||||
import type { WorkerAcceptanceVideo } from '@/types/worker-platform'
|
||||
|
||||
type VideoPreviewProps = {
|
||||
video?: WorkerAcceptanceVideo | null
|
||||
maxHeight?: number
|
||||
emptyText?: string
|
||||
showMeta?: boolean
|
||||
}
|
||||
|
||||
/** 验收视频只读预览(打手端详情、后台工单详情、售后固化快照共用)。 */
|
||||
export default function VideoPreview({
|
||||
video = null,
|
||||
maxHeight = 220,
|
||||
emptyText = '未上传验收视频',
|
||||
showMeta = true,
|
||||
}: VideoPreviewProps) {
|
||||
if (!video?.url) {
|
||||
return <Typography.Text type="secondary">{emptyText}</Typography.Text>
|
||||
}
|
||||
return (
|
||||
<div className="acceptance-video-preview">
|
||||
<video
|
||||
src={video.url}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
style={{ maxWidth: '100%', maxHeight, borderRadius: 8, background: '#000' }}
|
||||
/>
|
||||
{showMeta ? (
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<VideoCameraOutlined /> {formatAcceptanceVideoDuration(video.durationSeconds)}
|
||||
{video.size ? ` · ${formatAcceptanceVideoSize(video.size)}` : ''}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function formatAcceptanceVideoDuration(seconds?: number) {
|
||||
const total = Math.max(0, Math.round(Number(seconds) || 0))
|
||||
if (!total) return '时长未知'
|
||||
const minutes = Math.floor(total / 60)
|
||||
const rest = total % 60
|
||||
return minutes > 0 ? `${minutes}分${String(rest).padStart(2, '0')}秒` : `${rest}秒`
|
||||
}
|
||||
|
||||
function formatAcceptanceVideoSize(bytes: number) {
|
||||
const size = Number(bytes || 0)
|
||||
if (!size) return ''
|
||||
if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)}MB`
|
||||
return `${Math.max(1, Math.round(size / 1024))}KB`
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { DeleteOutlined, LoadingOutlined, VideoCameraOutlined } from '@ant-design/icons'
|
||||
import { App, Button, Progress, Space, Typography } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useRef, useState, type ChangeEvent } from 'react'
|
||||
|
||||
import { uploadVideoFile } from '@/services/files'
|
||||
import { fetchWorkerAcceptanceConfig } from '@/services/worker'
|
||||
import type { WorkerAcceptanceVideo } from '@/types/worker-platform'
|
||||
import {
|
||||
compressVideoForUpload,
|
||||
isVideoCompressionSupported,
|
||||
probeVideoFile,
|
||||
resolveVideoDurationLimitError,
|
||||
type AcceptanceVideoConfig,
|
||||
} from '@/utils/video-compress'
|
||||
|
||||
type VideoUploadProps = {
|
||||
value?: WorkerAcceptanceVideo | null
|
||||
onChange?: (video: WorkerAcceptanceVideo | null) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const VIDEO_ACCEPT = 'video/mp4,video/webm,video/quicktime,video/*'
|
||||
|
||||
/**
|
||||
* 验收视频上传(非必填,单个)。
|
||||
*
|
||||
* 流程:选片 → 读时长/分辨率 → 超过配置上限直接拒绝(不裁剪)→ Canvas 重编码为
|
||||
* 720p/1.2Mbps(见 utils/video-compress)→ 上传 → 回填视频信息。
|
||||
* 浏览器不支持 MediaRecorder / captureStream 时直接提示更换浏览器,不上传原片。
|
||||
*/
|
||||
export default function VideoUpload({
|
||||
value = null,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: VideoUploadProps) {
|
||||
const { message } = App.useApp()
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [phase, setPhase] = useState<'idle' | 'compressing' | 'uploading'>('idle')
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const configQuery = useQuery({
|
||||
queryKey: ['worker-acceptance-config'],
|
||||
queryFn: fetchWorkerAcceptanceConfig,
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
})
|
||||
const config = configQuery.data?.data as AcceptanceVideoConfig | undefined
|
||||
const supported = isVideoCompressionSupported()
|
||||
const busy = phase !== 'idle'
|
||||
const canPick = Boolean(config?.enabled) && supported && !disabled && !busy
|
||||
|
||||
async function handleSelect(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
// 让"选择同一个文件"也能再次触发 change。
|
||||
event.target.value = ''
|
||||
if (!file || !config) return
|
||||
if (!config.enabled) {
|
||||
message.error('验收视频功能暂未开启')
|
||||
return
|
||||
}
|
||||
if (!supported) {
|
||||
message.error('当前浏览器不支持视频压缩,请使用 Chrome / Edge,或先裁剪视频后再上传')
|
||||
return
|
||||
}
|
||||
|
||||
setPhase('compressing')
|
||||
setProgress(0)
|
||||
try {
|
||||
const probe = await probeVideoFile(file)
|
||||
const limitError = resolveVideoDurationLimitError(
|
||||
probe.durationSeconds,
|
||||
config.maxDurationSeconds,
|
||||
)
|
||||
if (limitError) throw new Error(limitError)
|
||||
|
||||
const compressed = await compressVideoForUpload(file, config, setProgress)
|
||||
if (compressed.blob.size > config.maxSizeMb * 1024 * 1024) {
|
||||
throw new Error(`压缩后仍超过 ${config.maxSizeMb}MB,请缩短视频后重试`)
|
||||
}
|
||||
|
||||
setPhase('uploading')
|
||||
setProgress(0)
|
||||
const uploadName = `acceptance-${Date.now()}.${compressed.extension}`
|
||||
const uploaded = await uploadVideoFile(
|
||||
new File([compressed.blob], uploadName, { type: compressed.contentType }),
|
||||
'worker-acceptance-video',
|
||||
compressed.durationSeconds,
|
||||
)
|
||||
onChange?.({
|
||||
objectKey: uploaded.objectKey,
|
||||
url: uploaded.url,
|
||||
filename: uploaded.filename || uploadName,
|
||||
contentType: uploaded.contentType || compressed.contentType,
|
||||
size: uploaded.size || compressed.blob.size,
|
||||
durationSeconds: uploaded.durationSeconds || compressed.durationSeconds,
|
||||
width: compressed.width,
|
||||
height: compressed.height,
|
||||
})
|
||||
message.success('验收视频已上传')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '视频处理失败,请重试')
|
||||
} finally {
|
||||
setPhase('idle')
|
||||
setProgress(0)
|
||||
}
|
||||
}
|
||||
|
||||
const hint = config
|
||||
? `选填,最长 ${config.maxDurationSeconds} 秒;上传前会自动压缩到 ${config.targetHeight}p,约 ${estimatedSizeMb(config)}MB 以内`
|
||||
: '选填;正在读取验收视频配置…'
|
||||
|
||||
return (
|
||||
<div className="worker-acceptance-video">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={VIDEO_ACCEPT}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => void handleSelect(event)}
|
||||
/>
|
||||
|
||||
{value ? (
|
||||
<div className="worker-acceptance-video-preview">
|
||||
<video
|
||||
src={value.url}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="worker-acceptance-video-player"
|
||||
/>
|
||||
<div className="worker-acceptance-video-meta">
|
||||
<Typography.Text type="secondary">
|
||||
{formatDuration(value.durationSeconds)} · {formatSize(value.size)}
|
||||
</Typography.Text>
|
||||
<Space size={8}>
|
||||
<Button size="small" disabled={!canPick} onClick={() => inputRef.current?.click()}>
|
||||
替换
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={disabled || busy}
|
||||
onClick={() => onChange?.(null)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Button
|
||||
icon={busy ? <LoadingOutlined /> : <VideoCameraOutlined />}
|
||||
disabled={!canPick}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{busy ? '处理中…' : '选择验收视频'}
|
||||
</Button>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{hint}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{busy ? (
|
||||
<Progress
|
||||
percent={Math.round(progress * 100)}
|
||||
size="small"
|
||||
status="active"
|
||||
format={(percent) => `${phase === 'compressing' ? '压缩中' : '上传中'} ${percent ?? 0}%`}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function estimatedSizeMb(config: AcceptanceVideoConfig) {
|
||||
const bytesPerSecond = ((config.bitrateKbps + config.audioBitrateKbps) * 1000) / 8
|
||||
const estimated = (bytesPerSecond * config.maxDurationSeconds) / (1024 * 1024)
|
||||
return Math.max(1, Math.ceil(estimated))
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const total = Math.max(0, Math.round(Number(seconds) || 0))
|
||||
const minutes = Math.floor(total / 60)
|
||||
const rest = total % 60
|
||||
return minutes > 0 ? `${minutes}分${String(rest).padStart(2, '0')}秒` : `${rest}秒`
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number) {
|
||||
const size = Number(bytes || 0)
|
||||
if (!size) return '大小未知'
|
||||
if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)}MB`
|
||||
return `${Math.max(1, Math.round(size / 1024))}KB`
|
||||
}
|
||||
Reference in New Issue
Block a user