Files
One-KVM/web/src/api/index.ts
mofeng-git 206594e292 feat(video): 事务化切换与前端统一编排,增强视频输入格式支持
- 后端:切换事务+transition_id,/stream/mode 返回 switching/transition_id 与实际 codec

- 事件:新增 mode_switching/mode_ready,config/webrtc_ready/mode_changed 关联事务

- 编码/格式:扩展 NV21/NV16/NV24/RGB/BGR 输入与转换链路,RKMPP direct input 优化

- 前端:useVideoSession 统一切换,失败回退真实切回 MJPEG,菜单格式同步修复

- 清理:useVideoStream 降级为 MJPEG-only
2026-01-11 10:41:57 +08:00

729 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// API client for One-KVM backend
import { toast } from 'vue-sonner'
const API_BASE = '/api'
// Toast debounce mechanism - prevent toast spam (5 seconds)
const toastDebounceMap = new Map<string, number>()
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<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
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) =>
request<{ success: boolean; message?: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
}),
logout: () =>
request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
check: () =>
request<{ authenticated: boolean; user?: string; is_admin?: boolean }>('/auth/check'),
}
// System API
export interface NetworkAddress {
interface: string
ip: string
}
export interface DeviceInfo {
hostname: string
cpu_model: string
cpu_usage: number
memory_total: number
memory_used: number
network_addresses: NetworkAddress[]
}
export const systemApi = {
info: () =>
request<{
version: string
build_date: string
initialized: boolean
capabilities: {
video: { available: boolean; backend?: string }
hid: { available: boolean; backend?: string }
msd: { available: boolean }
atx: { available: boolean; backend?: string }
audio: { available: boolean; backend?: string }
}
disk_space?: {
total: number
available: number
used: number
}
device_info?: DeviceInfo
}>('/info'),
health: () => request<{ status: string; version: string }>('/health'),
setupStatus: () =>
request<{ initialized: boolean; needs_setup: boolean }>('/setup'),
setup: (data: {
username: string
password: string
video_device?: string
video_format?: string
video_width?: number
video_height?: number
video_fps?: number
hid_backend?: string
hid_ch9329_port?: string
hid_ch9329_baudrate?: number
hid_otg_udc?: string
encoder_backend?: string
audio_device?: string
ttyd_enabled?: boolean
rustdesk_enabled?: boolean
}) =>
request<{ success: boolean; message?: string }>('/setup/init', {
method: 'POST',
body: JSON.stringify(data),
}),
restart: () =>
request<{ success: boolean; message?: string }>('/system/restart', {
method: 'POST',
}),
}
// Stream API
export interface VideoCodecInfo {
id: string
name: string
protocol: 'http' | 'webrtc'
hardware: boolean
backend: string | null
available: boolean
}
export interface EncoderBackendInfo {
id: string
name: string
is_hardware: boolean
supported_formats: string[]
}
export interface AvailableCodecsResponse {
success: boolean
backends: EncoderBackendInfo[]
codecs: VideoCodecInfo[]
}
export const streamApi = {
status: () =>
request<{
state: 'uninitialized' | 'ready' | 'streaming' | 'no_signal' | 'error'
device: string | null
format: string | null
resolution: [number, number] | null
clients: number
target_fps: number
fps: number
frames_captured: number
frames_dropped: number
}>('/stream/status'),
start: () =>
request<{ success: boolean }>('/stream/start', { method: 'POST' }),
stop: () =>
request<{ success: boolean }>('/stream/stop', { method: 'POST' }),
getMjpegUrl: (clientId?: string) => {
const base = `${API_BASE}/stream/mjpeg`
return clientId ? `${base}?client_id=${clientId}` : base
},
getSnapshotUrl: () => `${API_BASE}/snapshot`,
getMode: () =>
request<{ success: boolean; mode: string; transition_id?: string; switching?: boolean; message?: string }>('/stream/mode'),
setMode: (mode: string) =>
request<{ success: boolean; mode: string; transition_id?: string; switching?: boolean; message?: string }>('/stream/mode', {
method: 'POST',
body: JSON.stringify({ mode }),
}),
getCodecs: () =>
request<AvailableCodecsResponse>('/stream/codecs'),
setBitratePreset: (bitrate_preset: import('@/types/generated').BitratePreset) =>
request<{ success: boolean; message?: string }>('/stream/bitrate', {
method: 'POST',
body: JSON.stringify({ bitrate_preset }),
}),
}
// WebRTC API
export interface IceCandidate {
candidate: string
sdpMid?: string
sdpMLineIndex?: number
usernameFragment?: string
}
export interface IceServerConfig {
urls: string[]
username?: string
credential?: string
}
export const webrtcApi = {
createSession: () =>
request<{ session_id: string }>('/webrtc/session', { method: 'POST' }),
offer: (sdp: string, clientId?: string) =>
request<{ sdp: string; session_id: string; ice_candidates: IceCandidate[] }>('/webrtc/offer', {
method: 'POST',
body: JSON.stringify({ sdp, client_id: clientId }),
}),
addIceCandidate: (sessionId: string, candidate: IceCandidate) =>
request<{ success: boolean }>('/webrtc/ice', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId, candidate }),
}),
status: () =>
request<{
session_count: number
sessions: Array<{ session_id: string; state: string }>
}>('/webrtc/status'),
close: (sessionId: string) =>
request<{ success: boolean }>('/webrtc/close', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId }),
}),
getIceServers: () =>
request<{ ice_servers: IceServerConfig[] }>('/webrtc/ice-servers'),
}
// HID API
// Import HID WebSocket composable
import { useHidWebSocket, type HidKeyboardEvent, type HidMouseEvent } from '@/composables/useHidWebSocket'
// Create shared HID WebSocket instance
const hidWs = useHidWebSocket()
let hidWsInitialized = false
// Initialize HID WebSocket connection
async function ensureHidConnection() {
if (!hidWsInitialized) {
hidWsInitialized = true
await hidWs.connect()
}
}
// Map button string to number
function mapButton(button?: 'left' | 'right' | 'middle'): number | undefined {
if (!button) return undefined
const buttonMap = { left: 0, middle: 1, right: 2 }
return buttonMap[button]
}
export const hidApi = {
status: () =>
request<{
available: boolean
backend: string
initialized: boolean
supports_absolute_mouse: boolean
screen_resolution: [number, number] | null
}>('/hid/status'),
keyboard: async (type: 'down' | 'up', key: number, modifiers?: {
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
}) => {
await ensureHidConnection()
const event: HidKeyboardEvent = {
type: type === 'down' ? 'keydown' : 'keyup',
key,
modifiers,
}
await hidWs.sendKeyboard(event)
return { success: true }
},
mouse: async (data: {
type: 'move' | 'move_abs' | 'down' | 'up' | 'scroll'
x?: number | null
y?: number | null
button?: 'left' | 'right' | 'middle' | null
scroll?: number | null
}) => {
await ensureHidConnection()
// Ensure all values are properly typed (convert null to undefined)
const event: HidMouseEvent = {
type: data.type === 'move_abs' ? 'moveabs' : data.type,
x: data.x ?? undefined,
y: data.y ?? undefined,
button: mapButton(data.button ?? undefined),
scroll: data.scroll ?? undefined,
}
await hidWs.sendMouse(event)
return { success: true }
},
reset: () =>
request<{ success: boolean }>('/hid/reset', { method: 'POST' }),
consumer: async (usage: number) => {
await ensureHidConnection()
await hidWs.sendConsumer({ usage })
return { success: true }
},
// WebSocket connection management
connectWebSocket: () => hidWs.connect(),
disconnectWebSocket: () => hidWs.disconnect(),
isWebSocketConnected: () => hidWs.connected.value,
}
// ATX API
export const atxApi = {
status: () =>
request<{
available: boolean
backend: string
initialized: boolean
power_status: 'on' | 'off' | 'unknown'
led_supported: boolean
}>('/atx/status'),
power: (action: 'short' | 'long' | 'reset') =>
request<{ success: boolean; message?: string }>('/atx/power', {
method: 'POST',
body: JSON.stringify({ action }),
}),
}
// MSD API
export interface MsdImage {
id: string
name: string
size: number
created_at: string
}
export interface DriveFile {
name: string
path: string
is_dir: boolean
size: number
}
export const msdApi = {
status: () =>
request<{
available: boolean
state: {
connected: boolean
mode: 'none' | 'image' | 'drive'
current_image: {
id: string
name: string
size: number
created_at: string
} | null
drive_info: {
size: number
used: number
free: number
initialized: boolean
} | null
}
}>('/msd/status'),
// Image management
listImages: () => request<MsdImage[]>('/msd/images'),
uploadImage: async (file: File, onProgress?: (progress: number) => void) => {
const formData = new FormData()
formData.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', `${API_BASE}/msd/images`)
xhr.withCredentials = true
return new Promise<MsdImage>((resolve, reject) => {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress((e.loaded / e.total) * 100)
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(new ApiError(xhr.status, 'Upload failed'))
}
}
xhr.onerror = () => reject(new ApiError(0, 'Network error'))
xhr.send(formData)
})
},
deleteImage: (id: string) =>
request<{ success: boolean }>(`/msd/images/${id}`, { method: 'DELETE' }),
connect: (mode: 'image' | 'drive', imageId?: string, cdrom?: boolean, readOnly?: boolean) =>
request<{ success: boolean }>('/msd/connect', {
method: 'POST',
body: JSON.stringify({ mode, image_id: imageId, cdrom, read_only: readOnly }),
}),
disconnect: () =>
request<{ success: boolean }>('/msd/disconnect', { method: 'POST' }),
// Virtual drive
driveInfo: () =>
request<{
size: number
used: number
free: number
initialized: boolean
}>('/msd/drive'),
initDrive: (sizeMb?: number) =>
request<{ path: string; size_mb: number }>('/msd/drive/init', {
method: 'POST',
body: JSON.stringify({ size_mb: sizeMb }),
}),
deleteDrive: () =>
request<{ success: boolean }>('/msd/drive', { method: 'DELETE' }),
listDriveFiles: (path = '/') =>
request<DriveFile[]>(`/msd/drive/files?path=${encodeURIComponent(path)}`),
uploadDriveFile: async (file: File, targetPath = '/', onProgress?: (progress: number) => void) => {
const formData = new FormData()
formData.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', `${API_BASE}/msd/drive/files?path=${encodeURIComponent(targetPath)}`)
xhr.withCredentials = true
return new Promise<{ success: boolean; message?: string }>((resolve, reject) => {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress((e.loaded / e.total) * 100)
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(new ApiError(xhr.status, 'Upload failed'))
}
}
xhr.onerror = () => reject(new ApiError(0, 'Network error'))
xhr.send(formData)
})
},
downloadDriveFile: (path: string) =>
`${API_BASE}/msd/drive/files${path.startsWith('/') ? path : '/' + path}`,
deleteDriveFile: (path: string) =>
request<{ success: boolean }>(`/msd/drive/files${path.startsWith('/') ? path : '/' + path}`, {
method: 'DELETE',
}),
createDirectory: (path: string) =>
request<{ success: boolean }>(`/msd/drive/mkdir${path.startsWith('/') ? path : '/' + path}`, {
method: 'POST',
}),
// Download from URL
downloadFromUrl: (url: string, filename?: string) =>
request<{
download_id: string
url: string
filename: string
bytes_downloaded: number
total_bytes: number | null
progress_pct: number | null
status: string
error: string | null
}>('/msd/images/download', {
method: 'POST',
body: JSON.stringify({ url, filename }),
}),
cancelDownload: (downloadId: string) =>
request<{ success: boolean }>('/msd/images/download/cancel', {
method: 'POST',
body: JSON.stringify({ download_id: downloadId }),
}),
}
// Config API
/** @deprecated 使用域特定 APIvideoConfigApi, hidConfigApi 等)替代 */
export const configApi = {
get: () => request<Record<string, unknown>>('/config'),
/** @deprecated 使用域特定 API 的 update 方法替代 */
update: (updates: Record<string, unknown>) =>
request<{ success: boolean }>('/config', {
method: 'POST',
body: JSON.stringify(updates),
}),
listDevices: () =>
request<{
video: Array<{
path: string
name: string
driver: string
formats: Array<{
format: string
description: string
resolutions: Array<{
width: number
height: number
fps: number[]
}>
}>
usb_bus: string | null
}>
serial: Array<{ path: string; name: string }>
audio: Array<{
name: string
description: string
is_hdmi: boolean
usb_bus: string | null
}>
udc: Array<{ name: string }>
extensions: {
ttyd_available: boolean
rustdesk_available: boolean
}
}>('/devices'),
}
// 导出新的域分离配置 API
export {
videoConfigApi,
streamConfigApi,
hidConfigApi,
msdConfigApi,
atxConfigApi,
audioConfigApi,
extensionsApi,
rustdeskConfigApi,
webConfigApi,
type RustDeskConfigResponse,
type RustDeskStatusResponse,
type RustDeskConfigUpdate,
type RustDeskPasswordResponse,
type WebConfig,
} from './config'
// 导出生成的类型
export type {
AppConfig,
VideoConfig,
VideoConfigUpdate,
StreamConfig,
StreamConfigUpdate,
HidConfig,
HidConfigUpdate,
MsdConfig,
MsdConfigUpdate,
AtxConfig,
AtxConfigUpdate,
AudioConfig,
AudioConfigUpdate,
HidBackend,
StreamMode,
EncoderType,
BitratePreset,
} from '@/types/generated'
// Audio API
export const audioApi = {
status: () =>
request<{
enabled: boolean
streaming: boolean
device: string | null
sample_rate: number
channels: number
quality: string
subscriber_count: number
frames_encoded: number
bytes_output: number
error: string | null
}>('/audio/status'),
start: () =>
request<{ success: boolean }>('/audio/start', { method: 'POST' }),
stop: () =>
request<{ success: boolean }>('/audio/stop', { method: 'POST' }),
setQuality: (quality: 'voice' | 'balanced' | 'high') =>
request<{ success: boolean }>('/audio/quality', {
method: 'POST',
body: JSON.stringify({ quality }),
}),
selectDevice: (device: string) =>
request<{ success: boolean }>('/audio/device', {
method: 'POST',
body: JSON.stringify({ device }),
}),
}
// User Management API
export interface User {
id: string
username: string
role: 'admin' | 'user'
created_at: string
}
interface UserApiResponse {
id: string
username: string
is_admin: boolean
created_at: string
}
export const userApi = {
list: async () => {
const rawUsers = await request<UserApiResponse[]>('/users')
const users: User[] = rawUsers.map(u => ({
id: u.id,
username: u.username,
role: u.is_admin ? 'admin' : 'user',
created_at: u.created_at,
}))
return { success: true, users }
},
create: (username: string, password: string, role: 'admin' | 'user' = 'user') =>
request<UserApiResponse>('/users', {
method: 'POST',
body: JSON.stringify({ username, password, is_admin: role === 'admin' }),
}),
update: (id: string, data: { username?: string; role?: 'admin' | 'user' }) =>
request<{ success: boolean }>(`/users/${id}`, {
method: 'PUT',
body: JSON.stringify({ username: data.username, is_admin: data.role === 'admin' }),
}),
delete: (id: string) =>
request<{ success: boolean }>(`/users/${id}`, { method: 'DELETE' }),
changePassword: (id: string, newPassword: string, currentPassword?: string) =>
request<{ success: boolean }>(`/users/${id}/password`, {
method: 'POST',
body: JSON.stringify({ new_password: newPassword, current_password: currentPassword }),
}),
}
export { ApiError }