From 0d3dd3eacd49a14939ffd04e94c7323ad1166406 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sat, 24 Oct 2026 17:03:57 +0800 Subject: [PATCH] =?UTF-8?q?add:=E5=A6=82=E6=9E=9C=E6=89=93=E6=89=8B?= =?UTF-8?q?=E7=AB=AF=E4=BD=BF=E7=94=A8=E7=9A=84=E8=AE=BE=E5=A4=87=E6=98=AF?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=EF=BC=8C=E9=AA=8C=E8=AF=81=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=92=8C=E9=AA=8C=E8=AF=81=E8=A7=86=E9=A2=91=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E7=9A=84=E6=97=B6=E5=80=99=E5=8A=A0=E4=B8=AA=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E7=9B=B8=E6=9C=BA=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/files/ImageUpload.tsx | 48 +++++++++- .../src/components/files/VideoUpload.tsx | 92 ++++++++++++++++--- apps/frontend/src/styles/base.css | 57 ++++++++++++ apps/frontend/src/utils/camera-capture.ts | 44 +++++++++ apps/frontend/src/utils/video-compress.ts | 2 +- apps/frontend/tests/camera-capture.test.ts | 34 +++++++ apps/frontend/vite.config.ts | 9 ++ 7 files changed, 266 insertions(+), 20 deletions(-) create mode 100644 apps/frontend/src/utils/camera-capture.ts create mode 100644 apps/frontend/tests/camera-capture.test.ts diff --git a/apps/frontend/src/components/files/ImageUpload.tsx b/apps/frontend/src/components/files/ImageUpload.tsx index 6ecaf21e..9a117f83 100644 --- a/apps/frontend/src/components/files/ImageUpload.tsx +++ b/apps/frontend/src/components/files/ImageUpload.tsx @@ -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(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) { + const file = event.target.files?.[0] + // 让「再拍同一张」也能再次触发 change。 + event.target.value = '' + if (!file) return + await uploadImages([file]) + } + function handlePaste(event: ClipboardEvent) { const files = Array.from(event.clipboardData?.files || []) if (files.length === 0) return @@ -237,9 +256,30 @@ export default function ImageUpload({ )} + {cameraEnabled && value.length < maxCount ? ( +
+ void handleCameraSelect(event)} + /> + +
+ ) : null} {showPasteHint ? ( - 点击选择图片上传;截图后可直接按 Ctrl+V / ⌘V 粘贴,或拖拽图片到此处 + {cameraEnabled + ? '点击「上传」从相册选择,或点「拍照」直接调用相机' + : '点击选择图片上传;截图后可直接按 Ctrl+V / ⌘V 粘贴,或拖拽图片到此处'} ) : null} diff --git a/apps/frontend/src/components/files/VideoUpload.tsx b/apps/frontend/src/components/files/VideoUpload.tsx index 658b7c31..6a6408e1 100644 --- a/apps/frontend/src/components/files/VideoUpload.tsx +++ b/apps/frontend/src/components/files/VideoUpload.tsx @@ -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(null) + const cameraInputRef = useRef(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) { - 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) { + 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 ? ( + + ) : null} {value ? (
@@ -134,7 +168,17 @@ export default function VideoUpload({ {formatDuration(value.durationSeconds)} · {formatSize(value.size)} - + + {cameraEnabled ? ( + + ) : null} @@ -152,13 +196,31 @@ export default function VideoUpload({
) : ( - + {busy ? ( + + ) : ( + + {cameraEnabled ? ( + + ) : null} + + + )} {hint} diff --git a/apps/frontend/src/styles/base.css b/apps/frontend/src/styles/base.css index 4a725157..6aec5f35 100644 --- a/apps/frontend/src/styles/base.css +++ b/apps/frontend/src/styles/base.css @@ -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 { diff --git a/apps/frontend/src/utils/camera-capture.ts b/apps/frontend/src/utils/camera-capture.ts new file mode 100644 index 00000000..7d046737 --- /dev/null +++ b/apps/frontend/src/utils/camera-capture.ts @@ -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)隐藏,避免出现点了只会打开文件选择的无效入口。 + * + * 说明:这里用 `` 调起系统相机, + * 不依赖 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', + } +} diff --git a/apps/frontend/src/utils/video-compress.ts b/apps/frontend/src/utils/video-compress.ts index 8f287785..70db2c77 100644 --- a/apps/frontend/src/utils/video-compress.ts +++ b/apps/frontend/src/utils/video-compress.ts @@ -264,7 +264,7 @@ export async function compressVideoForUpload( } finally { if (frameHandle) cancelAnimationFrame(frameHandle) try { - audioContext?.close() + void audioContext?.close() } catch { // ignore } diff --git a/apps/frontend/tests/camera-capture.test.ts b/apps/frontend/tests/camera-capture.test.ts new file mode 100644 index 00000000..21c15e46 --- /dev/null +++ b/apps/frontend/tests/camera-capture.test.ts @@ -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', + }) +}) diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 48dbd160..780847ec 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -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: {