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`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
import { ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
App,
|
||||
@@ -26,7 +26,12 @@ import {
|
||||
repayAdminAfterSalesCaseDebt,
|
||||
resolveAdminAfterSalesCase,
|
||||
} from '@/services/admin'
|
||||
import type { UploadedFile, WorkerAfterSalesCase, WorkOrder } from '@/types/worker-platform'
|
||||
import type {
|
||||
UploadedFile,
|
||||
WorkerAcceptanceVideo,
|
||||
WorkerAfterSalesCase,
|
||||
WorkOrder,
|
||||
} from '@/types/worker-platform'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { uniqueUploadedFiles } from '@/utils/image-identity'
|
||||
@@ -259,6 +264,15 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
|
||||
/>
|
||||
</Space>
|
||||
) : null}
|
||||
{getAfterSalesSnapshotVideo(item) ? (
|
||||
<a
|
||||
href={getAfterSalesSnapshotVideo(item)?.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<VideoCameraOutlined /> 查看固化验收视频
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -570,4 +584,21 @@ function getAfterSalesSnapshotFiles(item: WorkerAfterSalesCase): UploadedFile[]
|
||||
)
|
||||
}
|
||||
|
||||
/** 售后固化快照里的验收视频(有则给出可点击链接)。 */
|
||||
function getAfterSalesSnapshotVideo(item: WorkerAfterSalesCase): WorkerAcceptanceVideo | null {
|
||||
const snapshot = asRecord(item.acceptanceSnapshot)
|
||||
const acceptance = asRecord(snapshot.acceptance)
|
||||
const video = asRecord(acceptance.video)
|
||||
const url = String(video.url || '').trim()
|
||||
if (!url) return null
|
||||
return {
|
||||
objectKey: String(video.objectKey || video.object_key || ''),
|
||||
url,
|
||||
filename: String(video.filename || ''),
|
||||
contentType: String(video.contentType || video.content_type || ''),
|
||||
size: Number(video.size || 0),
|
||||
durationSeconds: Number(video.durationSeconds || video.duration_seconds || 0),
|
||||
}
|
||||
}
|
||||
|
||||
export default AfterSalesPanel
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
UserAddOutlined,
|
||||
WarningOutlined,
|
||||
RollbackOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popover,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
@@ -45,6 +47,7 @@ import JsonPreview from '@/components/admin/JsonPreview'
|
||||
import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline'
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||
import VideoPreview, { formatAcceptanceVideoDuration } from '@/components/files/VideoPreview'
|
||||
import {
|
||||
acceptAdminWorkOrder,
|
||||
acceptAdminWorkOrderShare,
|
||||
@@ -2603,6 +2606,11 @@ export default function WorkOrdersPanel() {
|
||||
{getAcceptanceFiles(detailOrder).length +
|
||||
getAcceptanceImageUrls(detailOrder).length || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="验收视频">
|
||||
{detailOrder.acceptance?.video
|
||||
? formatAcceptanceVideoDuration(detailOrder.acceptance.video.durationSeconds)
|
||||
: '未上传'}
|
||||
</Descriptions.Item>
|
||||
{detailOrder.acceptance?.reviewMode === 'vip_auto' ? (
|
||||
<Descriptions.Item label="VIP 补图">
|
||||
{detailOrder.acceptance.evidenceStatus === 'pending'
|
||||
@@ -2626,6 +2634,11 @@ export default function WorkOrdersPanel() {
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无验收图</Typography.Text>
|
||||
)}
|
||||
<VideoPreview
|
||||
video={detailOrder.acceptance?.video ?? null}
|
||||
maxHeight={200}
|
||||
emptyText="暂无验收视频(选填)"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -3205,7 +3218,7 @@ export default function WorkOrdersPanel() {
|
||||
loading={sharingQuery.isFetching}
|
||||
pagination={false}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1528 }}
|
||||
scroll={{ x: 1624 }}
|
||||
rowClassName={(share) =>
|
||||
share.problemMarked
|
||||
? 'admin-share-row-problem'
|
||||
@@ -3316,6 +3329,26 @@ export default function WorkOrdersPanel() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '验收视频',
|
||||
width: 96,
|
||||
render: (_, share) => {
|
||||
const video = share.acceptance?.video
|
||||
if (!video?.url) return '-'
|
||||
return (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
title="验收视频"
|
||||
content={<VideoPreview video={video} maxHeight={240} showMeta />}
|
||||
>
|
||||
<Button size="small" icon={<VideoCameraOutlined />}>
|
||||
查看
|
||||
</Button>
|
||||
</Popover>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
width: 120,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Alert, Button, Card, Descriptions, Drawer, Space, Steps, Tag, Typograph
|
||||
|
||||
import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline'
|
||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||
import VideoPreview, { formatAcceptanceVideoDuration } from '@/components/files/VideoPreview'
|
||||
import type { WorkOrder } from '@/types/worker-platform'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import { resolveOrderConsignmentScreenshotUrls } from '@/utils/consignment-display'
|
||||
@@ -104,7 +105,7 @@ export function WorkerOrderDetailDrawer({
|
||||
icon={<PictureOutlined />}
|
||||
onClick={() => onOpenAcceptance(order)}
|
||||
>
|
||||
{hasSubmittedAcceptance(order) ? '补充图片' : '上传图片 / 提交验收'}
|
||||
{hasSubmittedAcceptance(order) ? '补充验收资料' : '上传图片/视频 · 提交验收'}
|
||||
</Button>
|
||||
) : null}
|
||||
{canRequestCancel(order) ? (
|
||||
@@ -322,6 +323,7 @@ function GiftProgressCard({ order }: { order: WorkOrder }) {
|
||||
function AcceptanceSummary({ order }: { order: WorkOrder }) {
|
||||
const acceptanceFiles = getUniqueAcceptanceFiles(getWorkerAcceptance(order))
|
||||
const draftFiles = getUniqueAcceptanceFiles(getWorkerDraft(order))
|
||||
const acceptanceVideo = getWorkerAcceptance(order)?.video ?? getWorkerDraft(order)?.video ?? null
|
||||
const hasAcceptance = acceptanceFiles.length > 0
|
||||
const hasDraft = draftFiles.length > 0 && !hasSubmittedAcceptance(order)
|
||||
|
||||
@@ -329,7 +331,12 @@ function AcceptanceSummary({ order }: { order: WorkOrder }) {
|
||||
<div className="worker-order-detail-stack">
|
||||
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
||||
<Descriptions.Item label="验收图片数量">{acceptanceFiles.length}</Descriptions.Item>
|
||||
<Descriptions.Item label="提交时间">
|
||||
<Descriptions.Item label="验收视频">
|
||||
{acceptanceVideo
|
||||
? formatAcceptanceVideoDuration(acceptanceVideo.durationSeconds)
|
||||
: '未上传'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提交时间" span={2}>
|
||||
{formatDateTime(getAcceptanceSubmittedAt(order))}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="验收说明" span={2}>
|
||||
@@ -345,6 +352,8 @@ function AcceptanceSummary({ order }: { order: WorkOrder }) {
|
||||
<Typography.Text type="secondary">暂无已提交验收图片</Typography.Text>
|
||||
)}
|
||||
|
||||
<VideoPreview video={acceptanceVideo} maxHeight={180} emptyText="暂无验收视频(选填)" />
|
||||
|
||||
{hasDraft ? (
|
||||
<div className="acceptance-draft-badge-wrap" style={{ marginTop: 8 }}>
|
||||
<Tag color="processing" icon={<PictureOutlined />}>
|
||||
|
||||
@@ -476,7 +476,7 @@ function WorkerOrderMobileCard({
|
||||
className="worker-order-mobile-action-submit"
|
||||
onClick={() => onOpenAcceptance(order)}
|
||||
>
|
||||
{hasSubmittedAcceptance(order) ? '补充图片' : '提交验收'}
|
||||
{hasSubmittedAcceptance(order) ? '补充验收资料' : '上传图片/视频 · 提交验收'}
|
||||
</Button>
|
||||
) : null}
|
||||
{canRemindAcceptance(order) ? (
|
||||
|
||||
@@ -3,8 +3,10 @@ import { Button, Form, Input, Modal, Radio, Space, Tag, Typography } from 'antd'
|
||||
import type { FormInstance } from 'antd'
|
||||
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import VideoUpload from '@/components/files/VideoUpload'
|
||||
import type {
|
||||
UploadedFile,
|
||||
WorkerAcceptanceVideo,
|
||||
WorkerCancelRequestRisk,
|
||||
WorkerOrderFeedback,
|
||||
WorkOrder,
|
||||
@@ -59,10 +61,12 @@ export function WorkerOrderModals({
|
||||
onSaveNote,
|
||||
editingOrder,
|
||||
acceptanceFiles,
|
||||
acceptanceVideo,
|
||||
acceptanceForm,
|
||||
workerCanAutoAcceptWithoutEvidence,
|
||||
onCloseAcceptance,
|
||||
onAcceptanceFilesChange,
|
||||
onAcceptanceVideoChange,
|
||||
onSubmitAcceptance,
|
||||
onSaveAcceptanceImages,
|
||||
giftMaterialOrder,
|
||||
@@ -89,10 +93,12 @@ export function WorkerOrderModals({
|
||||
onSaveNote: (values: WorkerNoteFormValues) => void | Promise<void>
|
||||
editingOrder: WorkOrder | null
|
||||
acceptanceFiles: UploadedFile[]
|
||||
acceptanceVideo: WorkerAcceptanceVideo | null
|
||||
acceptanceForm: FormInstance<AcceptanceFormValues>
|
||||
workerCanAutoAcceptWithoutEvidence: boolean
|
||||
onCloseAcceptance: () => void
|
||||
onAcceptanceFilesChange: (files: UploadedFile[]) => void
|
||||
onAcceptanceVideoChange: (video: WorkerAcceptanceVideo | null) => void
|
||||
onSubmitAcceptance: (values: AcceptanceFormValues) => void | Promise<void>
|
||||
onSaveAcceptanceImages: (values: AcceptanceFormValues) => void | Promise<void>
|
||||
giftMaterialOrder: WorkOrder | null
|
||||
@@ -283,6 +289,12 @@ export function WorkerOrderModals({
|
||||
showPasteHint
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="验收视频(选填)"
|
||||
extra="选片后会在本机压到 720p 再上传;已提交的验收只能替换视频,不能删除。"
|
||||
>
|
||||
<VideoUpload value={acceptanceVideo} onChange={onAcceptanceVideoChange} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Modal
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
submitWorkerOrderFeedback,
|
||||
supplementWorkerAcceptedOrderEvidence,
|
||||
} from '@/services/worker'
|
||||
import type { UploadedFile, WorkOrder } from '@/types/worker-platform'
|
||||
import type { UploadedFile, WorkOrder, WorkerAcceptanceVideo } from '@/types/worker-platform'
|
||||
import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import { useIsMobile } from '@/utils/use-is-mobile'
|
||||
@@ -181,6 +181,9 @@ export default function WorkerOrdersPage() {
|
||||
const [cancelRequestOrder, setCancelRequestOrder] = useState<WorkOrder | null>(null)
|
||||
const [feedbackOrder, setFeedbackOrder] = useState<WorkOrder | null>(null)
|
||||
const [acceptanceFiles, setAcceptanceFiles] = useState<UploadedFile[]>([])
|
||||
/** 验收视频(单条,非必填);dirty 表示本次弹窗里换过视频,用于补图时允许"只补视频" */
|
||||
const [acceptanceVideo, setAcceptanceVideo] = useState<WorkerAcceptanceVideo | null>(null)
|
||||
const [acceptanceVideoDirty, setAcceptanceVideoDirty] = useState(false)
|
||||
const [giftMaterialOrder, setGiftMaterialOrder] = useState<WorkOrder | null>(null)
|
||||
const [giftMaterialFiles, setGiftMaterialFiles] = useState<UploadedFile[]>([])
|
||||
const [form] = Form.useForm<AcceptanceFormValues>()
|
||||
@@ -340,18 +343,20 @@ export default function WorkerOrdersPage() {
|
||||
const editingTarget = buildShareEditingOrder(order, shareId)
|
||||
setEditingShareId(shareId)
|
||||
setEditingOrder(editingTarget)
|
||||
setAcceptanceVideoDirty(false)
|
||||
const submitted = getWorkerAcceptance(editingTarget)
|
||||
if (canSupplementAcceptedEvidence(editingTarget)) {
|
||||
// 已验收补图只上传新增文件,原有验收图片由服务端保留。
|
||||
// 已验收补图只上传新增文件,原有验收图片由服务端保留;视频只能替换、不能删除。
|
||||
setAcceptanceFiles([])
|
||||
setAcceptanceVideo(submitted?.video ?? null)
|
||||
form.resetFields()
|
||||
setDetailOrder(null)
|
||||
return
|
||||
}
|
||||
// 已提交:以已提交的验收资料为初始内容;未提交:以暂存草稿为初始内容
|
||||
const source = hasSubmittedAcceptance(editingTarget)
|
||||
? getWorkerAcceptance(editingTarget)
|
||||
: getWorkerDraft(editingTarget)
|
||||
const source = hasSubmittedAcceptance(editingTarget) ? submitted : getWorkerDraft(editingTarget)
|
||||
setAcceptanceFiles(getUniqueAcceptanceFiles(source))
|
||||
setAcceptanceVideo(source?.video ?? null)
|
||||
form.setFieldsValue({ note: String(source?.note || '') })
|
||||
setDetailOrder(null)
|
||||
}
|
||||
@@ -484,22 +489,25 @@ export default function WorkerOrdersPage() {
|
||||
if (!editingOrder) return
|
||||
const isAcceptedEvidenceSupplement = canSupplementAcceptedEvidence(editingOrder)
|
||||
if (isAcceptedEvidenceSupplement) {
|
||||
if (acceptanceFiles.length === 0) {
|
||||
message.error('请上传新的验收图片')
|
||||
if (acceptanceFiles.length === 0 && !acceptanceVideoDirty) {
|
||||
message.error('请上传新的验收图片,或替换验收视频')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await supplementWorkerAcceptedOrderEvidence(editingOrder.workOrderId, {
|
||||
files: acceptanceFiles,
|
||||
...(acceptanceVideoDirty ? { video: acceptanceVideo } : {}),
|
||||
...(editingShareId ? { shareId: editingShareId } : {}),
|
||||
})
|
||||
message.success('验收图片已补充')
|
||||
message.success('验收资料已补充')
|
||||
setEditingOrder(null)
|
||||
setAcceptanceFiles([])
|
||||
setAcceptanceVideo(null)
|
||||
setAcceptanceVideoDirty(false)
|
||||
form.resetFields()
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '补充验收图片失败')
|
||||
message.error(error instanceof Error ? error.message : '补充验收资料失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -518,11 +526,14 @@ export default function WorkerOrdersPage() {
|
||||
await submitWorkerAcceptance(editingOrder.workOrderId, {
|
||||
note: values.note,
|
||||
files: acceptanceFiles,
|
||||
video: acceptanceVideo,
|
||||
...(editingShareId ? { shareId: editingShareId } : {}),
|
||||
})
|
||||
message.success(canAutoAccept ? '已自动验收,可在 24 小时内补充图片' : '已提交验收')
|
||||
setEditingOrder(null)
|
||||
setAcceptanceFiles([])
|
||||
setAcceptanceVideo(null)
|
||||
setAcceptanceVideoDirty(false)
|
||||
form.resetFields()
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
@@ -540,13 +551,16 @@ export default function WorkerOrdersPage() {
|
||||
await saveWorkerAcceptanceDraft(editingOrder.workOrderId, {
|
||||
note: values.note,
|
||||
files: acceptanceFiles,
|
||||
video: acceptanceVideo,
|
||||
...(editingShareId ? { shareId: editingShareId } : {}),
|
||||
})
|
||||
message.success(
|
||||
hasSubmittedAcceptance(editingOrder) ? '验收图片已更新' : '验收图片已暂存,可继续补充',
|
||||
hasSubmittedAcceptance(editingOrder) ? '验收资料已更新' : '验收资料已暂存,可继续补充',
|
||||
)
|
||||
setEditingOrder(null)
|
||||
setAcceptanceFiles([])
|
||||
setAcceptanceVideo(null)
|
||||
setAcceptanceVideoDirty(false)
|
||||
form.resetFields()
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
@@ -754,14 +768,21 @@ export default function WorkerOrdersPage() {
|
||||
onSaveNote={saveOrderNote}
|
||||
editingOrder={editingOrder}
|
||||
acceptanceFiles={acceptanceFiles}
|
||||
acceptanceVideo={acceptanceVideo}
|
||||
acceptanceForm={form}
|
||||
workerCanAutoAcceptWithoutEvidence={workerCanAutoAcceptWithoutEvidence}
|
||||
onCloseAcceptance={() => {
|
||||
setEditingOrder(null)
|
||||
setAcceptanceFiles([])
|
||||
setAcceptanceVideo(null)
|
||||
setAcceptanceVideoDirty(false)
|
||||
form.resetFields()
|
||||
}}
|
||||
onAcceptanceFilesChange={setAcceptanceFiles}
|
||||
onAcceptanceVideoChange={(video) => {
|
||||
setAcceptanceVideo(video)
|
||||
setAcceptanceVideoDirty(true)
|
||||
}}
|
||||
onSubmitAcceptance={submitAcceptance}
|
||||
onSaveAcceptanceImages={saveAcceptanceImages}
|
||||
giftMaterialOrder={giftMaterialOrder}
|
||||
|
||||
@@ -18,3 +18,16 @@ export async function uploadFile(file: File, scene: string, scope: UploadScope)
|
||||
const response = await apiPostForm<{ file: UploadedFile }>(endpoint, form)
|
||||
return response.data.file
|
||||
}
|
||||
|
||||
/**
|
||||
* 验收视频上传:视频已在浏览器内压好,这里不再做图片压缩。
|
||||
* 带上 durationSeconds 供服务端在容器解析不到时长时兜底(见 resolveVideoDuration)。
|
||||
*/
|
||||
export async function uploadVideoFile(file: File, scene: string, durationSeconds: number) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('scene', scene)
|
||||
form.append('durationSeconds', String(durationSeconds))
|
||||
const response = await apiPostForm<{ file: UploadedFile }>('/api/v1/worker/files/upload', form)
|
||||
return response.data.file
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
WorkerCancelRequest,
|
||||
WorkerCancelRequestRisk,
|
||||
WorkerAnnouncementConfig,
|
||||
WorkerAcceptanceConfig,
|
||||
WorkerAcceptanceVideo,
|
||||
WorkerOrderFeedback,
|
||||
WorkerAfterSalesCase,
|
||||
WorkerHallCategoriesResponse,
|
||||
@@ -143,6 +145,11 @@ export function fetchWorkerAnnouncement() {
|
||||
return apiGet<WorkerAnnouncementConfig>('/api/v1/worker/announcement')
|
||||
}
|
||||
|
||||
/** 验收视频配置(后台可改):时长上限、目标高度、码率等,压缩前读取。 */
|
||||
export function fetchWorkerAcceptanceConfig() {
|
||||
return apiGet<WorkerAcceptanceConfig>('/api/v1/worker/acceptance/config')
|
||||
}
|
||||
|
||||
export function fetchWorkerWalletLedgers(params?: Record<string, unknown>) {
|
||||
return apiGet<WorkerListResponse<WorkerWalletLedger>>(
|
||||
'/api/v1/worker/profile/wallet-ledgers',
|
||||
@@ -301,10 +308,16 @@ export function saveWorkerOrderNote(workOrderId: number, note: string) {
|
||||
return apiPost<{ order: WorkOrder }>(`/api/v1/worker/orders/${workOrderId}/note`, { note })
|
||||
}
|
||||
|
||||
export function submitWorkerAcceptance(
|
||||
workOrderId: number,
|
||||
payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[]; shareId?: number },
|
||||
) {
|
||||
export type WorkerAcceptancePayload = {
|
||||
note?: string
|
||||
imageUrls?: string[]
|
||||
files?: UploadedFile[]
|
||||
/** 验收视频(非必填);null 在草稿阶段表示删除,已提交阶段服务端只接受替换 */
|
||||
video?: WorkerAcceptanceVideo | null
|
||||
shareId?: number
|
||||
}
|
||||
|
||||
export function submitWorkerAcceptance(workOrderId: number, payload: WorkerAcceptancePayload) {
|
||||
return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>(
|
||||
`/api/v1/worker/orders/${workOrderId}/submit-acceptance`,
|
||||
payload,
|
||||
@@ -328,10 +341,7 @@ export function remindWorkerAcceptance(workOrderId: number) {
|
||||
)
|
||||
}
|
||||
|
||||
export function saveWorkerAcceptanceDraft(
|
||||
workOrderId: number,
|
||||
payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[]; shareId?: number },
|
||||
) {
|
||||
export function saveWorkerAcceptanceDraft(workOrderId: number, payload: WorkerAcceptancePayload) {
|
||||
return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>(
|
||||
`/api/v1/worker/orders/${workOrderId}/acceptance-draft`,
|
||||
payload,
|
||||
@@ -340,7 +350,7 @@ export function saveWorkerAcceptanceDraft(
|
||||
|
||||
export function supplementWorkerAcceptedOrderEvidence(
|
||||
workOrderId: number,
|
||||
payload: { files: UploadedFile[]; shareId?: number },
|
||||
payload: { files: UploadedFile[]; video?: WorkerAcceptanceVideo | null; shareId?: number },
|
||||
) {
|
||||
return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>(
|
||||
`/api/v1/worker/orders/${workOrderId}/acceptance-evidence`,
|
||||
|
||||
@@ -492,6 +492,31 @@ export type UploadedFile = {
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
/** 仅验收视频:服务端解析(或客户端上报)的时长,单位秒 */
|
||||
durationSeconds?: number
|
||||
}
|
||||
|
||||
/** 验收视频(单条,非必填)。 */
|
||||
export type WorkerAcceptanceVideo = {
|
||||
objectKey: string
|
||||
url: string
|
||||
filename?: string
|
||||
contentType?: string
|
||||
size?: number
|
||||
durationSeconds: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
/** 验收视频配置(来自 .env 的 WORKER_ACCEPTANCE_VIDEO_*,后台不提供配置界面)。 */
|
||||
export type WorkerAcceptanceConfig = {
|
||||
enabled: boolean
|
||||
maxDurationSeconds: number
|
||||
maxSizeMb: number
|
||||
targetHeight: number
|
||||
bitrateKbps: number
|
||||
audioBitrateKbps: number
|
||||
fps: number
|
||||
}
|
||||
|
||||
export type WorkOrderStatus =
|
||||
@@ -516,6 +541,8 @@ export type WorkerAcceptance = {
|
||||
note?: string
|
||||
files?: UploadedFile[]
|
||||
imageUrls?: string[]
|
||||
/** 验收视频(非必填):打手压缩后上传的单个视频 */
|
||||
video?: WorkerAcceptanceVideo | null
|
||||
submittedAt?: string
|
||||
updatedAt?: string
|
||||
reviewMode?: 'vip_auto' | string
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 验收视频:浏览器内探测 + 重编码(Canvas + MediaRecorder)。
|
||||
*
|
||||
* 为什么不用 ffmpeg.wasm:核心包 31MB、需要 COOP/COEP,iOS 内存易崩;
|
||||
* 这里只是把「游戏中录屏的 30 秒片段」压到 720p/1.2Mbps,WebM(VP8) 足够,
|
||||
* 且零依赖、不上传原片(打手多为手机流量)。
|
||||
*
|
||||
* 时长策略(产品已确认):**超过上限直接拒绝,不自动裁剪**。
|
||||
*/
|
||||
export type AcceptanceVideoConfig = {
|
||||
enabled: boolean
|
||||
maxDurationSeconds: number
|
||||
maxSizeMb: number
|
||||
targetHeight: number
|
||||
bitrateKbps: number
|
||||
audioBitrateKbps: number
|
||||
fps: number
|
||||
}
|
||||
|
||||
export type VideoProbeResult = {
|
||||
durationSeconds: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type CompressedVideo = {
|
||||
blob: Blob
|
||||
durationSeconds: number
|
||||
width: number
|
||||
height: number
|
||||
contentType: string
|
||||
extension: 'webm' | 'mp4'
|
||||
}
|
||||
|
||||
/** 按兼容性从高到低尝试;实际可用性由 MediaRecorder.isTypeSupported 决定。 */
|
||||
const MIME_CANDIDATES = [
|
||||
'video/webm;codecs=vp8,opus',
|
||||
'video/webm;codecs=vp9,opus',
|
||||
'video/webm',
|
||||
'video/mp4;codecs=avc1.42E01E,mp4a.40.2',
|
||||
'video/mp4',
|
||||
]
|
||||
|
||||
export function pickSupportedVideoMimeType(
|
||||
isSupported: (mime: string) => boolean = defaultMimeSupport,
|
||||
): string {
|
||||
for (const candidate of MIME_CANDIDATES) {
|
||||
if (isSupported(candidate)) return candidate
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 时长是否超出配置上限;返回空串表示通过。 */
|
||||
export function resolveVideoDurationLimitError(
|
||||
durationSeconds: number,
|
||||
maxDurationSeconds: number,
|
||||
): string {
|
||||
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0)
|
||||
return '无法读取视频时长,请更换视频'
|
||||
if (durationSeconds > maxDurationSeconds + 0.5) {
|
||||
return `视频时长不能超过 ${maxDurationSeconds} 秒,请先裁剪后再上传`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 按目标高度等比缩放(取偶数值,编码器要求宽高为偶数)。 */
|
||||
export function resolveVideoTargetSize(
|
||||
width: number,
|
||||
height: number,
|
||||
targetHeight: number,
|
||||
): { width: number; height: number } {
|
||||
const safeWidth = Math.max(2, Math.round(width || 0))
|
||||
const safeHeight = Math.max(2, Math.round(height || 0))
|
||||
const limit = Math.max(2, Math.round(targetHeight || 720))
|
||||
const scale = Math.min(1, limit / safeHeight)
|
||||
return {
|
||||
width: ensureEven(safeWidth * scale),
|
||||
height: ensureEven(safeHeight * scale),
|
||||
}
|
||||
}
|
||||
|
||||
export function isVideoCompressionSupported(): boolean {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return false
|
||||
if (typeof MediaRecorder === 'undefined') return false
|
||||
if (typeof HTMLCanvasElement === 'undefined') return false
|
||||
if (typeof HTMLCanvasElement.prototype.captureStream !== 'function') return false
|
||||
return Boolean(pickSupportedVideoMimeType())
|
||||
}
|
||||
|
||||
/** 读取视频时长/分辨率;部分 WebM 的 duration 为 Infinity,这里做一次定位探测。 */
|
||||
export function probeVideoFile(file: File): Promise<VideoProbeResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file)
|
||||
const video = document.createElement('video')
|
||||
let settled = false
|
||||
const cleanup = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
video.removeAttribute('src')
|
||||
video.load()
|
||||
}
|
||||
const finish = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
const durationSeconds = Number.isFinite(video.duration) ? video.duration : 0
|
||||
const width = video.videoWidth
|
||||
const height = video.videoHeight
|
||||
cleanup()
|
||||
if (!durationSeconds || durationSeconds <= 0 || !width || !height) {
|
||||
reject(new Error('无法读取视频信息,请更换视频或先裁剪'))
|
||||
return
|
||||
}
|
||||
resolve({ durationSeconds, width, height })
|
||||
}
|
||||
|
||||
video.preload = 'metadata'
|
||||
video.muted = true
|
||||
video.src = url
|
||||
video.onloadedmetadata = () => {
|
||||
if (Number.isFinite(video.duration) && video.duration > 0) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// WebM 常见:duration = Infinity。跳到极远处逼浏览器计算真实时长。
|
||||
video.currentTime = 1e101
|
||||
const timer = setTimeout(finish, 1500)
|
||||
video.ondurationchange = () => {
|
||||
clearTimeout(timer)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
video.onerror = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(new Error('无法读取视频信息,请更换视频或先裁剪'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩到配置参数并返回新的 Blob;超过时长上限直接抛错(不裁剪)。
|
||||
* onProgress 回调 0~1,用于展示"压缩中"进度。
|
||||
*/
|
||||
export async function compressVideoForUpload(
|
||||
file: File,
|
||||
config: AcceptanceVideoConfig,
|
||||
onProgress?: (ratio: number) => void,
|
||||
): Promise<CompressedVideo> {
|
||||
if (!isVideoCompressionSupported()) {
|
||||
throw new Error('当前浏览器不支持视频压缩,请使用 Chrome / Edge,或先裁剪视频后再上传')
|
||||
}
|
||||
const mimeType = pickSupportedVideoMimeType()
|
||||
const probe = await probeVideoFile(file)
|
||||
const limitError = resolveVideoDurationLimitError(
|
||||
probe.durationSeconds,
|
||||
config.maxDurationSeconds,
|
||||
)
|
||||
if (limitError) throw new Error(limitError)
|
||||
|
||||
const target = resolveVideoTargetSize(probe.width, probe.height, config.targetHeight)
|
||||
const url = URL.createObjectURL(file)
|
||||
const video = document.createElement('video')
|
||||
const canvas = document.createElement('canvas')
|
||||
let audioContext: AudioContext | null = null
|
||||
let stopped = false
|
||||
let frameHandle = 0
|
||||
|
||||
const stopRecording = () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
try {
|
||||
video.pause()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
video.src = url
|
||||
video.muted = true
|
||||
video.playsInline = true
|
||||
video.preload = 'auto'
|
||||
await waitForEvent(video, 'loadeddata')
|
||||
|
||||
canvas.width = target.width
|
||||
canvas.height = target.height
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('当前浏览器不支持视频压缩,请更换浏览器')
|
||||
|
||||
const stream = canvas.captureStream(Math.max(15, Math.min(30, config.fps || 30)))
|
||||
try {
|
||||
const AudioContextCtor =
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||
if (AudioContextCtor) {
|
||||
audioContext = new AudioContextCtor()
|
||||
const source = audioContext.createMediaElementSource(video)
|
||||
const destination = audioContext.createMediaStreamDestination()
|
||||
source.connect(destination)
|
||||
for (const track of destination.stream.getAudioTracks()) stream.addTrack(track)
|
||||
}
|
||||
} catch {
|
||||
// 视频无音轨或浏览器限制:退化为无声视频,不阻断压缩。
|
||||
}
|
||||
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
mimeType,
|
||||
videoBitsPerSecond: Math.max(300, Number(config.bitrateKbps) || 1200) * 1000,
|
||||
audioBitsPerSecond: Math.max(32, Number(config.audioBitrateKbps) || 64) * 1000,
|
||||
})
|
||||
const chunks: Blob[] = []
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0) chunks.push(event.data)
|
||||
}
|
||||
const recorderStopped = new Promise<void>((resolve) => {
|
||||
recorder.onstop = () => resolve()
|
||||
})
|
||||
|
||||
const drawFrame = () => {
|
||||
if (stopped) return
|
||||
if (video.readyState >= 2) {
|
||||
context.drawImage(video, 0, 0, target.width, target.height)
|
||||
}
|
||||
onProgress?.(Math.max(0, Math.min(1, video.currentTime / probe.durationSeconds)))
|
||||
if (video.ended || video.currentTime >= config.maxDurationSeconds) {
|
||||
stopRecording()
|
||||
return
|
||||
}
|
||||
frameHandle = requestAnimationFrame(drawFrame)
|
||||
}
|
||||
|
||||
recorder.start(1000)
|
||||
await video.play()
|
||||
frameHandle = requestAnimationFrame(drawFrame)
|
||||
video.addEventListener('ended', stopRecording, { once: true })
|
||||
|
||||
// 防呆:视频卡住时最多等「时长 + 10 秒」,避免压缩界面永久转圈。
|
||||
const timeoutHandle = setTimeout(stopRecording, (probe.durationSeconds + 10) * 1000)
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (stopped) {
|
||||
clearInterval(interval)
|
||||
resolve()
|
||||
}
|
||||
}, 200)
|
||||
})
|
||||
clearTimeout(timeoutHandle)
|
||||
if (recorder.state !== 'inactive') recorder.stop()
|
||||
await recorderStopped
|
||||
if (frameHandle) cancelAnimationFrame(frameHandle)
|
||||
|
||||
const blob = new Blob(chunks, { type: mimeType.split(';')[0] })
|
||||
if (!blob.size) throw new Error('视频压缩失败,请重试或更换视频')
|
||||
const contentType = blob.type || 'video/webm'
|
||||
onProgress?.(1)
|
||||
return {
|
||||
blob,
|
||||
durationSeconds: Math.min(probe.durationSeconds, config.maxDurationSeconds),
|
||||
width: target.width,
|
||||
height: target.height,
|
||||
contentType,
|
||||
extension: contentType.includes('mp4') ? 'mp4' : 'webm',
|
||||
}
|
||||
} finally {
|
||||
if (frameHandle) cancelAnimationFrame(frameHandle)
|
||||
try {
|
||||
audioContext?.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
URL.revokeObjectURL(url)
|
||||
video.removeAttribute('src')
|
||||
video.load()
|
||||
}
|
||||
}
|
||||
|
||||
function defaultMimeSupport(mime: string): boolean {
|
||||
if (typeof MediaRecorder === 'undefined') return false
|
||||
return typeof MediaRecorder.isTypeSupported === 'function'
|
||||
? MediaRecorder.isTypeSupported(mime)
|
||||
: false
|
||||
}
|
||||
|
||||
function ensureEven(value: number): number {
|
||||
const rounded = Math.max(2, Math.round(value))
|
||||
return rounded % 2 === 0 ? rounded : rounded - 1
|
||||
}
|
||||
|
||||
function waitForEvent(target: HTMLMediaElement, eventName: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onEvent = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const onError = () => {
|
||||
cleanup()
|
||||
reject(new Error('视频读取失败,请更换文件'))
|
||||
}
|
||||
const cleanup = () => {
|
||||
target.removeEventListener(eventName, onEvent)
|
||||
target.removeEventListener('error', onError)
|
||||
}
|
||||
target.addEventListener(eventName, onEvent, { once: true })
|
||||
target.addEventListener('error', onError, { once: true })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user