feat:项目打包成Electron

This commit is contained in:
2026-06-14 18:10:58 +08:00
parent 7a0443ebcc
commit 8970b63800
20 changed files with 3895 additions and 11 deletions

View File

@@ -6,6 +6,18 @@
import { useCallback, useRef, useState } from "react";
import { useI18n } from "../../lib/i18n";
// Electron API 类型
interface ElectronAPI {
requestCameraAccess: () => Promise<boolean>;
isElectron: boolean;
}
declare global {
interface Window {
electronAPI?: ElectronAPI;
}
}
export interface CameraManagerHandle {
/** 获取当前视频轨道 */
stream: MediaStream | null;
@@ -13,39 +25,130 @@ export interface CameraManagerHandle {
captureFrame: () => string | null;
}
/** 等待 video 元素接收到第一帧数据 */
function waitForVideoReady(video: HTMLVideoElement, timeoutMs = 10000): Promise<void> {
return new Promise((resolve, reject) => {
if (video.readyState >= 2 && video.videoWidth > 0) {
resolve();
return;
}
const timer = setTimeout(() => {
cleanup();
reject(new Error(`视频加载超时 (readyState=${video.readyState}, paused=${video.paused}, readyState=${video.readyState})`));
}, timeoutMs);
const onLoadedData = () => {
cleanup();
resolve();
};
const onPlaying = () => {
if (video.readyState >= 2 && video.videoWidth > 0) {
cleanup();
resolve();
}
};
function cleanup() {
clearTimeout(timer);
video.removeEventListener("loadeddata", onLoadedData);
video.removeEventListener("playing", onPlaying);
}
video.addEventListener("loadeddata", onLoadedData);
video.addEventListener("playing", onPlaying);
// 主动触发播放autoPlay 在某些环境下不生效)
video.play().catch(() => { /* play 可能在设置 srcObject 期间被中断,重试即可 */ });
});
}
export function useCamera() {
const { t } = useI18n();
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [error, setError] = useState<string | null>(null);
// 防止 React StrictMode 或快速连续调用导致并发执行
const isStartingRef = useRef(false);
/** 停止当前的摄像头流 */
const stopCurrentStream = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setStream(null);
}, []);
const startCamera = useCallback(async () => {
// 防止并发调用
if (isStartingRef.current) return;
isStartingRef.current = true;
try {
// 清理之前的流(防止 "play interrupted by new load"
stopCurrentStream();
// Electron 环境:先通过 IPC 请求系统级权限(触发 macOS TCC 弹窗)
if (window.electronAPI?.isElectron) {
const granted = await window.electronAPI.requestCameraAccess();
if (!granted) {
setError(t("error.cameraAccess"));
return;
}
}
const mediaStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment", width: 640, height: 480 },
audio: false,
});
streamRef.current = mediaStream;
setStream(mediaStream);
if (videoRef.current) {
videoRef.current.srcObject = mediaStream;
const video = videoRef.current;
if (video) {
video.srcObject = mediaStream;
try {
await waitForVideoReady(video);
} catch (err) {
// play() 可能被新的 load 中断,重试一次
console.warn("[Camera] 首次等待视频就绪失败,重试:", (err as Error).message);
try {
video.play().catch(() => {});
await waitForVideoReady(video, 5000);
} catch {
// macOS TCC 可能阻止了视频帧,但仍保留流(用户可手动授权后重试)
console.warn("[Camera] 视频元素未能加载帧数据(可能 macOS 摄像头权限未授予)");
}
}
}
setError(null);
} catch (err) {
const message = err instanceof Error ? err.message : t("error.cameraAccess");
setError(message);
console.error("[Camera] 获取摄像头失败:", err);
} finally {
isStartingRef.current = false;
}
}, []);
}, [t, stopCurrentStream]);
const stopCamera = useCallback(() => {
stream?.getTracks().forEach((track) => track.stop());
setStream(null);
}, [stream]);
stopCurrentStream();
isStartingRef.current = false;
}, [stopCurrentStream]);
/** 从 video 元素捕获当前帧为 JPEG DataURL */
const captureFrame = useCallback((): string | null => {
const video = videoRef.current;
if (!video || video.readyState < 2) return null;
if (!video || video.readyState < 2 || video.videoWidth === 0) return null;
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;

View File

@@ -6,6 +6,18 @@
import { useCallback, useRef, useState } from "react";
import { useI18n } from "../../lib/i18n";
// Electron API 类型
interface ElectronAPI {
requestMicAccess: () => Promise<boolean>;
isElectron: boolean;
}
declare global {
interface Window {
electronAPI?: ElectronAPI;
}
}
export function useMicrophone() {
const { t } = useI18n();
const [stream, setStream] = useState<MediaStream | null>(null);
@@ -14,6 +26,17 @@ export function useMicrophone() {
const startMic = useCallback(async () => {
try {
// Electron 环境:先通过 IPC 请求系统级权限
if (window.electronAPI?.isElectron) {
const granted = await window.electronAPI.requestMicAccess();
if (!granted) {
const msg = t("error.micAccess");
setError(msg);
console.warn("[Mic] macOS 系统麦克风权限被拒绝");
return null;
}
}
const mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,

View File

@@ -6,10 +6,40 @@
import type { ClientMessage, ServerMessage, WsMessage } from "../types";
// WebSocket 地址:优先使用环境变量,否则基于当前页面地址自动推导
const WS_URL =
import.meta.env.VITE_WS_URL ||
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`;
// Electron 类型声明(通过 preload 脚本注入)
interface ElectronAPI {
getBackendUrl: () => string;
isElectron: boolean;
}
declare global {
interface Window {
electronAPI?: ElectronAPI;
}
}
// WebSocket 地址优先级:
// 1. Electron preload 注入的地址(桌面端)
// 2. Vite 环境变量
// 3. 根据当前页面地址自动推导(浏览器端)
function getWsUrl(): string {
// Electron 环境:使用 preload 注入的地址
if (window.electronAPI?.isElectron) {
return window.electronAPI.getBackendUrl();
}
// Vite 环境变量
if (import.meta.env.VITE_WS_URL) {
return import.meta.env.VITE_WS_URL;
}
// 浏览器端:根据当前页面地址推导
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.host || "localhost:8080";
return `${protocol}//${host}/ws`;
}
const WS_URL = getWsUrl();
const PING_INTERVAL = 30_000; // 30 秒心跳
const MAX_RECONNECT_DELAY = 30_000; // 最大重连延迟 30 秒