add:如果打手端使用的设备是移动端,验证图片和验证视频上传的时候加个调用相机的功能
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user