优化连拍功能

This commit is contained in:
Daniel
2026-04-19 15:48:38 +08:00
parent aa78763af2
commit aee4f9d09f
6 changed files with 425 additions and 208 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

204
frontend/dist/assets/index-YakFBlkm.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -9,8 +9,8 @@
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>学习伙伴</title>
<script type="module" crossorigin src="/assets/index-Dw7fpRnj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CoOKrmS9.css">
<script type="module" crossorigin src="/assets/index-YakFBlkm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B2Duzhn9.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1948,10 +1948,16 @@ export default function App() {
const [mainTab, setMainTab] = useState("mistake");
const [extraTab, setExtraTab] = useState("resource");
const [showQuickCamera, setShowQuickCamera] = useState(false);
const [showBurstCamera, setShowBurstCamera] = useState(false);
const [quickCameraFabCompact, setQuickCameraFabCompact] = useState(() => readQuickCameraFabCompact());
const [quickCaptureMode, setQuickCaptureMode] = useState("single");
const [quickCaptureTask, setQuickCaptureTask] = useState(null);
const [burstShots, setBurstShots] = useState([]);
const [burstCameraError, setBurstCameraError] = useState("");
const [burstCameraReady, setBurstCameraReady] = useState(false);
const quickCaptureInputRef = useRef(null);
const burstVideoRef = useRef(null);
const burstStreamRef = useRef(null);
const triggerQuickCapture = () => {
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 (
<div className="app-shell">
<header className="app-hero">
@@ -2129,7 +2230,7 @@ export default function App() {
</div>
</div>
<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 type="button" className="btn btn-ghost" onClick={() => setShowQuickCamera(false)}>
@@ -2138,6 +2239,35 @@ export default function App() {
</div>
</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>
);
}

View File

@@ -959,6 +959,72 @@ textarea {
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) {
.quick-camera-fab-shell {
right: 14px;
@@ -988,6 +1054,27 @@ textarea {
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 {