fix:相机优化

This commit is contained in:
2026-10-25 16:16:27 +08:00
parent 027cfc95b1
commit 6993c36efb
6 changed files with 158 additions and 65 deletions
@@ -71,7 +71,6 @@ export default function ImageUpload({
const { message } = App.useApp() const { message } = App.useApp()
const isMobile = useIsMobile() const isMobile = useIsMobile()
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const cameraInputRef = useRef<HTMLInputElement | null>(null)
const dragCounterRef = useRef(0) const dragCounterRef = useRef(0)
const targetIdRef = useRef(0) const targetIdRef = useRef(0)
const valueRef = useRef(value) const valueRef = useRef(value)
@@ -258,21 +257,25 @@ export default function ImageUpload({
</Upload> </Upload>
{cameraEnabled && value.length < maxCount ? ( {cameraEnabled && value.length < maxCount ? (
<div className="image-upload-camera"> <div className="image-upload-camera">
<input {/*
ref={cameraInputRef} 相机入口用「透明原生 input 铺满按钮」的方式:
type="file" 用户手指直接点在 input 上,浏览器才会把它当作拍照手势;
accept={cameraInputProps.accept} 旧实现把 input 移到 left:-9999px 再由按钮脚本 .click(),部分安卓 WebView
capture={cameraInputProps.capture} 会把相机 intent 降级成文件选择(表现为点了「拍照」却弹出文件浏览)。
className="image-upload-camera-input" */}
onChange={(event) => void handleCameraSelect(event)} <span className="camera-pick-trigger">
/> <input
<Button type="file"
size="small" accept={cameraInputProps.accept}
icon={<CameraOutlined />} capture={cameraInputProps.capture}
onClick={() => cameraInputRef.current?.click()} className="image-upload-camera-input"
> aria-label="拍照"
拍照 onChange={(event) => void handleCameraSelect(event)}
</Button> />
<Button size="small" icon={<CameraOutlined />} tabIndex={-1}>
拍照
</Button>
</span>
</div> </div>
) : null} ) : null}
{showPasteHint ? ( {showPasteHint ? (
@@ -50,7 +50,6 @@ export default function VideoUpload({
const { message } = App.useApp() const { message } = App.useApp()
const isMobile = useIsMobile() const isMobile = useIsMobile()
const inputRef = useRef<HTMLInputElement | null>(null) const inputRef = useRef<HTMLInputElement | null>(null)
const cameraInputRef = useRef<HTMLInputElement | null>(null)
const [phase, setPhase] = useState<'idle' | 'compressing' | 'uploading'>('idle') const [phase, setPhase] = useState<'idle' | 'compressing' | 'uploading'>('idle')
const [progress, setProgress] = useState(0) const [progress, setProgress] = useState(0)
@@ -144,16 +143,6 @@ export default function VideoUpload({
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleSelect} onChange={handleSelect}
/> />
{cameraEnabled ? (
<input
ref={cameraInputRef}
type="file"
accept={cameraInputProps.accept}
capture={cameraInputProps.capture}
className="worker-acceptance-video-camera-input"
onChange={handleSelect}
/>
) : null}
{value ? ( {value ? (
<div className="worker-acceptance-video-preview"> <div className="worker-acceptance-video-preview">
@@ -170,14 +159,22 @@ export default function VideoUpload({
</Typography.Text> </Typography.Text>
<Space size={8} wrap> <Space size={8} wrap>
{cameraEnabled ? ( {cameraEnabled ? (
<Button // 透明原生 input 铺满按钮:真实手势点 input 才会被当作「拍摄」,
size="small" // 脚本 click 在部分安卓 WebView 会退化成文件选择。
icon={<CameraOutlined />} <span className="camera-pick-trigger">
disabled={!canPick} <input
onClick={() => cameraInputRef.current?.click()} type="file"
> accept={cameraInputProps.accept}
重拍 capture={cameraInputProps.capture}
</Button> className="worker-acceptance-video-camera-input"
aria-label="重拍验收视频"
disabled={!canPick}
onChange={handleSelect}
/>
<Button size="small" icon={<CameraOutlined />} disabled={!canPick} tabIndex={-1}>
重拍
</Button>
</span>
) : null} ) : null}
<Button size="small" disabled={!canPick} onClick={() => inputRef.current?.click()}> <Button size="small" disabled={!canPick} onClick={() => inputRef.current?.click()}>
替换 替换
@@ -203,14 +200,26 @@ export default function VideoUpload({
) : ( ) : (
<Space size={8} wrap> <Space size={8} wrap>
{cameraEnabled ? ( {cameraEnabled ? (
<Button // 同「重拍」:透明原生 input 铺满按钮,靠真实手势调起系统相机。
type="primary" <span className="camera-pick-trigger">
icon={<CameraOutlined />} <input
disabled={!canPick} type="file"
onClick={() => cameraInputRef.current?.click()} accept={cameraInputProps.accept}
> capture={cameraInputProps.capture}
拍摄验收视频 className="worker-acceptance-video-camera-input"
</Button> aria-label="拍摄验收视频"
disabled={!canPick}
onChange={handleSelect}
/>
<Button
type="primary"
icon={<CameraOutlined />}
disabled={!canPick}
tabIndex={-1}
>
拍摄验收视频
</Button>
</span>
) : null} ) : null}
<Button <Button
icon={<VideoCameraOutlined />} icon={<VideoCameraOutlined />}
+23 -8
View File
@@ -327,18 +327,33 @@ select {
margin-top: 8px; margin-top: 8px;
} }
/* /**
* 相机 file input 用「移出视口」隐藏,而不是 display:none: * 移动端系统相机入口:
* 部分 iOS Safari 版本不允许对 display:none 的 file input 触发 .click(),会表现为点了没反应。 * 原生 input 透明铺满按钮,用户手指直接点在 input 上(真实手势),
* 浏览器才更愿意按 capture="environment" 调起相机。
*
* 旧实现把 input 移到 left:-9999px 再由按钮脚本 .click():
* iOS Safari 会点了没反应,部分安卓 WebView 则把相机 intent 降级成文件选择
* (表现为点「拍照」却弹出文件浏览)。
*/ */
.image-upload-camera-input, .image-upload-camera-input,
.worker-acceptance-video-camera-input { .worker-acceptance-video-camera-input {
position: fixed; position: absolute;
top: 0; inset: 0;
left: -9999px; width: 100%;
width: 1px; height: 100%;
height: 1px;
opacity: 0; opacity: 0;
cursor: pointer;
}
.camera-pick-trigger {
position: relative;
display: inline-flex;
align-items: center;
}
/* 视觉按钮只负责外观,点击统一由上层透明 input 接收(避免脚本 click 触发降级) */
.camera-pick-trigger .ant-btn {
pointer-events: none; pointer-events: none;
} }
@@ -32,6 +32,10 @@ export function resolveCameraEntryEnabled(options: ResolveCameraEntryOptions): b
* *
* `capture="environment"` 让移动浏览器优先打开后置摄像头;iOS Safari 的部分版本会 * `capture="environment"` 让移动浏览器优先打开后置摄像头;iOS Safari 的部分版本会
* 忽略该属性并弹出「拍照 / 从相册选择」菜单,属于预期内的降级行为。 * 忽略该属性并弹出「拍照 / 从相册选择」菜单,属于预期内的降级行为。
*
* 用法约束(安卓兼容性):input 必须**透明铺满按钮**、由用户手指直接点到它
* (见 base.css 的 `.camera-pick-trigger` / `*-camera-input`)。离屏 + 脚本 `.click()`
* 在部分安卓 WebView 上会让浏览器把相机 intent 退化成文件选择。
*/ */
export function cameraCaptureInputProps(kind: CameraCaptureKind): { export function cameraCaptureInputProps(kind: CameraCaptureKind): {
accept: string accept: string
+44 -14
View File
@@ -1,19 +1,49 @@
# 本地开发网关(仅 dev,生产用 Caddyfile.prod / 其它部署文件):
# 管理端:http://<IP>/ 或 http://localhost/
# 打手端:http://<IP>:8080/
#
# 前端是同一份 SPA,靠 hostname 判断站点(apps/frontend/src/utils/site.ts)。
# 因此这里用「两个 Vite dev server」而不是只加端口:
# :80 → frontend:5173 (其 VITE_WORKER_SITE_URL 指向 worker.localhost,所以 IP 访问=管理端)
# :8080 → frontend-worker:5174 (其 VITE_WORKER_SITE_URL 指向 http://<IP>,所以 IP 访问=打手端)
# 这样无需改动任何业务代码,生产构建也不受影响。
:80 { :80 {
route { route {
handle /api/* { handle /api/* {
reverse_proxy backend:3000 reverse_proxy backend:3000
} }
handle /health* { handle /health* {
reverse_proxy backend:3000 reverse_proxy backend:3000
} }
handle /s/* { handle /s/* {
reverse_proxy backend:3000 reverse_proxy backend:3000
} }
handle { handle {
reverse_proxy frontend:5173 reverse_proxy frontend:5173
} }
} }
}
:8080 {
route {
handle /api/* {
reverse_proxy backend:3000
}
handle /health* {
reverse_proxy backend:3000
}
handle /s/* {
reverse_proxy backend:3000
}
handle {
reverse_proxy frontend-worker:5174
}
}
} }
+32
View File
@@ -152,6 +152,33 @@ services:
- ./.prettierignore:/.prettierignore:ro - ./.prettierignore:/.prettierignore:ro
- frontend_node_modules:/app/node_modules - frontend_node_modules:/app/node_modules
# 打手端专用 dev server:同一份代码,只把「打手端 hostname」设成 IP,
# 由 Caddy 的 8080 端口反代过来(前端按 hostname 判站点,见 utils/site.ts)。
frontend-worker:
image: ${BASE_REGISTRY:-docker.m.daocloud.io}/library/node:22-bookworm-slim
working_dir: /app
restart: unless-stopped
logging: *json-log-rotation
depends_on:
backend:
condition: service_healthy
command: >-
sh -lc
"[ -d node_modules/vite ] || npm install --no-fund --no-audit;
npm run dev -- --host 0.0.0.0 --port 5174"
environment:
TZ: ${TZ:-Asia/Shanghai}
CHOKIDAR_USEPOLLING: "true"
NPM_CONFIG_REGISTRY: https://registry.npmmirror.com
VITE_API_TARGET: http://backend:3000
VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-localhost,127.0.0.1}
VITE_WORKER_SITE_URL: ${WORKER_SITE_ADDR_IP:-http://192.168.10.8}
volumes:
- ./apps/frontend:/app
- ./.prettierrc.json:/.prettierrc.json:ro
- ./.prettierignore:/.prettierignore:ro
- frontend_worker_node_modules:/app/node_modules
web: web:
image: ${BASE_REGISTRY:-docker.m.daocloud.io}/library/caddy:2-alpine image: ${BASE_REGISTRY:-docker.m.daocloud.io}/library/caddy:2-alpine
restart: unless-stopped restart: unless-stopped
@@ -161,9 +188,13 @@ services:
condition: service_healthy condition: service_healthy
frontend: frontend:
condition: service_started condition: service_started
frontend-worker:
condition: service_started
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
# 打手端(IP 访问):http://<IP>:8080/
- "${WORKER_LAN_PORT:-8080}:8080"
environment: environment:
TZ: ${TZ:-Asia/Shanghai} TZ: ${TZ:-Asia/Shanghai}
volumes: volumes:
@@ -174,3 +205,4 @@ volumes:
minio_dev_data: minio_dev_data:
backend_node_modules: backend_node_modules:
frontend_node_modules: frontend_node_modules:
frontend_worker_node_modules: