diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 4f25d4f1..e7ac249f 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -31,28 +31,7 @@ import type { TtydStatus, } from '@/types/generated' -const API_BASE = '/api' - -/** - * 通用请求函数 - */ -async function request(endpoint: string, options: RequestInit = {}): Promise { - const response = await fetch(`${API_BASE}${endpoint}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, - credentials: 'include', - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'Request failed' })) - throw new Error(error.message || `HTTP ${response.status}`) - } - - return response.json() -} +import { request } from './request' // ===== 全局配置 API ===== export const configApi = { diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 4f0d7200..f962bdb8 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -1,97 +1,9 @@ // API client for One-KVM backend -import { toast } from 'vue-sonner' +import { request, ApiError } from './request' const API_BASE = '/api' -// Toast debounce mechanism - prevent toast spam (5 seconds) -const toastDebounceMap = new Map() -const TOAST_DEBOUNCE_TIME = 5000 - -function shouldShowToast(key: string): boolean { - const now = Date.now() - const lastToastTime = toastDebounceMap.get(key) - - if (!lastToastTime || now - lastToastTime >= TOAST_DEBOUNCE_TIME) { - toastDebounceMap.set(key, now) - return true - } - - return false -} - -class ApiError extends Error { - status: number - - constructor(status: number, message: string) { - super(message) - this.name = 'ApiError' - this.status = status - } -} - -async function request( - endpoint: string, - options: RequestInit = {} -): Promise { - const url = `${API_BASE}${endpoint}` - - try { - const response = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, - credentials: 'include', - }) - - // Parse response body (all responses are 200 OK with success field) - const data = await response.json().catch(() => ({ - success: false, - message: 'Failed to parse response' - })) - - // Check success field - all errors are indicated by success=false - if (data && typeof data.success === 'boolean' && !data.success) { - const errorMessage = data.message || 'Operation failed' - const apiError = new ApiError(response.status, errorMessage) - - console.info(`[API] ${endpoint} failed:`, errorMessage) - - // Show toast notification to user (with debounce) - if (shouldShowToast(`error_${endpoint}`)) { - toast.error('Operation Failed', { - description: errorMessage, - duration: 4000, - }) - } - - throw apiError - } - - return data - } catch (error) { - // Network errors or JSON parsing errors - if (error instanceof ApiError) { - throw error // Already handled above - } - - // Network connectivity issues - console.info(`[API] Network error for ${endpoint}:`, error) - - // Show toast for network errors (with debounce) - if (shouldShowToast('network_error')) { - toast.error('Network Error', { - description: 'Unable to connect to server. Please check your connection.', - duration: 4000, - }) - } - - throw new ApiError(0, 'Network error') - } -} - // Auth API export const authApi = { login: (username: string, password: string) => diff --git a/web/src/api/request.ts b/web/src/api/request.ts new file mode 100644 index 00000000..d0ebcce0 --- /dev/null +++ b/web/src/api/request.ts @@ -0,0 +1,133 @@ +import { toast } from 'vue-sonner' +import i18n from '@/i18n' + +const API_BASE = '/api' + +// Toast debounce mechanism - prevent toast spam (5 seconds) +const toastDebounceMap = new Map() +const TOAST_DEBOUNCE_TIME = 5000 + +function shouldShowToast(key: string): boolean { + const now = Date.now() + const lastToastTime = toastDebounceMap.get(key) + + if (!lastToastTime || now - lastToastTime >= TOAST_DEBOUNCE_TIME) { + toastDebounceMap.set(key, now) + return true + } + + return false +} + +function t(key: string, params?: Record): string { + return String(i18n.global.t(key, params as any)) +} + +export class ApiError extends Error { + status: number + + constructor(status: number, message: string) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +export interface ApiRequestConfig { + /** + * Enable toast notifications on errors. + * Defaults to true to match existing behavior in api/index.ts. + */ + toastOnError?: boolean + /** + * Toast debounce key. Defaults to `error_${endpoint}`. + */ + toastKey?: string +} + +function getToastKey(endpoint: string, config?: ApiRequestConfig): string { + return config?.toastKey ?? `error_${endpoint}` +} + +function getErrorMessage(data: unknown, fallback: string): string { + if (data && typeof data === 'object') { + const message = (data as any).message + if (typeof message === 'string' && message.trim()) return message + } + return fallback +} + +export async function request( + endpoint: string, + options: RequestInit = {}, + config: ApiRequestConfig = {} +): Promise { + const url = `${API_BASE}${endpoint}` + const toastOnError = config.toastOnError !== false + const toastKey = getToastKey(endpoint, config) + + try { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + credentials: 'include', + }) + + const data = await response.json().catch(() => null) + + // Handle HTTP errors (in case backend returns non-2xx) + if (!response.ok) { + const message = getErrorMessage(data, `HTTP ${response.status}`) + if (toastOnError && shouldShowToast(toastKey)) { + toast.error(t('api.operationFailed'), { + description: message, + duration: 4000, + }) + } + throw new ApiError(response.status, message) + } + + // Handle backend "success=false" convention (even when HTTP is 200) + if (data && typeof (data as any).success === 'boolean' && !(data as any).success) { + const message = getErrorMessage(data, t('api.operationFailedDesc')) + + if (toastOnError && shouldShowToast(toastKey)) { + toast.error(t('api.operationFailed'), { + description: message, + duration: 4000, + }) + } + + throw new ApiError(response.status, message) + } + + // If response body isn't JSON (or empty), treat as failure for callers expecting JSON. + if (data === null) { + const message = t('api.parseResponseFailed') + if (toastOnError && shouldShowToast(toastKey)) { + toast.error(t('api.operationFailed'), { + description: message, + duration: 4000, + }) + } + throw new ApiError(response.status, message) + } + + return data as T + } catch (error) { + if (error instanceof ApiError) throw error + + if (toastOnError && shouldShowToast('network_error')) { + toast.error(t('api.networkError'), { + description: t('api.networkErrorDesc'), + duration: 4000, + }) + } + + throw new ApiError(0, t('api.networkError')) + } +} + diff --git a/web/src/components/HidConfigPopover.vue b/web/src/components/HidConfigPopover.vue index 33bc69f0..a2fd02a9 100644 --- a/web/src/components/HidConfigPopover.vue +++ b/web/src/components/HidConfigPopover.vue @@ -23,7 +23,6 @@ import { MousePointer, Move, Loader2, RefreshCw } from 'lucide-vue-next' import HelpTooltip from '@/components/HelpTooltip.vue' import { configApi } from '@/api' import { useSystemStore } from '@/stores/system' -import { setMouseThrottle } from '@/composables/useHidWebSocket' const props = defineProps<{ open: boolean @@ -39,9 +38,24 @@ const emit = defineEmits<{ const { t } = useI18n() const systemStore = useSystemStore() +const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16 + +function clampMouseMoveSendIntervalMs(ms: number): number { + if (!Number.isFinite(ms)) return DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS + return Math.max(0, Math.min(1000, Math.floor(ms))) +} + +function loadMouseMoveSendIntervalFromStorage(): number { + const raw = localStorage.getItem('hidMouseThrottle') + const parsed = raw === null ? NaN : Number(raw) + return clampMouseMoveSendIntervalMs( + Number.isFinite(parsed) ? parsed : DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS + ) +} + // Mouse Settings (real-time) const mouseThrottle = ref( - Number(localStorage.getItem('hidMouseThrottle')) || 0 + loadMouseMoveSendIntervalFromStorage() ) const showCursor = ref( localStorage.getItem('hidShowCursor') !== 'false' // default true @@ -105,9 +119,7 @@ async function loadDevices() { // Initialize from current config function initializeFromCurrent() { // Re-sync real-time settings from localStorage - const storedThrottle = Number(localStorage.getItem('hidMouseThrottle')) || 0 - mouseThrottle.value = storedThrottle - setMouseThrottle(storedThrottle) + mouseThrottle.value = loadMouseMoveSendIntervalFromStorage() const storedCursor = localStorage.getItem('hidShowCursor') !== 'false' showCursor.value = storedCursor @@ -138,11 +150,14 @@ function toggleMouseMode() { // Update mouse throttle (real-time) function handleThrottleChange(value: number[] | undefined) { if (!value || value.length === 0 || value[0] === undefined) return - const throttleValue = value[0] + const throttleValue = clampMouseMoveSendIntervalMs(value[0]) mouseThrottle.value = throttleValue - setMouseThrottle(throttleValue) // Save to localStorage localStorage.setItem('hidMouseThrottle', String(throttleValue)) + // Notify ConsoleView (storage event doesn't fire in same tab) + window.dispatchEvent(new CustomEvent('hidMouseSendIntervalChanged', { + detail: { intervalMs: throttleValue }, + })) } // Handle backend change @@ -273,7 +288,7 @@ watch(() => props.open, (isOpen) => { @update:model-value="handleThrottleChange" :min="0" :max="1000" - :step="10" + :step="1" class="py-2" />
diff --git a/web/src/composables/useHidWebSocket.ts b/web/src/composables/useHidWebSocket.ts index b41f33c6..e8d7159e 100644 --- a/web/src/composables/useHidWebSocket.ts +++ b/web/src/composables/useHidWebSocket.ts @@ -25,12 +25,6 @@ const networkErrorMessage = ref(null) let reconnectTimeout: number | null = null const hidUnavailable = ref(false) // Track if HID is unavailable to prevent unnecessary reconnects -// Mouse throttle mechanism (10ms = 100Hz for smoother cursor movement) -let mouseThrottleMs = 10 -let lastMouseSendTime = 0 -let pendingMouseEvent: HidMouseEvent | null = null -let throttleTimer: number | null = null - // Connection promise to avoid race conditions let connectionPromise: Promise | null = null let connectionResolved = false @@ -160,11 +154,6 @@ function sendKeyboard(event: HidKeyboardEvent): Promise { }) } -// Set mouse throttle interval (0-1000ms, 0 = no throttle) -export function setMouseThrottle(ms: number) { - mouseThrottleMs = Math.max(0, Math.min(1000, ms)) -} - // Internal function to actually send mouse event function _sendMouseInternal(event: HidMouseEvent): Promise { return new Promise((resolve, reject) => { @@ -182,41 +171,8 @@ function _sendMouseInternal(event: HidMouseEvent): Promise { }) } -// Throttled mouse event sender -// Note: Returns immediately for throttled events to avoid Promise memory leak. -// When an event is throttled, we store it as pending and resolve immediately. -// A timer will send the pending event later, but that's fire-and-forget. function sendMouse(event: HidMouseEvent): Promise { - const now = Date.now() - const elapsed = now - lastMouseSendTime - - if (elapsed >= mouseThrottleMs) { - // Send immediately if enough time has passed - lastMouseSendTime = now - return _sendMouseInternal(event) - } else { - // Throttle: store event for later, resolve immediately to avoid Promise leak - pendingMouseEvent = event - - // Clear existing timer and set a new one - if (throttleTimer !== null) { - clearTimeout(throttleTimer) - } - - // Schedule send after remaining throttle time (fire-and-forget) - throttleTimer = window.setTimeout(() => { - if (pendingMouseEvent) { - lastMouseSendTime = Date.now() - _sendMouseInternal(pendingMouseEvent).catch(() => { - // Silently ignore errors for throttled events - }) - pendingMouseEvent = null - } - }, mouseThrottleMs - elapsed) - - // Resolve immediately - the event is queued, caller doesn't need to wait - return Promise.resolve() - } + return _sendMouseInternal(event) } // Send consumer control event (multimedia keys) diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index fb88baec..0b4a877a 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -41,6 +41,13 @@ export default { toggleTheme: 'Toggle theme', toggleLanguage: 'Toggle language', }, + api: { + operationFailed: 'Operation Failed', + operationFailedDesc: 'Operation failed', + parseResponseFailed: 'Failed to parse response', + networkError: 'Network Error', + networkErrorDesc: 'Unable to connect to server. Please check your connection.', + }, nav: { console: 'Console', msd: 'Virtual Media', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 58fc8f22..d2081995 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -41,6 +41,13 @@ export default { toggleTheme: '切换主题', toggleLanguage: '切换语言', }, + api: { + operationFailed: '操作失败', + operationFailedDesc: '操作失败', + parseResponseFailed: '解析响应失败', + networkError: '网络错误', + networkErrorDesc: '无法连接到服务器,请检查网络连接。', + }, nav: { console: '控制台', msd: '虚拟媒体', diff --git a/web/src/views/ConsoleView.vue b/web/src/views/ConsoleView.vue index 97d86c7c..bce84c80 100644 --- a/web/src/views/ConsoleView.vue +++ b/web/src/views/ConsoleView.vue @@ -124,8 +124,10 @@ const lastMousePosition = ref({ x: 0, y: 0 }) // Track last position for relativ const isPointerLocked = ref(false) // Track pointer lock state // Mouse move throttling (60 Hz = ~16.67ms interval) -const MOUSE_SEND_INTERVAL_MS = 16 -let mouseSendTimer: ReturnType | null = null +const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16 +let mouseMoveSendIntervalMs = DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS +let mouseFlushTimer: ReturnType | null = null +let lastMouseMoveSendTime = 0 let pendingMouseMove: { type: 'move' | 'move_abs'; x: number; y: number } | null = null let accumulatedDelta = { x: 0, y: 0 } // For relative mode: accumulate deltas between sends @@ -1472,7 +1474,7 @@ function handleMouseMove(e: MouseEvent) { mousePosition.value = { x, y } // Queue for throttled sending (absolute mode: just update pending position) pendingMouseMove = { type: 'move_abs', x, y } - ensureMouseSendTimer() + requestMouseMoveFlush() } else { // Relative mode: use movementX/Y when pointer is locked if (isPointerLocked.value) { @@ -1484,7 +1486,7 @@ function handleMouseMove(e: MouseEvent) { // Accumulate deltas for throttled sending accumulatedDelta.x += dx accumulatedDelta.y += dy - ensureMouseSendTimer() + requestMouseMoveFlush() } // Update display position (accumulated delta for display only) @@ -1496,48 +1498,80 @@ function handleMouseMove(e: MouseEvent) { } } -// Start the mouse send timer if not already running -function ensureMouseSendTimer() { - if (mouseSendTimer !== null) return - - // Send immediately on first event, then throttle - flushMouseMove() - - mouseSendTimer = setInterval(() => { - if (!flushMouseMove()) { - // No pending data, stop the timer - if (mouseSendTimer !== null) { - clearInterval(mouseSendTimer) - mouseSendTimer = null - } - } - }, MOUSE_SEND_INTERVAL_MS) +function hasPendingMouseMove(): boolean { + if (mouseMode.value === 'absolute') return pendingMouseMove !== null + return accumulatedDelta.x !== 0 || accumulatedDelta.y !== 0 } -// Flush pending mouse move data, returns true if data was sent -function flushMouseMove(): boolean { +function flushMouseMoveOnce(): boolean { if (mouseMode.value === 'absolute') { - if (pendingMouseMove) { - sendMouseEvent(pendingMouseMove) - pendingMouseMove = null - return true - } - } else { - // Relative mode: send accumulated delta - if (accumulatedDelta.x !== 0 || accumulatedDelta.y !== 0) { - // Clamp to i8 range (-127 to 127) - const clampedDx = Math.max(-127, Math.min(127, accumulatedDelta.x)) - const clampedDy = Math.max(-127, Math.min(127, accumulatedDelta.y)) - - sendMouseEvent({ type: 'move', x: clampedDx, y: clampedDy }) - - // Subtract sent amount (keep remainder for next send if clamped) - accumulatedDelta.x -= clampedDx - accumulatedDelta.y -= clampedDy - return true - } + if (!pendingMouseMove) return false + sendMouseEvent(pendingMouseMove) + pendingMouseMove = null + return true } - return false + + if (accumulatedDelta.x === 0 && accumulatedDelta.y === 0) return false + + // Clamp to i8 range (-127 to 127) + const clampedDx = Math.max(-127, Math.min(127, accumulatedDelta.x)) + const clampedDy = Math.max(-127, Math.min(127, accumulatedDelta.y)) + + sendMouseEvent({ type: 'move', x: clampedDx, y: clampedDy }) + + // Subtract sent amount (keep remainder for next send if clamped) + accumulatedDelta.x -= clampedDx + accumulatedDelta.y -= clampedDy + return true +} + +function scheduleMouseMoveFlush() { + if (mouseFlushTimer !== null) return + + const interval = mouseMoveSendIntervalMs + const now = Date.now() + const elapsed = now - lastMouseMoveSendTime + const delay = interval <= 0 ? 0 : Math.max(0, interval - elapsed) + + mouseFlushTimer = setTimeout(() => { + mouseFlushTimer = null + + const burstLimit = mouseMoveSendIntervalMs <= 0 ? 8 : 1 + let sent = false + for (let i = 0; i < burstLimit; i++) { + if (!flushMouseMoveOnce()) break + sent = true + if (!hasPendingMouseMove()) break + } + if (sent) lastMouseMoveSendTime = Date.now() + + if (hasPendingMouseMove()) { + scheduleMouseMoveFlush() + } + }, delay) +} + +function requestMouseMoveFlush() { + const interval = mouseMoveSendIntervalMs + const now = Date.now() + + if (interval <= 0 || now - lastMouseMoveSendTime >= interval) { + const burstLimit = interval <= 0 ? 8 : 1 + let sent = false + for (let i = 0; i < burstLimit; i++) { + if (!flushMouseMoveOnce()) break + sent = true + if (!hasPendingMouseMove()) break + } + if (sent) lastMouseMoveSendTime = Date.now() + + if (hasPendingMouseMove()) { + scheduleMouseMoveFlush() + } + return + } + + scheduleMouseMoveFlush() } // Track pressed mouse button for window-level mouseup handling @@ -1656,6 +1690,41 @@ function handleCursorVisibilityChange(e: Event) { cursorVisible.value = customEvent.detail.visible } +function clampMouseMoveSendIntervalMs(ms: number): number { + if (!Number.isFinite(ms)) return DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS + return Math.max(0, Math.min(1000, Math.floor(ms))) +} + +function loadMouseMoveSendIntervalFromStorage(): number { + const raw = localStorage.getItem('hidMouseThrottle') + const parsed = raw === null ? NaN : Number(raw) + return clampMouseMoveSendIntervalMs( + Number.isFinite(parsed) ? parsed : DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS + ) +} + +function setMouseMoveSendInterval(ms: number) { + mouseMoveSendIntervalMs = clampMouseMoveSendIntervalMs(ms) + + if (mouseFlushTimer !== null) { + clearTimeout(mouseFlushTimer) + mouseFlushTimer = null + } + if (hasPendingMouseMove()) { + requestMouseMoveFlush() + } +} + +function handleMouseSendIntervalChange(e: Event) { + const customEvent = e as CustomEvent<{ intervalMs: number }> + setMouseMoveSendInterval(customEvent.detail?.intervalMs) +} + +function handleMouseSendIntervalStorage(e: StorageEvent) { + if (e.key !== 'hidMouseThrottle') return + setMouseMoveSendInterval(loadMouseMoveSendIntervalFromStorage()) +} + // ActionBar handlers // (MSD and Settings are now handled by ActionBar component directly) @@ -1687,6 +1756,8 @@ function handleToggleMouseMode() { } mouseMode.value = mouseMode.value === 'absolute' ? 'relative' : 'absolute' + pendingMouseMove = null + accumulatedDelta = { x: 0, y: 0 } // Reset position when switching modes lastMousePosition.value = { x: 0, y: 0 } mousePosition.value = { x: 0, y: 0 } @@ -1727,8 +1798,12 @@ onMounted(async () => { window.addEventListener('blur', handleBlur) window.addEventListener('mouseup', handleWindowMouseUp) + setMouseMoveSendInterval(loadMouseMoveSendIntervalFromStorage()) + // Listen for cursor visibility changes from HidConfigPopover window.addEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener) + window.addEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener) + window.addEventListener('storage', handleMouseSendIntervalStorage) // Pointer Lock event listeners document.addEventListener('pointerlockchange', handlePointerLockChange) @@ -1757,10 +1832,10 @@ onUnmounted(() => { // Reset initial device info flag initialDeviceInfoReceived = false - // Clear mouse send timer - if (mouseSendTimer !== null) { - clearInterval(mouseSendTimer) - mouseSendTimer = null + // Clear mouse flush timer + if (mouseFlushTimer !== null) { + clearTimeout(mouseFlushTimer) + mouseFlushTimer = null } // Clear ttyd poll interval @@ -1799,6 +1874,8 @@ onUnmounted(() => { window.removeEventListener('blur', handleBlur) window.removeEventListener('mouseup', handleWindowMouseUp) window.removeEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener) + window.removeEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener) + window.removeEventListener('storage', handleMouseSendIntervalStorage) // Remove pointer lock event listeners document.removeEventListener('pointerlockchange', handlePointerLockChange)