优化连拍功能
This commit is contained in:
File diff suppressed because one or more lines are too long
204
frontend/dist/assets/index-Dw7fpRnj.js
vendored
204
frontend/dist/assets/index-Dw7fpRnj.js
vendored
File diff suppressed because one or more lines are too long
204
frontend/dist/assets/index-YakFBlkm.js
vendored
Normal file
204
frontend/dist/assets/index-YakFBlkm.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
frontend/dist/index.html
vendored
4
frontend/dist/index.html
vendored
@@ -9,8 +9,8 @@
|
|||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<title>学习伙伴</title>
|
<title>学习伙伴</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Dw7fpRnj.js"></script>
|
<script type="module" crossorigin src="/assets/index-YakFBlkm.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CoOKrmS9.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-B2Duzhn9.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1948,10 +1948,16 @@ export default function App() {
|
|||||||
const [mainTab, setMainTab] = useState("mistake");
|
const [mainTab, setMainTab] = useState("mistake");
|
||||||
const [extraTab, setExtraTab] = useState("resource");
|
const [extraTab, setExtraTab] = useState("resource");
|
||||||
const [showQuickCamera, setShowQuickCamera] = useState(false);
|
const [showQuickCamera, setShowQuickCamera] = useState(false);
|
||||||
|
const [showBurstCamera, setShowBurstCamera] = useState(false);
|
||||||
const [quickCameraFabCompact, setQuickCameraFabCompact] = useState(() => readQuickCameraFabCompact());
|
const [quickCameraFabCompact, setQuickCameraFabCompact] = useState(() => readQuickCameraFabCompact());
|
||||||
const [quickCaptureMode, setQuickCaptureMode] = useState("single");
|
const [quickCaptureMode, setQuickCaptureMode] = useState("single");
|
||||||
const [quickCaptureTask, setQuickCaptureTask] = useState(null);
|
const [quickCaptureTask, setQuickCaptureTask] = useState(null);
|
||||||
|
const [burstShots, setBurstShots] = useState([]);
|
||||||
|
const [burstCameraError, setBurstCameraError] = useState("");
|
||||||
|
const [burstCameraReady, setBurstCameraReady] = useState(false);
|
||||||
const quickCaptureInputRef = useRef(null);
|
const quickCaptureInputRef = useRef(null);
|
||||||
|
const burstVideoRef = useRef(null);
|
||||||
|
const burstStreamRef = useRef(null);
|
||||||
|
|
||||||
const triggerQuickCapture = () => {
|
const triggerQuickCapture = () => {
|
||||||
quickCaptureInputRef.current?.click();
|
quickCaptureInputRef.current?.click();
|
||||||
@@ -1977,6 +1983,101 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pushQuickCaptureTaskWithFiles = (files, mode = quickCaptureMode) => {
|
||||||
|
if (!files?.length) return;
|
||||||
|
setMainTab("mistake");
|
||||||
|
setQuickCaptureTask({
|
||||||
|
id: Date.now(),
|
||||||
|
mode,
|
||||||
|
files
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopBurstStream = () => {
|
||||||
|
if (burstStreamRef.current) {
|
||||||
|
burstStreamRef.current.getTracks().forEach((t) => t.stop());
|
||||||
|
burstStreamRef.current = null;
|
||||||
|
}
|
||||||
|
if (burstVideoRef.current) {
|
||||||
|
burstVideoRef.current.srcObject = null;
|
||||||
|
}
|
||||||
|
setBurstCameraReady(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startBurstCamera = async () => {
|
||||||
|
setBurstCameraError("");
|
||||||
|
setBurstShots([]);
|
||||||
|
setShowQuickCamera(false);
|
||||||
|
setShowBurstCamera(true);
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: { ideal: "environment" },
|
||||||
|
width: { ideal: 1920 },
|
||||||
|
height: { ideal: 1080 }
|
||||||
|
},
|
||||||
|
audio: false
|
||||||
|
});
|
||||||
|
burstStreamRef.current = stream;
|
||||||
|
if (burstVideoRef.current) {
|
||||||
|
burstVideoRef.current.srcObject = stream;
|
||||||
|
await burstVideoRef.current.play().catch(() => {});
|
||||||
|
}
|
||||||
|
setBurstCameraReady(true);
|
||||||
|
} catch {
|
||||||
|
setBurstCameraError("无法开启相机,请检查浏览器权限。你也可以改用系统相机上传。");
|
||||||
|
setBurstCameraReady(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeBurstCamera = () => {
|
||||||
|
stopBurstStream();
|
||||||
|
setShowBurstCamera(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const captureBurstFrame = async () => {
|
||||||
|
const video = burstVideoRef.current;
|
||||||
|
if (!video || !video.videoWidth || !video.videoHeight) return;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
const blob = await new Promise((resolve) =>
|
||||||
|
canvas.toBlob((b) => resolve(b), "image/jpeg", 0.9)
|
||||||
|
);
|
||||||
|
if (!blob) return;
|
||||||
|
const file = new File([blob], `burst-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.jpg`, { type: "image/jpeg" });
|
||||||
|
setBurstShots((prev) => [...prev, file]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishBurstCapture = () => {
|
||||||
|
if (!burstShots.length) return;
|
||||||
|
pushQuickCaptureTaskWithFiles(burstShots, "burst");
|
||||||
|
closeBurstCamera();
|
||||||
|
};
|
||||||
|
|
||||||
|
const startQuickCaptureFlow = () => {
|
||||||
|
if (quickCaptureMode === "single") {
|
||||||
|
triggerQuickCapture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mediaSupported = !!navigator?.mediaDevices?.getUserMedia;
|
||||||
|
if (mediaSupported) {
|
||||||
|
startBurstCamera();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fallback for old browsers/devices.
|
||||||
|
triggerQuickCapture();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopBurstStream();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header className="app-hero">
|
<header className="app-hero">
|
||||||
@@ -2129,7 +2230,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="btn-row" style={{ marginTop: 14 }}>
|
<div className="btn-row" style={{ marginTop: 14 }}>
|
||||||
<button type="button" className="btn btn-primary" onClick={triggerQuickCapture}>
|
<button type="button" className="btn btn-primary" onClick={startQuickCaptureFlow}>
|
||||||
开始拍照
|
开始拍照
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => setShowQuickCamera(false)}>
|
<button type="button" className="btn btn-ghost" onClick={() => setShowQuickCamera(false)}>
|
||||||
@@ -2138,6 +2239,35 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showBurstCamera && (
|
||||||
|
<div className="burst-camera-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="burst-camera-panel">
|
||||||
|
<div className="burst-camera-head">
|
||||||
|
<strong>连拍录题</strong>
|
||||||
|
<span className="text-muted small">已拍 {burstShots.length} 张</span>
|
||||||
|
</div>
|
||||||
|
<div className="burst-camera-preview">
|
||||||
|
<video ref={burstVideoRef} playsInline muted autoPlay />
|
||||||
|
{!burstCameraReady && (
|
||||||
|
<div className="burst-camera-mask">{burstCameraError || "正在启动相机..."}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="burst-camera-actions">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={closeBurstCamera}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary burst-shutter-btn" onClick={captureBurstFrame} disabled={!burstCameraReady}>
|
||||||
|
拍一张
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-success" onClick={finishBurstCapture} disabled={burstShots.length === 0}>
|
||||||
|
完成并导入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted small">连拍模式下不会退出相机,连续点击“拍一张”即可。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -959,6 +959,72 @@ textarea {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.burst-camera-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 220;
|
||||||
|
background: rgba(2, 6, 23, 0.82);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.burst-camera-panel {
|
||||||
|
width: min(820px, 100%);
|
||||||
|
background: #0b1222;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.burst-camera-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.burst-camera-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.burst-camera-preview video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.burst-camera-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.burst-camera-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.burst-shutter-btn {
|
||||||
|
min-width: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.quick-camera-fab-shell {
|
.quick-camera-fab-shell {
|
||||||
right: 14px;
|
right: 14px;
|
||||||
@@ -988,6 +1054,27 @@ textarea {
|
|||||||
width: 42px;
|
width: 42px;
|
||||||
min-width: 42px;
|
min-width: 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.burst-camera-overlay {
|
||||||
|
padding: 0;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.burst-camera-panel {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 14px 14px 0 0;
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.burst-camera-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.burst-camera-actions .btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
|
|||||||
Reference in New Issue
Block a user