add: 添加前端对焦交互逻辑
This commit is contained in:
		@@ -4,6 +4,19 @@ import fetch from 'node-fetch';
 | 
			
		||||
 | 
			
		||||
const execAsync = promisify(exec);
 | 
			
		||||
 | 
			
		||||
// Windows 支持函数
 | 
			
		||||
function getCommand(command: string): string {
 | 
			
		||||
  // dotnet 在 Windows 上不需要 .cmd 后缀
 | 
			
		||||
  if (command === 'dotnet') {
 | 
			
		||||
    return 'dotnet';
 | 
			
		||||
  }
 | 
			
		||||
  return process.platform === 'win32' ? `${command}.cmd` : command;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getSpawnOptions() {
 | 
			
		||||
  return process.platform === 'win32' ? { stdio: 'pipe', shell: true } : { stdio: 'pipe' };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function waitForServer(url: string, maxRetries: number = 30, interval: number = 1000): Promise<boolean> {
 | 
			
		||||
  for (let i = 0; i < maxRetries; i++) {
 | 
			
		||||
    try {
 | 
			
		||||
@@ -28,9 +41,7 @@ let webProcess: ChildProcess | null = null;
 | 
			
		||||
async function startWeb(): Promise<ChildProcess> {
 | 
			
		||||
  console.log('Starting Vite frontend...');
 | 
			
		||||
  return new Promise((resolve, reject) => {
 | 
			
		||||
    const process = spawn('npm', ['run', 'dev'], {
 | 
			
		||||
      stdio: 'pipe'
 | 
			
		||||
    });
 | 
			
		||||
    const process = spawn(getCommand('npm'), ['run', 'dev'], getSpawnOptions() as any);
 | 
			
		||||
 | 
			
		||||
    let webStarted = false;
 | 
			
		||||
 | 
			
		||||
@@ -75,10 +86,10 @@ async function startWeb(): Promise<ChildProcess> {
 | 
			
		||||
async function startServer(): Promise<ChildProcess> {
 | 
			
		||||
  console.log('Starting .NET server...');
 | 
			
		||||
  return new Promise((resolve, reject) => {
 | 
			
		||||
    const process = spawn('dotnet', ['run', '--property:Configuration=Release'], {
 | 
			
		||||
    const process = spawn(getCommand('dotnet'), ['run', '--property:Configuration=Release'], {
 | 
			
		||||
      cwd: 'server',
 | 
			
		||||
      stdio: 'pipe'
 | 
			
		||||
    });
 | 
			
		||||
      ...getSpawnOptions()
 | 
			
		||||
    } as any);
 | 
			
		||||
 | 
			
		||||
    let serverStarted = false;
 | 
			
		||||
 | 
			
		||||
@@ -175,7 +186,12 @@ async function stopServer(): Promise<void> {
 | 
			
		||||
    
 | 
			
		||||
    // 额外清理:确保没有遗留的 dotnet 进程
 | 
			
		||||
    try {
 | 
			
		||||
      if (process.platform !== 'win32') {
 | 
			
		||||
      if (process.platform === 'win32') {
 | 
			
		||||
        // Windows: 使用 taskkill 清理进程
 | 
			
		||||
        await execAsync('taskkill /F /IM dotnet.exe').catch(() => {
 | 
			
		||||
          // 忽略错误,可能没有匹配的进程
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        // 只清理与我们项目相关的进程
 | 
			
		||||
        await execAsync('pkill -f "dotnet.*run.*--property:Configuration=Release"').catch(() => {
 | 
			
		||||
          // 忽略错误,可能没有匹配的进程
 | 
			
		||||
@@ -242,7 +258,12 @@ async function stopWeb(): Promise<void> {
 | 
			
		||||
    
 | 
			
		||||
    // 额外清理:确保没有遗留的 npm/node 进程
 | 
			
		||||
    try {
 | 
			
		||||
      if (process.platform !== 'win32') {
 | 
			
		||||
      if (process.platform === 'win32') {
 | 
			
		||||
        // Windows: 清理可能的 node 进程
 | 
			
		||||
        await execAsync('taskkill /F /IM node.exe').catch(() => {
 | 
			
		||||
          // 忽略错误,可能没有匹配的进程
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        // 清理可能的 vite 进程
 | 
			
		||||
        await execAsync('pkill -f "vite"').catch(() => {
 | 
			
		||||
          // 忽略错误,可能没有匹配的进程
 | 
			
		||||
@@ -257,7 +278,8 @@ async function stopWeb(): Promise<void> {
 | 
			
		||||
async function generateApiClient(): Promise<void> {
 | 
			
		||||
  console.log('Generating API client...');
 | 
			
		||||
  try {
 | 
			
		||||
    await execAsync('npx nswag openapi2tsclient /input:http://localhost:5000/swagger/v1/swagger.json /output:src/APIClient.ts');
 | 
			
		||||
    const npxCommand = getCommand('npx');
 | 
			
		||||
    await execAsync(`${npxCommand} nswag openapi2tsclient /input:http://localhost:5000/swagger/v1/swagger.json /output:src/APIClient.ts`);
 | 
			
		||||
    console.log('✓ API client generated successfully');
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    throw new Error(`Failed to generate API client: ${error}`);
 | 
			
		||||
 
 | 
			
		||||
@@ -447,4 +447,61 @@ public class VideoStreamController : ControllerBase
 | 
			
		||||
            return TypedResults.InternalServerError($"执行自动对焦失败: {ex.Message}");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 执行一次自动对焦 (GET方式)
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <returns>对焦结果</returns>
 | 
			
		||||
    [HttpGet("Focus")]
 | 
			
		||||
    [EnableCors("Users")]
 | 
			
		||||
    [ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
 | 
			
		||||
    [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)]
 | 
			
		||||
    [ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
 | 
			
		||||
    public async Task<IResult> Focus()
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            logger.Info("收到执行一次对焦请求 (GET)");
 | 
			
		||||
 | 
			
		||||
            // 检查摄像头是否已配置
 | 
			
		||||
            if (!_videoStreamService.IsCameraConfigured())
 | 
			
		||||
            {
 | 
			
		||||
                logger.Warn("摄像头未配置,无法执行对焦");
 | 
			
		||||
                return TypedResults.BadRequest(new
 | 
			
		||||
                {
 | 
			
		||||
                    success = false,
 | 
			
		||||
                    message = "摄像头未配置,请先配置摄像头连接",
 | 
			
		||||
                    timestamp = DateTime.Now
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var result = await _videoStreamService.PerformAutoFocusAsync();
 | 
			
		||||
            
 | 
			
		||||
            if (result)
 | 
			
		||||
            {
 | 
			
		||||
                logger.Info("对焦执行成功");
 | 
			
		||||
                return TypedResults.Ok(new
 | 
			
		||||
                {
 | 
			
		||||
                    success = true,
 | 
			
		||||
                    message = "对焦执行成功",
 | 
			
		||||
                    timestamp = DateTime.Now
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                logger.Warn("对焦执行失败");
 | 
			
		||||
                return TypedResults.BadRequest(new
 | 
			
		||||
                {
 | 
			
		||||
                    success = false,
 | 
			
		||||
                    message = "对焦执行失败",
 | 
			
		||||
                    timestamp = DateTime.Now
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.Error(ex, "执行对焦时发生异常");
 | 
			
		||||
            return TypedResults.InternalServerError($"执行对焦失败: {ex.Message}");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -213,8 +213,7 @@ class Camera
 | 
			
		||||
    /// <returns>包含图像数据的字节数组</returns>
 | 
			
		||||
    public async ValueTask<Result<byte[]>> ReadFrame()
 | 
			
		||||
    {
 | 
			
		||||
        // 只在第一次或出错时清除UDP缓冲区,避免每帧都清除造成延迟
 | 
			
		||||
        // await MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
 | 
			
		||||
        MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
 | 
			
		||||
 | 
			
		||||
        logger.Trace($"Reading frame from camera {this.address}");
 | 
			
		||||
 | 
			
		||||
@@ -303,7 +302,13 @@ class Camera
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<Result<byte>> ReadRegisters(UInt16 register)
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 读取I2C寄存器字节值
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="register">要读取的寄存器地址 (16位)</param>
 | 
			
		||||
    /// <returns>ret</returns>
 | 
			
		||||
    public async ValueTask<Result<byte>> ReadRegister(UInt16 register)
 | 
			
		||||
    {
 | 
			
		||||
        var i2c = new Peripherals.I2cClient.I2c(this.address, this.port, this.taskID, this.timeout);
 | 
			
		||||
        
 | 
			
		||||
@@ -1218,18 +1223,22 @@ class Camera
 | 
			
		||||
    {
 | 
			
		||||
        logger.Info("开始写入OV5640自动对焦固件");
 | 
			
		||||
 | 
			
		||||
        MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
 | 
			
		||||
        var setStartAddrResult = await ConfigureRegisters([[0x3000, 0x80]]);
 | 
			
		||||
        if (!setStartAddrResult.IsSuccessful) return setStartAddrResult;
 | 
			
		||||
 | 
			
		||||
        // 组装固件写入命令:地址 + 所有固件数据
 | 
			
		||||
        UInt16 firmwareAddr = 0x8000;
 | 
			
		||||
        var firmwareCommands = new List<UInt16[]>();
 | 
			
		||||
        var firmwareCommand = new UInt16[1 + OV5640_AF_FIRMWARE.Length];
 | 
			
		||||
        firmwareCommand[0] = firmwareAddr;
 | 
			
		||||
        
 | 
			
		||||
        // 将固件数据复制到命令数组中
 | 
			
		||||
        for (int i = 0; i < OV5640_AF_FIRMWARE.Length; i++)
 | 
			
		||||
        {
 | 
			
		||||
            UInt16 addr = (UInt16)(firmwareAddr + i);
 | 
			
		||||
            firmwareCommands.Add([addr, OV5640_AF_FIRMWARE[i]]);
 | 
			
		||||
            firmwareCommand[i + 1] = OV5640_AF_FIRMWARE[i];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var result = await ConfigureRegisters(firmwareCommands.ToArray());
 | 
			
		||||
        var result = await ConfigureRegisters([firmwareCommand]);
 | 
			
		||||
        if (!result.IsSuccessful)
 | 
			
		||||
        {
 | 
			
		||||
            logger.Error($"固件写入失败: {result.Error}");
 | 
			
		||||
@@ -1324,7 +1333,7 @@ class Camera
 | 
			
		||||
        // 步骤2: 读取寄存器 0x3029 的状态,如果返回值为 0x10,代表对焦已完成
 | 
			
		||||
        for (int iteration = 5000; iteration > 0; iteration--)
 | 
			
		||||
        {
 | 
			
		||||
            var readResult = await ReadRegisters(0x3029);
 | 
			
		||||
            var readResult = await ReadRegister(0x3029);
 | 
			
		||||
            if (!readResult.IsSuccessful)
 | 
			
		||||
            {
 | 
			
		||||
                logger.Error($"读取对焦状态寄存器(0x3029)失败: {readResult.Error}");
 | 
			
		||||
 
 | 
			
		||||
@@ -91,9 +91,9 @@ public class UDPClientPool
 | 
			
		||||
        var sendLen = socket.SendTo(sendBytes, endPoint);
 | 
			
		||||
        socket.Close();
 | 
			
		||||
 | 
			
		||||
        // logger.Debug($"UDP socket send address package to device {endPoint.Address.ToString()}:{endPoint.Port.ToString()}:");
 | 
			
		||||
        // logger.Debug($"  Original Data: {BitConverter.ToString(pkg.ToBytes()).Replace("-", " ")}");
 | 
			
		||||
        // logger.Debug($"  Decoded  Data: {pkg.ToString()}");
 | 
			
		||||
        logger.Debug($"UDP socket send address package to device {endPoint.Address.ToString()}:{endPoint.Port.ToString()}:");
 | 
			
		||||
        logger.Debug($"  Original Data: {BitConverter.ToString(pkg.ToBytes()).Replace("-", " ")}");
 | 
			
		||||
        logger.Debug($"  Decoded  Data: {pkg.ToString()}");
 | 
			
		||||
 | 
			
		||||
        if (sendLen == sendBytes.Length) { return true; }
 | 
			
		||||
        else { return false; }
 | 
			
		||||
@@ -164,8 +164,8 @@ public class UDPClientPool
 | 
			
		||||
        var sendLen = socket.SendTo(sendBytes, endPoint);
 | 
			
		||||
        socket.Close();
 | 
			
		||||
 | 
			
		||||
        // logger.Debug($"UDP socket send data package to device {endPoint.Address.ToString()}:{endPoint.Port.ToString()}:");
 | 
			
		||||
        // logger.Debug($"  Original Data: {BitConverter.ToString(pkg.ToBytes()).Replace("-", " ")}");
 | 
			
		||||
        logger.Debug($"UDP socket send data package to device {endPoint.Address.ToString()}:{endPoint.Port.ToString()}:");
 | 
			
		||||
        logger.Debug($"  Original Data: {BitConverter.ToString(pkg.ToBytes()).Replace("-", " ")}");
 | 
			
		||||
 | 
			
		||||
        if (sendLen == sendBytes.Length) { return true; }
 | 
			
		||||
        else { return false; }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										8347
									
								
								src/APIClient.ts
									
									
									
									
									
								
							
							
						
						
									
										8347
									
								
								src/APIClient.ts
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -161,8 +161,13 @@
 | 
			
		||||
        </h2>
 | 
			
		||||
 | 
			
		||||
        <div
 | 
			
		||||
          class="relative bg-black rounded-lg overflow-hidden"
 | 
			
		||||
          class="relative bg-black rounded-lg overflow-hidden cursor-pointer"
 | 
			
		||||
          :class="[
 | 
			
		||||
            focusAnimationClass,
 | 
			
		||||
            { 'cursor-not-allowed': !isPlaying || hasVideoError }
 | 
			
		||||
          ]"
 | 
			
		||||
          style="aspect-ratio: 4/3"
 | 
			
		||||
          @click="handleVideoClick"
 | 
			
		||||
        >
 | 
			
		||||
          <!-- 视频播放器 - 使用img标签直接显示MJPEG流 -->
 | 
			
		||||
          <div
 | 
			
		||||
@@ -178,6 +183,14 @@
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- 对焦提示 -->
 | 
			
		||||
          <div
 | 
			
		||||
            v-if="isPlaying && !hasVideoError"
 | 
			
		||||
            class="absolute top-4 right-4 text-white text-sm bg-black bg-opacity-50 px-2 py-1 rounded"
 | 
			
		||||
          >
 | 
			
		||||
            {{ isFocusing ? '对焦中...' : '点击画面对焦' }}
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- 错误信息显示 -->
 | 
			
		||||
          <div
 | 
			
		||||
            v-if="hasVideoError"
 | 
			
		||||
@@ -353,6 +366,10 @@ const isPlaying = ref(false);
 | 
			
		||||
const hasVideoError = ref(false);
 | 
			
		||||
const videoStatus = ref('点击"播放视频流"按钮开始查看实时视频');
 | 
			
		||||
 | 
			
		||||
// 对焦相关状态
 | 
			
		||||
const isFocusing = ref(false);
 | 
			
		||||
const focusAnimationClass = ref('');
 | 
			
		||||
 | 
			
		||||
// 分辨率相关状态
 | 
			
		||||
const changingResolution = ref(false);
 | 
			
		||||
const loadingResolutions = ref(false);
 | 
			
		||||
@@ -565,6 +582,67 @@ const tryReconnect = () => {
 | 
			
		||||
  currentVideoSource.value = `${streamInfo.value.mjpegUrl}?t=${new Date().getTime()}`;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 执行对焦
 | 
			
		||||
const performFocus = async () => {
 | 
			
		||||
  if (isFocusing.value || !isPlaying.value) return;
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    isFocusing.value = true;
 | 
			
		||||
    focusAnimationClass.value = 'focus-starting';
 | 
			
		||||
    addLog("info", "正在执行自动对焦...");
 | 
			
		||||
 | 
			
		||||
    // 调用对焦API
 | 
			
		||||
    const response = await fetch('/api/VideoStream/Focus');
 | 
			
		||||
    const result = await response.json();
 | 
			
		||||
 | 
			
		||||
    if (result.success) {
 | 
			
		||||
      // 对焦成功动画
 | 
			
		||||
      focusAnimationClass.value = 'focus-success';
 | 
			
		||||
      addLog("success", "自动对焦执行成功");
 | 
			
		||||
      
 | 
			
		||||
      // 2秒后消失
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        focusAnimationClass.value = '';
 | 
			
		||||
      }, 2000);
 | 
			
		||||
    } else {
 | 
			
		||||
      // 对焦失败动画
 | 
			
		||||
      focusAnimationClass.value = 'focus-error';
 | 
			
		||||
      addLog("error", `自动对焦执行失败: ${result.message || '未知错误'}`);
 | 
			
		||||
      
 | 
			
		||||
      // 2秒后消失
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        focusAnimationClass.value = '';
 | 
			
		||||
      }, 2000);
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    // 对焦失败动画
 | 
			
		||||
    focusAnimationClass.value = 'focus-error';
 | 
			
		||||
    addLog("error", `自动对焦执行失败: ${error}`);
 | 
			
		||||
    console.error("自动对焦执行失败:", error);
 | 
			
		||||
    
 | 
			
		||||
    // 2秒后消失
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      focusAnimationClass.value = '';
 | 
			
		||||
    }, 2000);
 | 
			
		||||
  } finally {
 | 
			
		||||
    // 1秒后重置对焦状态
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      isFocusing.value = false;
 | 
			
		||||
    }, 1000);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 处理视频点击事件
 | 
			
		||||
const handleVideoClick = (event: MouseEvent) => {
 | 
			
		||||
  // 只在播放状态下才允许对焦
 | 
			
		||||
  if (!isPlaying.value || hasVideoError.value) return;
 | 
			
		||||
  
 | 
			
		||||
  // 防止重复点击
 | 
			
		||||
  if (isFocusing.value) return;
 | 
			
		||||
  
 | 
			
		||||
  performFocus();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 启动视频流
 | 
			
		||||
const startStream = async () => {
 | 
			
		||||
  try {
 | 
			
		||||
@@ -711,4 +789,69 @@ img {
 | 
			
		||||
* {
 | 
			
		||||
  transition: all 500ms ease-in-out;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* 对焦动画样式 */
 | 
			
		||||
.focus-starting {
 | 
			
		||||
  border: 3px solid transparent;
 | 
			
		||||
  animation: focus-starting-animation 0.5s ease-in-out forwards;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.focus-success {
 | 
			
		||||
  border: 3px solid transparent;
 | 
			
		||||
  animation: focus-success-animation 2s ease-in-out forwards;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.focus-error {
 | 
			
		||||
  border: 3px solid transparent;
 | 
			
		||||
  animation: focus-error-animation 2s ease-in-out forwards;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes focus-starting-animation {
 | 
			
		||||
  0% {
 | 
			
		||||
    border-color: transparent;
 | 
			
		||||
  }
 | 
			
		||||
  100% {
 | 
			
		||||
    border-color: #fbbf24; /* 黄色 */
 | 
			
		||||
    box-shadow: 0 0 20px rgba(251, 191, 36, 0.5);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes focus-success-animation {
 | 
			
		||||
  0% {
 | 
			
		||||
    border-color: #fbbf24; /* 黄色 */
 | 
			
		||||
    box-shadow: 0 0 20px rgba(251, 191, 36, 0.5);
 | 
			
		||||
  }
 | 
			
		||||
  50% {
 | 
			
		||||
    border-color: #10b981; /* 绿色 */
 | 
			
		||||
    box-shadow: 0 0 20px rgba(16, 185, 129, 0.5);
 | 
			
		||||
  }
 | 
			
		||||
  100% {
 | 
			
		||||
    border-color: transparent;
 | 
			
		||||
    box-shadow: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes focus-error-animation {
 | 
			
		||||
  0% {
 | 
			
		||||
    border-color: #fbbf24; /* 黄色 */
 | 
			
		||||
    box-shadow: 0 0 20px rgba(251, 191, 36, 0.5);
 | 
			
		||||
  }
 | 
			
		||||
  50% {
 | 
			
		||||
    border-color: #ef4444; /* 红色 */
 | 
			
		||||
    box-shadow: 0 0 20px rgba(239, 68, 68, 0.5);
 | 
			
		||||
  }
 | 
			
		||||
  100% {
 | 
			
		||||
    border-color: transparent;
 | 
			
		||||
    box-shadow: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* 对焦状态下的鼠标指针 */
 | 
			
		||||
.cursor-pointer {
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.cursor-not-allowed {
 | 
			
		||||
  cursor: not-allowed;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user