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>
+57
View File
@@ -305,6 +305,63 @@ select {
.image-upload-hint {
font-size: 12px;
line-height: 1.5;
clear: both;
}
/* antd 的 picture-card 列表可能是浮动/行内块,让外层独占整行,避免相机按钮被挤到同一行 */
.image-upload-droppable > .ant-upload-wrapper {
display: block;
width: 100%;
}
/* 移动端系统相机入口:隐藏原生 file input,仅保留按钮 */
.image-upload-camera {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
/* 强制独占一行:缩略图列表可能是行内块/浮动,clear + flex-basis 保证按钮落到下一行 */
clear: both;
flex: 1 0 100%;
justify-self: stretch;
margin-top: 8px;
}
/*
* 相机 file input 用「移出视口」隐藏,而不是 display:none:
* 部分 iOS Safari 版本不允许对 display:none 的 file input 触发 .click(),会表现为点了没反应。
*/
.image-upload-camera-input,
.worker-acceptance-video-camera-input {
position: fixed;
top: 0;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.worker-acceptance-video-preview {
display: flex;
flex-direction: column;
gap: 8px;
}
.worker-acceptance-video-player {
width: 100%;
max-width: 360px;
max-height: 240px;
border-radius: 6px;
background: #000;
}
.worker-acceptance-video-meta {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.image-preview-list {
+44
View File
@@ -0,0 +1,44 @@
/** 上传场景:只有打手端(worker)在移动设备上需要直接调起系统相机。 */
export type CameraEntryScope = 'admin' | 'worker' | 'collect'
/** 相机捕获类型:图片走拍照,视频走拍摄。 */
export type CameraCaptureKind = 'image' | 'video'
type ResolveCameraEntryOptions = {
/** 当前是否为移动端布局(useIsMobile,宽度 <= 768) */
isMobile: boolean
/** 上传作用域 */
scope?: CameraEntryScope | (string & {})
/** 显式开关:传了就以此为准(true 强制显示,false 强制隐藏) */
allowCamera?: boolean
}
/**
* 是否显示「拍照 / 拍摄」入口。
*
* 规则:默认仅「打手端 + 移动端」显示。管理端/采集端仍只保留相册选择;
* 桌面端(宽度 > 768)隐藏,避免出现点了只会打开文件选择的无效入口。
*
* 说明:这里用 `<input type="file" capture="environment">` 调起系统相机,
* 不依赖 getUserMedia,因此在局域网 HTTP(如 http://192.168.10.8:8080)下同样可用。
*/
export function resolveCameraEntryEnabled(options: ResolveCameraEntryOptions): boolean {
if (typeof options.allowCamera === 'boolean') return options.allowCamera
return options.isMobile && options.scope === 'worker'
}
/**
* 系统相机 file input 的属性。
*
* `capture="environment"` 让移动浏览器优先打开后置摄像头;iOS Safari 的部分版本会
* 忽略该属性并弹出「拍照 / 从相册选择」菜单,属于预期内的降级行为。
*/
export function cameraCaptureInputProps(kind: CameraCaptureKind): {
accept: string
capture: 'environment'
} {
return {
accept: kind === 'image' ? 'image/*' : 'video/*',
capture: 'environment',
}
}
+1 -1
View File
@@ -264,7 +264,7 @@ export async function compressVideoForUpload(
} finally {
if (frameHandle) cancelAnimationFrame(frameHandle)
try {
audioContext?.close()
void audioContext?.close()
} catch {
// ignore
}
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { cameraCaptureInputProps, resolveCameraEntryEnabled } from '../src/utils/camera-capture.ts'
test('相机入口:仅打手端移动端显示', () => {
assert.equal(resolveCameraEntryEnabled({ isMobile: true, scope: 'worker' }), true)
assert.equal(resolveCameraEntryEnabled({ isMobile: false, scope: 'worker' }), false)
assert.equal(resolveCameraEntryEnabled({ isMobile: true, scope: 'admin' }), false)
assert.equal(resolveCameraEntryEnabled({ isMobile: true, scope: 'collect' }), false)
assert.equal(resolveCameraEntryEnabled({ isMobile: true }), false)
})
test('相机入口:allowCamera 显式开关优先', () => {
assert.equal(
resolveCameraEntryEnabled({ isMobile: true, scope: 'worker', allowCamera: false }),
false,
)
assert.equal(
resolveCameraEntryEnabled({ isMobile: false, scope: 'admin', allowCamera: true }),
true,
)
})
test('相机构造:图片/视频分别使用对应 accept 与后置摄像头', () => {
assert.deepEqual(cameraCaptureInputProps('image'), {
accept: 'image/*',
capture: 'environment',
})
assert.deepEqual(cameraCaptureInputProps('video'), {
accept: 'video/*',
capture: 'environment',
})
})
+9
View File
@@ -55,6 +55,15 @@ export default defineConfig({
server: {
host: '0.0.0.0',
port: 5173,
/**
* Windows 下 Docker 绑定挂载(D:\... -> /app)收不到 inotify 事件,Vite 的文件监听
* 会失效:代码改了页面却一直不更新(和后端 tsx watch 同类问题)。
* 改用轮询,改文件后 HMR 才能正常触发。
*/
watch: {
usePolling: true,
interval: 300,
},
// 隧道/自定义域名访问 dev server 时需加入白名单,否则 Vite 会拦截 Host
allowedHosts,
proxy: {