add:如果打手端使用的设备是移动端,验证图片和验证视频上传的时候加个调用相机的功能

This commit is contained in:
2026-10-24 17:03:57 +08:00
parent 0ee5554f57
commit 0d3dd3eacd
7 changed files with 266 additions and 20 deletions
@@ -1,11 +1,13 @@
import { CloudUploadOutlined, PlusOutlined } from '@ant-design/icons'
import { App, Typography, Upload } from 'antd'
import { CameraOutlined, CloudUploadOutlined, PlusOutlined } from '@ant-design/icons'
import { App, Button, Typography, Upload } from 'antd'
import type { UploadFile, UploadProps } from 'antd'
import { useEffect, useRef, useState } from 'react'
import type { ClipboardEvent, DragEvent, FocusEvent, MouseEvent } from 'react'
import type { ChangeEvent, ClipboardEvent, DragEvent, FocusEvent, MouseEvent } from 'react'
import { uploadFile } from '@/services/files'
import type { UploadedFile } from '@/types/worker-platform'
import { cameraCaptureInputProps, resolveCameraEntryEnabled } from '@/utils/camera-capture'
import { useIsMobile } from '@/utils/use-is-mobile'
type ImageUploadProps = {
value?: UploadedFile[]
@@ -16,6 +18,8 @@ type ImageUploadProps = {
showPasteHint?: boolean
/** 开启后,页面任意位置粘贴剪贴板截图都会上传到本组件(多实例时以最近点击/挂载的为准) */
captureGlobalPaste?: boolean
/** 是否显示移动端「拍照」入口;不传时按「打手端 + 移动端」自动判断 */
allowCamera?: boolean
}
type GlobalPasteTarget = {
@@ -62,14 +66,20 @@ export default function ImageUpload({
maxCount = 6,
showPasteHint = false,
captureGlobalPaste = false,
allowCamera,
}: ImageUploadProps) {
const { message } = App.useApp()
const isMobile = useIsMobile()
const [isDragging, setIsDragging] = useState(false)
const cameraInputRef = useRef<HTMLInputElement | null>(null)
const dragCounterRef = useRef(0)
const targetIdRef = useRef(0)
const valueRef = useRef(value)
valueRef.current = value
const cameraEnabled = resolveCameraEntryEnabled({ isMobile, scope, allowCamera })
const cameraInputProps = cameraCaptureInputProps('image')
const fileList: UploadFile[] = value.map((file) => ({
uid: file.objectKey || file.url,
name: file.filename || '图片',
@@ -105,6 +115,15 @@ export default function ImageUpload({
await uploadImages(images, source)
}
/** 系统相机拍照:一次一张,省去 multiple(部分浏览器在 capture 模式下会忽略多选)。 */
async function handleCameraSelect(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
// 让「再拍同一张」也能再次触发 change。
event.target.value = ''
if (!file) return
await uploadImages([file])
}
function handlePaste(event: ClipboardEvent<HTMLDivElement>) {
const files = Array.from(event.clipboardData?.files || [])
if (files.length === 0) return
@@ -237,9 +256,30 @@ export default function ImageUpload({
</button>
)}
</Upload>
{cameraEnabled && value.length < maxCount ? (
<div className="image-upload-camera">
<input
ref={cameraInputRef}
type="file"
accept={cameraInputProps.accept}
capture={cameraInputProps.capture}
className="image-upload-camera-input"
onChange={(event) => void handleCameraSelect(event)}
/>
<Button
size="small"
icon={<CameraOutlined />}
onClick={() => cameraInputRef.current?.click()}
>
拍照
</Button>
</div>
) : null}
{showPasteHint ? (
<Typography.Text type="secondary" className="image-upload-hint">
点击选择图片上传;截图后可直接按 Ctrl+V / ⌘V 粘贴,或拖拽图片到此处
{cameraEnabled
? '点击「上传」从相册选择,或点「拍照」直接调用相机'
: '点击选择图片上传;截图后可直接按 Ctrl+V / ⌘V 粘贴,或拖拽图片到此处'}
</Typography.Text>
) : null}
</div>
@@ -1,4 +1,9 @@
import { DeleteOutlined, LoadingOutlined, VideoCameraOutlined } from '@ant-design/icons'
import {
CameraOutlined,
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'
@@ -6,6 +11,8 @@ 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 { cameraCaptureInputProps, resolveCameraEntryEnabled } from '@/utils/camera-capture'
import { useIsMobile } from '@/utils/use-is-mobile'
import {
compressVideoForUpload,
isVideoCompressionSupported,
@@ -18,6 +25,8 @@ type VideoUploadProps = {
value?: WorkerAcceptanceVideo | null
onChange?: (video: WorkerAcceptanceVideo | null) => void
disabled?: boolean
/** 是否显示移动端「拍摄」入口;不传时按移动端自动判断(本组件仅用于打手端) */
allowCamera?: boolean
}
const VIDEO_ACCEPT = 'video/mp4,video/webm,video/quicktime,video/*'
@@ -28,17 +37,26 @@ const VIDEO_ACCEPT = 'video/mp4,video/webm,video/quicktime,video/*'
* 流程:选片 → 读时长/分辨率 → 超过配置上限直接拒绝(不裁剪)→ Canvas 重编码为
* 720p/1.2Mbps(见 utils/video-compress)→ 上传 → 回填视频信息。
* 浏览器不支持 MediaRecorder / captureStream 时直接提示更换浏览器,不上传原片。
*
* 移动端额外提供「拍摄 / 重拍」入口(capture=environment 调起系统相机);
* 录制时长由系统相机控制,HTML 无法强制限制,超长由时长校验拦下。
*/
export default function VideoUpload({
value = null,
onChange,
disabled = false,
allowCamera,
}: VideoUploadProps) {
const { message } = App.useApp()
const isMobile = useIsMobile()
const inputRef = useRef<HTMLInputElement | null>(null)
const cameraInputRef = useRef<HTMLInputElement | null>(null)
const [phase, setPhase] = useState<'idle' | 'compressing' | 'uploading'>('idle')
const [progress, setProgress] = useState(0)
const cameraEnabled = resolveCameraEntryEnabled({ isMobile, scope: 'worker', allowCamera })
const cameraInputProps = cameraCaptureInputProps('video')
const configQuery = useQuery({
queryKey: ['worker-acceptance-config'],
queryFn: fetchWorkerAcceptanceConfig,
@@ -51,10 +69,7 @@ export default function VideoUpload({
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 = ''
async function processFile(file: File | undefined) {
if (!file || !config) return
if (!config.enabled) {
message.error('验收视频功能暂未开启')
@@ -107,8 +122,17 @@ export default function VideoUpload({
}
}
function handleSelect(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
// 让「选择/拍摄同一个文件」也能再次触发 change。
event.target.value = ''
void processFile(file)
}
const hint = config
? `选填,最长 ${config.maxDurationSeconds} 秒;上传前会自动压缩到 ${config.targetHeight}p,约 ${estimatedSizeMb(config)}MB 以内`
? cameraEnabled
? `选填,拍摄请控制在 ${config.maxDurationSeconds} 秒内(超时会被拒绝);上传前自动压缩到 ${config.targetHeight}p,约 ${estimatedSizeMb(config)}MB 以内`
: `选填,最长 ${config.maxDurationSeconds} 秒;上传前会自动压缩到 ${config.targetHeight}p,约 ${estimatedSizeMb(config)}MB 以内`
: '选填;正在读取验收视频配置…'
return (
@@ -118,8 +142,18 @@ export default function VideoUpload({
type="file"
accept={VIDEO_ACCEPT}
style={{ display: 'none' }}
onChange={(event) => void handleSelect(event)}
onChange={handleSelect}
/>
{cameraEnabled ? (
<input
ref={cameraInputRef}
type="file"
accept={cameraInputProps.accept}
capture={cameraInputProps.capture}
className="worker-acceptance-video-camera-input"
onChange={handleSelect}
/>
) : null}
{value ? (
<div className="worker-acceptance-video-preview">
@@ -134,7 +168,17 @@ export default function VideoUpload({
<Typography.Text type="secondary">
{formatDuration(value.durationSeconds)} · {formatSize(value.size)}
</Typography.Text>
<Space size={8}>
<Space size={8} wrap>
{cameraEnabled ? (
<Button
size="small"
icon={<CameraOutlined />}
disabled={!canPick}
onClick={() => cameraInputRef.current?.click()}
>
重拍
</Button>
) : null}
<Button size="small" disabled={!canPick} onClick={() => inputRef.current?.click()}>
替换
</Button>
@@ -152,13 +196,31 @@ export default function VideoUpload({
</div>
) : (
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Button
icon={busy ? <LoadingOutlined /> : <VideoCameraOutlined />}
disabled={!canPick}
onClick={() => inputRef.current?.click()}
>
{busy ? '处理中…' : '选择验收视频'}
</Button>
{busy ? (
<Button icon={<LoadingOutlined />} disabled>
处理中…
</Button>
) : (
<Space size={8} wrap>
{cameraEnabled ? (
<Button
type="primary"
icon={<CameraOutlined />}
disabled={!canPick}
onClick={() => cameraInputRef.current?.click()}
>
拍摄验收视频
</Button>
) : null}
<Button
icon={<VideoCameraOutlined />}
disabled={!canPick}
onClick={() => inputRef.current?.click()}
>
{cameraEnabled ? '从相册选择' : '选择验收视频'}
</Button>
</Space>
)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{hint}
</Typography.Text>