feat: 完善架构优化性能

- 调整音视频架构,提升 RKMPP 编码 MJPEG-->H264 性能,同时解决丢帧马赛克问题;
- 删除多用户逻辑,只保留单用户,支持设置 web 单会话;
- 修复删除体验不好的的回退逻辑,前端页面菜单位置微调;
- 增加 OTG USB 设备动态调整功能;
- 修复 mdns 问题,webrtc 视频切换更顺畅。
This commit is contained in:
mofeng
2026-01-25 16:04:29 +08:00
parent 01e01430da
commit 1786b7689d
66 changed files with 4225 additions and 2936 deletions

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>One-KVM</title>
</head>

14
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "web",
"version": "0.0.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web",
"version": "0.0.0",
"version": "0.1.1",
"dependencies": {
"@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1",
@@ -1368,6 +1368,7 @@
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1782,6 +1783,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2448,6 +2450,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2495,6 +2498,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -2787,7 +2791,8 @@
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.17.tgz",
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.3.0",
@@ -2841,6 +2846,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2906,6 +2912,7 @@
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -2987,6 +2994,7 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",

BIN
web/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -7,6 +7,8 @@
import type {
AppConfig,
AuthConfig,
AuthConfigUpdate,
VideoConfig,
VideoConfigUpdate,
StreamConfigResponse,
@@ -41,6 +43,24 @@ export const configApi = {
getAll: () => request<AppConfig>('/config'),
}
// ===== Auth 配置 API =====
export const authConfigApi = {
/**
* 获取认证配置
*/
get: () => request<AuthConfig>('/config/auth'),
/**
* 更新认证配置
* @param config 要更新的字段
*/
update: (config: AuthConfigUpdate) =>
request<AuthConfig>('/config/auth', {
method: 'PATCH',
body: JSON.stringify(config),
}),
}
// ===== Video 配置 API =====
export const videoConfigApi = {
/**

View File

@@ -17,6 +17,18 @@ export const authApi = {
check: () =>
request<{ authenticated: boolean; user?: string; is_admin?: boolean }>('/auth/check'),
changePassword: (currentPassword: string, newPassword: string) =>
request<{ success: boolean }>('/auth/password', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
}),
changeUsername: (username: string, currentPassword: string) =>
request<{ success: boolean }>('/auth/username', {
method: 'POST',
body: JSON.stringify({ username, current_password: currentPassword }),
}),
}
// System API
@@ -121,8 +133,6 @@ export const streamApi = {
clients: number
target_fps: number
fps: number
frames_captured: number
frames_dropped: number
}>('/stream/status'),
start: () =>
@@ -200,7 +210,7 @@ export const webrtcApi = {
}),
getIceServers: () =>
request<{ ice_servers: IceServerConfig[] }>('/webrtc/ice-servers'),
request<{ ice_servers: IceServerConfig[]; mdns_mode: string }>('/webrtc/ice-servers'),
}
// HID API
@@ -516,6 +526,7 @@ export const configApi = {
// 导出新的域分离配置 API
export {
authConfigApi,
videoConfigApi,
streamConfigApi,
hidConfigApi,
@@ -535,6 +546,8 @@ export {
// 导出生成的类型
export type {
AppConfig,
AuthConfig,
AuthConfigUpdate,
VideoConfig,
VideoConfigUpdate,
StreamConfig,
@@ -588,53 +601,4 @@ export const audioApi = {
}),
}
// 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 }

View File

@@ -6,6 +6,7 @@ const API_BASE = '/api'
// Toast debounce mechanism - prevent toast spam (5 seconds)
const toastDebounceMap = new Map<string, number>()
const TOAST_DEBOUNCE_TIME = 5000
let sessionExpiredNotified = false
function shouldShowToast(key: string): boolean {
const now = Date.now()
@@ -81,7 +82,26 @@ export async function request<T>(
// Handle HTTP errors (in case backend returns non-2xx)
if (!response.ok) {
const message = getErrorMessage(data, `HTTP ${response.status}`)
if (toastOnError && shouldShowToast(toastKey)) {
const normalized = message.toLowerCase()
const isNotAuthenticated = normalized.includes('not authenticated')
if (response.status === 401 && !sessionExpiredNotified) {
const isLoggedInElsewhere = normalized.includes('logged in elsewhere')
const isSessionExpired = normalized.includes('session expired')
if (isLoggedInElsewhere || isSessionExpired) {
sessionExpiredNotified = true
const titleKey = isLoggedInElsewhere ? 'auth.loggedInElsewhere' : 'auth.sessionExpired'
if (toastOnError && shouldShowToast('error_session_expired')) {
toast.error(t(titleKey), {
description: message,
duration: 3000,
})
}
setTimeout(() => {
window.location.reload()
}, 1200)
}
}
if (toastOnError && shouldShowToast(toastKey) && !(response.status === 401 && isNotAuthenticated)) {
toast.error(t('api.operationFailed'), {
description: message,
duration: 4000,
@@ -130,4 +150,3 @@ export async function request<T>(
throw new ApiError(0, t('api.networkError'))
}
}

View File

@@ -32,6 +32,10 @@ function createVideoSession() {
resolve: (ready: boolean) => void
timer: ReturnType<typeof setTimeout>
} | null = null
let webrtcReadyAnyWaiter: {
resolve: (ready: boolean) => void
timer: ReturnType<typeof setTimeout>
} | null = null
let modeReadyWaiter: {
transitionId: string
@@ -62,6 +66,11 @@ function createVideoSession() {
webrtcReadyWaiter.resolve(false)
webrtcReadyWaiter = null
}
if (webrtcReadyAnyWaiter) {
clearTimeout(webrtcReadyAnyWaiter.timer)
webrtcReadyAnyWaiter.resolve(false)
webrtcReadyAnyWaiter = null
}
if (modeReadyWaiter) {
clearTimeout(modeReadyWaiter.timer)
modeReadyWaiter.resolve(null)
@@ -104,6 +113,28 @@ function createVideoSession() {
})
}
function waitForWebRTCReadyAny(timeoutMs = 3000): Promise<boolean> {
if (webrtcReadyAnyWaiter) {
clearTimeout(webrtcReadyAnyWaiter.timer)
webrtcReadyAnyWaiter.resolve(false)
webrtcReadyAnyWaiter = null
}
return new Promise((resolve) => {
const timer = setTimeout(() => {
if (webrtcReadyAnyWaiter) {
webrtcReadyAnyWaiter = null
}
resolve(false)
}, timeoutMs)
webrtcReadyAnyWaiter = {
resolve,
timer,
}
})
}
function waitForModeReady(transitionId: string, timeoutMs = 5000): Promise<string | null> {
if (modeReadyWaiter) {
clearTimeout(modeReadyWaiter.timer)
@@ -156,6 +187,10 @@ function createVideoSession() {
clearTimeout(webrtcReadyWaiter.timer)
webrtcReadyWaiter.resolve(true)
webrtcReadyWaiter = null
} else if (!data.transition_id && webrtcReadyAnyWaiter) {
clearTimeout(webrtcReadyAnyWaiter.timer)
webrtcReadyAnyWaiter.resolve(true)
webrtcReadyAnyWaiter = null
}
}
@@ -170,6 +205,7 @@ function createVideoSession() {
clearWaiters,
registerTransition,
waitForWebRTCReady,
waitForWebRTCReadyAny,
waitForModeReady,
onModeSwitching,
onModeReady,

View File

@@ -2,7 +2,7 @@
// Provides low-latency video via WebRTC with DataChannel for HID
import { ref, onUnmounted, computed, type Ref } from 'vue'
import { webrtcApi } from '@/api'
import { webrtcApi, type IceCandidate } from '@/api'
import { generateUUID } from '@/lib/utils'
import {
type HidKeyboardEvent,
@@ -10,6 +10,7 @@ import {
encodeKeyboardEvent,
encodeMouseEvent,
} from '@/types/hid'
import { useWebSocket } from '@/composables/useWebSocket'
export type { HidKeyboardEvent, HidMouseEvent }
@@ -39,10 +40,25 @@ export interface WebRTCStats {
// Cached ICE servers from backend API
let cachedIceServers: RTCIceServer[] | null = null
interface WebRTCIceCandidateEvent {
session_id: string
candidate: IceCandidate
}
interface WebRTCIceCompleteEvent {
session_id: string
}
// Fetch ICE servers from backend API
async function fetchIceServers(): Promise<RTCIceServer[]> {
try {
const response = await webrtcApi.getIceServers()
if (response.mdns_mode) {
allowMdnsHostCandidates = response.mdns_mode !== 'disabled'
} else if (response.ice_servers) {
allowMdnsHostCandidates = response.ice_servers.length === 0
}
if (response.ice_servers && response.ice_servers.length > 0) {
cachedIceServers = response.ice_servers.map(server => ({
urls: server.urls,
@@ -65,6 +81,7 @@ async function fetchIceServers(): Promise<RTCIceServer[]> {
window.location.hostname.startsWith('10.'))
if (isLocalConnection) {
allowMdnsHostCandidates = false
console.log('[WebRTC] Local connection detected, using host candidates only')
return []
}
@@ -83,8 +100,16 @@ let sessionId: string | null = null
let statsInterval: number | null = null
let isConnecting = false // Lock to prevent concurrent connect calls
let pendingIceCandidates: RTCIceCandidate[] = [] // Queue for ICE candidates before sessionId is set
let pendingRemoteCandidates: WebRTCIceCandidateEvent[] = [] // Queue for server ICE candidates
let pendingRemoteIceComplete = new Set<string>() // Session IDs waiting for end-of-candidates
let seenRemoteCandidates = new Set<string>() // Deduplicate server ICE candidates
let cachedMediaStream: MediaStream | null = null // Cached MediaStream to avoid recreating
let allowMdnsHostCandidates = false
let wsHandlersRegistered = false
const { on: wsOn } = useWebSocket()
const state = ref<WebRTCState>('disconnected')
const videoTrack = ref<MediaStreamTrack | null>(null)
const audioTrack = ref<MediaStreamTrack | null>(null)
@@ -148,6 +173,7 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
// Handle ICE candidates
pc.onicecandidate = async (event) => {
if (!event.candidate) return
if (shouldSkipLocalCandidate(event.candidate)) return
const currentSessionId = sessionId
if (currentSessionId && pc.connectionState !== 'closed') {
@@ -218,6 +244,99 @@ function createDataChannel(pc: RTCPeerConnection): RTCDataChannel {
return channel
}
function registerWebSocketHandlers() {
if (wsHandlersRegistered) return
wsHandlersRegistered = true
wsOn('webrtc.ice_candidate', handleRemoteIceCandidate)
wsOn('webrtc.ice_complete', handleRemoteIceComplete)
}
function shouldSkipLocalCandidate(candidate: RTCIceCandidate): boolean {
if (allowMdnsHostCandidates) return false
const value = candidate.candidate || ''
return value.includes(' typ host') && value.includes('.local')
}
async function handleRemoteIceCandidate(data: WebRTCIceCandidateEvent) {
if (!data || !data.candidate) return
// Queue until session is ready and remote description is set
if (!sessionId) {
pendingRemoteCandidates.push(data)
return
}
if (data.session_id !== sessionId) return
if (!peerConnection || !peerConnection.remoteDescription) {
pendingRemoteCandidates.push(data)
return
}
await addRemoteIceCandidate(data.candidate)
}
async function handleRemoteIceComplete(data: WebRTCIceCompleteEvent) {
if (!data || !data.session_id) return
if (!sessionId) {
pendingRemoteIceComplete.add(data.session_id)
return
}
if (data.session_id !== sessionId) return
if (!peerConnection || !peerConnection.remoteDescription) {
pendingRemoteIceComplete.add(data.session_id)
return
}
try {
await peerConnection.addIceCandidate(null)
} catch {
// End-of-candidates failures are non-fatal
}
}
async function addRemoteIceCandidate(candidate: IceCandidate) {
if (!peerConnection) return
if (!candidate.candidate) return
if (seenRemoteCandidates.has(candidate.candidate)) return
seenRemoteCandidates.add(candidate.candidate)
const iceCandidate: RTCIceCandidateInit = {
candidate: candidate.candidate,
sdpMid: candidate.sdpMid ?? undefined,
sdpMLineIndex: candidate.sdpMLineIndex ?? undefined,
usernameFragment: candidate.usernameFragment ?? undefined,
}
try {
await peerConnection.addIceCandidate(iceCandidate)
} catch {
// ICE candidate add failures are non-fatal
}
}
async function flushPendingRemoteIce() {
if (!peerConnection || !sessionId || !peerConnection.remoteDescription) return
const remaining: WebRTCIceCandidateEvent[] = []
for (const event of pendingRemoteCandidates) {
if (event.session_id === sessionId) {
await addRemoteIceCandidate(event.candidate)
} else {
// Drop candidates for old sessions
}
}
pendingRemoteCandidates = remaining
if (pendingRemoteIceComplete.has(sessionId)) {
pendingRemoteIceComplete.delete(sessionId)
try {
await peerConnection.addIceCandidate(null)
} catch {
// Ignore end-of-candidates errors
}
}
}
// Start collecting stats
function startStatsCollection() {
if (statsInterval) return
@@ -315,6 +434,7 @@ async function flushPendingIceCandidates() {
pendingIceCandidates = []
for (const candidate of candidates) {
if (shouldSkipLocalCandidate(candidate)) continue
try {
await webrtcApi.addIceCandidate(sessionId, {
candidate: candidate.candidate,
@@ -330,6 +450,8 @@ async function flushPendingIceCandidates() {
// Connect to WebRTC server
async function connect(): Promise<boolean> {
registerWebSocketHandlers()
// Prevent concurrent connection attempts
if (isConnecting) {
return false
@@ -384,19 +506,13 @@ async function connect(): Promise<boolean> {
}
await peerConnection.setRemoteDescription(answer)
// Flush any pending server ICE candidates once remote description is set
await flushPendingRemoteIce()
// Add any ICE candidates from the response
if (response.ice_candidates && response.ice_candidates.length > 0) {
for (const candidateObj of response.ice_candidates) {
try {
const iceCandidate: RTCIceCandidateInit = {
candidate: candidateObj.candidate,
sdpMid: candidateObj.sdpMid ?? '0',
sdpMLineIndex: candidateObj.sdpMLineIndex ?? 0,
}
await peerConnection.addIceCandidate(iceCandidate)
} catch {
// ICE candidate add failures are non-fatal
}
await addRemoteIceCandidate(candidateObj)
}
}
@@ -440,6 +556,9 @@ async function disconnect() {
sessionId = null
isConnecting = false
pendingIceCandidates = []
pendingRemoteCandidates = []
pendingRemoteIceComplete.clear()
seenRemoteCandidates.clear()
if (dataChannel) {
dataChannel.close()

View File

@@ -76,6 +76,8 @@ export default {
passwordTooShort: 'Password must be at least 4 characters',
passwordChanged: 'Password changed successfully',
userNotFound: 'User not found',
sessionExpired: 'Session expired',
loggedInElsewhere: 'Logged in elsewhere',
},
status: {
connected: 'Connected',
@@ -270,8 +272,6 @@ export default {
extensionsDescription: 'Choose which extensions to auto-start',
ttydTitle: 'Web Terminal (ttyd)',
ttydDescription: 'Access device command line in browser',
rustdeskTitle: 'RustDesk Remote Desktop',
rustdeskDescription: 'Remote access via RustDesk client',
extensionsHint: 'These settings can be changed later in Settings',
notInstalled: 'Not installed',
// Password strength
@@ -427,6 +427,8 @@ export default {
basic: 'Basic',
general: 'General',
appearance: 'Appearance',
account: 'User',
access: 'Access',
video: 'Video',
encoder: 'Encoder',
hid: 'HID',
@@ -436,6 +438,7 @@ export default {
users: 'Users',
hardware: 'Hardware',
system: 'System',
other: 'Other',
extensions: 'Extensions',
configured: 'Configured',
security: 'Security',
@@ -457,6 +460,8 @@ export default {
changePassword: 'Change Password',
currentPassword: 'Current Password',
newPassword: 'New Password',
usernameDesc: 'Change your login username',
passwordDesc: 'Change your login password',
version: 'Version',
buildInfo: 'Build Info',
detectDevices: 'Detect Devices',
@@ -470,7 +475,7 @@ export default {
httpPort: 'HTTP Port',
configureHttpPort: 'Configure HTTP server port',
// Web server
webServer: 'Basic',
webServer: 'Access Address',
webServerDesc: 'Configure HTTP/HTTPS ports and bind address. Restart required for changes to take effect.',
httpsPort: 'HTTPS Port',
bindAddress: 'Bind Address',
@@ -480,6 +485,13 @@ export default {
restartRequired: 'Restart Required',
restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.',
restarting: 'Restarting...',
// Auth
auth: 'Access',
authSettings: 'Access Settings',
authSettingsDesc: 'Single-user access and session behavior',
allowMultipleSessions: 'Allow multiple web sessions',
allowMultipleSessionsDesc: 'When disabled, a new login will kick the previous session.',
singleUserSessionNote: 'Single-user mode is enforced; only session concurrency is configurable.',
// User management
userManagement: 'User Management',
userManagementDesc: 'Manage user accounts and permissions',
@@ -569,6 +581,25 @@ export default {
hidBackend: 'HID Backend',
serialDevice: 'Serial Device',
baudRate: 'Baud Rate',
otgHidProfile: 'OTG HID Profile',
otgHidProfileDesc: 'Select which HID functions are exposed to the host',
profile: 'Profile',
otgProfileFull: 'Full (keyboard + relative mouse + absolute mouse + consumer)',
otgProfileLegacyKeyboard: 'Legacy: keyboard only',
otgProfileLegacyMouseRelative: 'Legacy: relative mouse only',
otgProfileCustom: 'Custom',
otgFunctionKeyboard: 'Keyboard',
otgFunctionKeyboardDesc: 'Standard HID keyboard device',
otgFunctionMouseRelative: 'Relative Mouse',
otgFunctionMouseRelativeDesc: 'Traditional mouse movement (HID boot mouse)',
otgFunctionMouseAbsolute: 'Absolute Mouse',
otgFunctionMouseAbsoluteDesc: 'Absolute positioning (touchscreen-like)',
otgFunctionConsumer: 'Consumer Control',
otgFunctionConsumerDesc: 'Media keys like volume/play/pause',
otgFunctionMsd: 'Mass Storage (MSD)',
otgFunctionMsdDesc: 'Expose USB storage to the host',
otgProfileWarning: 'Changing HID functions will reconnect the USB device',
otgFunctionMinWarning: 'Enable at least one HID function before saving',
// OTG Descriptor
otgDescriptor: 'USB Device Descriptor',
otgDescriptorDesc: 'Configure USB device identification',
@@ -678,6 +709,10 @@ export default {
viewLogs: 'View Logs',
noLogs: 'No logs available',
binaryNotFound: '{path} not found, please install the required program',
remoteAccess: {
title: 'Remote Access',
desc: 'GOSTC NAT traversal and Easytier networking',
},
// ttyd
ttyd: {
title: 'Ttyd Web Terminal',

View File

@@ -76,6 +76,8 @@ export default {
passwordTooShort: '密码至少需要4个字符',
passwordChanged: '密码修改成功',
userNotFound: '用户不存在',
sessionExpired: '会话已过期',
loggedInElsewhere: '已在别处登录',
},
status: {
connected: '已连接',
@@ -270,8 +272,6 @@ export default {
extensionsDescription: '选择要自动启动的扩展服务',
ttydTitle: 'Web 终端 (ttyd)',
ttydDescription: '在浏览器中访问设备的命令行终端',
rustdeskTitle: 'RustDesk 远程桌面',
rustdeskDescription: '通过 RustDesk 客户端远程访问设备',
extensionsHint: '这些设置可以在设置页面中随时更改',
notInstalled: '未安装',
// Password strength
@@ -427,6 +427,8 @@ export default {
basic: '基础',
general: '通用',
appearance: '外观',
account: '用户',
access: '访问',
video: '视频',
encoder: '编码器',
hid: 'HID',
@@ -436,6 +438,7 @@ export default {
users: '用户',
hardware: '硬件',
system: '系统',
other: '其他',
extensions: '扩展',
configured: '已配置',
security: '安全',
@@ -457,6 +460,8 @@ export default {
changePassword: '修改密码',
currentPassword: '当前密码',
newPassword: '新密码',
usernameDesc: '修改登录用户名',
passwordDesc: '修改登录密码',
version: '版本',
buildInfo: '构建信息',
detectDevices: '探测设备',
@@ -470,7 +475,7 @@ export default {
httpPort: 'HTTP 端口',
configureHttpPort: '配置 HTTP 服务器端口',
// Web server
webServer: '基础',
webServer: '访问地址',
webServerDesc: '配置 HTTP/HTTPS 端口和绑定地址,修改后需要重启生效',
httpsPort: 'HTTPS 端口',
bindAddress: '绑定地址',
@@ -480,6 +485,13 @@ export default {
restartRequired: '需要重启',
restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。',
restarting: '正在重启...',
// Auth
auth: '访问控制',
authSettings: '访问设置',
authSettingsDesc: '单用户访问与会话策略',
allowMultipleSessions: '允许多个 Web 会话',
allowMultipleSessionsDesc: '关闭后,新登录会踢掉旧会话。',
singleUserSessionNote: '系统固定为单用户模式,仅可配置会话并发方式。',
// User management
userManagement: '用户管理',
userManagementDesc: '管理用户账号和权限',
@@ -569,6 +581,25 @@ export default {
hidBackend: 'HID 后端',
serialDevice: '串口设备',
baudRate: '波特率',
otgHidProfile: 'OTG HID 组合',
otgHidProfileDesc: '选择对目标主机暴露的 HID 功能',
profile: '组合',
otgProfileFull: '完整(键盘 + 相对鼠标 + 绝对鼠标 + 多媒体)',
otgProfileLegacyKeyboard: '兼容:仅键盘',
otgProfileLegacyMouseRelative: '兼容:仅相对鼠标',
otgProfileCustom: '自定义',
otgFunctionKeyboard: '键盘',
otgFunctionKeyboardDesc: '标准 HID 键盘设备',
otgFunctionMouseRelative: '相对鼠标',
otgFunctionMouseRelativeDesc: '传统鼠标移动HID 启动鼠标)',
otgFunctionMouseAbsolute: '绝对鼠标',
otgFunctionMouseAbsoluteDesc: '绝对定位(类似触控)',
otgFunctionConsumer: '多媒体控制',
otgFunctionConsumerDesc: '音量/播放/暂停等按键',
otgFunctionMsd: 'U盘MSD',
otgFunctionMsdDesc: '向目标主机暴露 USB 存储',
otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接',
otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存',
// OTG Descriptor
otgDescriptor: 'USB 设备描述符',
otgDescriptorDesc: '配置 USB 设备标识信息',
@@ -678,6 +709,10 @@ export default {
viewLogs: '查看日志',
noLogs: '暂无日志',
binaryNotFound: '未找到 {path},请先安装对应程序',
remoteAccess: {
title: '远程访问',
desc: 'GOSTC 内网穿透与 Easytier 组网',
},
// ttyd
ttyd: {
title: 'Ttyd 网页终端',

View File

@@ -24,7 +24,7 @@ const routes: RouteRecordRaw[] = [
path: '/settings',
name: 'Settings',
component: () => import('@/views/SettingsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
meta: { requiresAuth: true },
},
]
@@ -63,11 +63,6 @@ router.beforeEach(async (to, _from, next) => {
}
}
// Check admin requirement
if (to.meta.requiresAdmin && !authStore.isAdmin) {
// Redirect non-admin users to console
return next({ name: 'Console' })
}
}
next()

View File

@@ -24,8 +24,6 @@ interface StreamState {
resolution: [number, number] | null
targetFps: number
clients: number
framesCaptured: number
framesDropped: number
streamMode: string // 'mjpeg' or 'webrtc'
error: string | null
}
@@ -277,8 +275,6 @@ export const useSystemStore = defineStore('system', () => {
resolution: data.video.resolution,
targetFps: data.video.fps,
clients: stream.value?.clients ?? 0,
framesCaptured: stream.value?.framesCaptured ?? 0,
framesDropped: stream.value?.framesDropped ?? 0,
streamMode: data.video.stream_mode || 'mjpeg',
error: data.video.error,
}

View File

@@ -6,6 +6,8 @@
export interface AuthConfig {
/** Session timeout in seconds */
session_timeout_secs: number;
/** Allow multiple concurrent web sessions (single-user mode) */
single_user_allow_multiple_sessions: boolean;
/** Enable 2FA */
totp_enabled: boolean;
/** TOTP secret (encrypted) */
@@ -52,6 +54,26 @@ export interface OtgDescriptorConfig {
serial_number?: string;
}
/** OTG HID function profile */
export enum OtgHidProfile {
/** Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) */
Full = "full",
/** Legacy profile: only keyboard */
LegacyKeyboard = "legacy_keyboard",
/** Legacy profile: only relative mouse */
LegacyMouseRelative = "legacy_mouse_relative",
/** Custom function selection */
Custom = "custom",
}
/** OTG HID function selection (used when profile is Custom) */
export interface OtgHidFunctions {
keyboard: boolean;
mouse_relative: boolean;
mouse_absolute: boolean;
consumer: boolean;
}
/** HID configuration */
export interface HidConfig {
/** HID backend type */
@@ -64,6 +86,10 @@ export interface HidConfig {
otg_udc?: string;
/** OTG USB device descriptor configuration */
otg_descriptor?: OtgDescriptorConfig;
/** OTG HID function profile */
otg_profile?: OtgHidProfile;
/** OTG HID function selection (used when profile is Custom) */
otg_functions?: OtgHidFunctions;
/** CH9329 serial port */
ch9329_port: string;
/** CH9329 baud rate */
@@ -392,6 +418,10 @@ export interface AudioConfigUpdate {
quality?: string;
}
export interface AuthConfigUpdate {
single_user_allow_multiple_sessions?: boolean;
}
/** Update easytier config */
export interface EasytierConfigUpdate {
enabled?: boolean;
@@ -496,12 +526,21 @@ export interface OtgDescriptorConfigUpdate {
serial_number?: string;
}
export interface OtgHidFunctionsUpdate {
keyboard?: boolean;
mouse_relative?: boolean;
mouse_absolute?: boolean;
consumer?: boolean;
}
export interface HidConfigUpdate {
backend?: HidBackend;
ch9329_port?: string;
ch9329_baudrate?: number;
otg_udc?: string;
otg_descriptor?: OtgDescriptorConfigUpdate;
otg_profile?: OtgHidProfile;
otg_functions?: OtgHidFunctionsUpdate;
mouse_absolute?: boolean;
}

View File

@@ -10,7 +10,7 @@ import { useHidWebSocket } from '@/composables/useHidWebSocket'
import { useWebRTC } from '@/composables/useWebRTC'
import { useVideoSession } from '@/composables/useVideoSession'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
import { streamApi, hidApi, atxApi, extensionsApi, atxConfigApi, userApi } from '@/api'
import { streamApi, hidApi, atxApi, extensionsApi, atxConfigApi, authApi } from '@/api'
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
import { toast } from 'vue-sonner'
import { generateUUID } from '@/lib/utils'
@@ -641,7 +641,7 @@ function handleStreamConfigChanging(data: any) {
})
}
function handleStreamConfigApplied(data: any) {
async function handleStreamConfigApplied(data: any) {
// Reset consecutive error counter for new config
consecutiveErrors = 0
@@ -662,6 +662,10 @@ function handleStreamConfigApplied(data: any) {
if (videoMode.value !== 'mjpeg') {
// In WebRTC mode, reconnect WebRTC (session was closed due to config change)
const ready = await videoSession.waitForWebRTCReadyAny(3000)
if (!ready) {
console.warn('[WebRTC] Backend not ready after timeout (config change), attempting connection anyway')
}
switchToWebRTC(videoMode.value)
} else {
// In MJPEG mode, refresh the MJPEG stream
@@ -1259,16 +1263,7 @@ async function handleChangePassword() {
changingPassword.value = true
try {
// Get current user ID - we need to fetch user list first
const result = await userApi.list()
const currentUser = result.users.find(u => u.username === authStore.user)
if (!currentUser) {
toast.error(t('auth.userNotFound'))
return
}
await userApi.changePassword(currentUser.id, newPassword.value, currentPassword.value)
await authApi.changePassword(currentPassword.value, newPassword.value)
toast.success(t('auth.passwordChanged'))
// Reset form and close dialog

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSystemStore } from '@/stores/system'
import { useAuthStore } from '@/stores/auth'
import {
authApi,
authConfigApi,
configApi,
streamApi,
userApi,
videoConfigApi,
streamConfigApi,
hidConfigApi,
@@ -16,7 +18,7 @@ import {
webConfigApi,
systemApi,
type EncoderBackendInfo,
type User as UserType,
type AuthConfig,
type RustDeskConfigResponse,
type RustDeskStatusResponse,
type RustDeskPasswordResponse,
@@ -28,6 +30,8 @@ import type {
AtxDriverType,
ActiveLevel,
AtxDevices,
OtgHidProfile,
OtgHidFunctions,
} from '@/types/generated'
import { setLanguage } from '@/i18n'
import { useClipboard } from '@/composables/useClipboard'
@@ -57,16 +61,11 @@ import {
EyeOff,
Save,
Check,
Network,
HardDrive,
Power,
UserPlus,
User,
Pencil,
Trash2,
Menu,
Users,
Globe,
Lock,
User,
RefreshCw,
Terminal,
Play,
@@ -80,6 +79,7 @@ import {
const { t, locale } = useI18n()
const systemStore = useSystemStore()
const authStore = useAuthStore()
// Settings state
const activeSection = ref('appearance')
@@ -90,9 +90,11 @@ const saved = ref(false)
// Navigation structure
const navGroups = computed(() => [
{
title: t('settings.general'),
title: t('settings.system'),
items: [
{ id: 'appearance', label: t('settings.appearance'), icon: Sun },
{ id: 'account', label: t('settings.account'), icon: User },
{ id: 'access', label: t('settings.access'), icon: Lock },
]
},
{
@@ -100,7 +102,7 @@ const navGroups = computed(() => [
items: [
{ id: 'video', label: t('settings.video'), icon: Monitor, status: config.value.video_device ? t('settings.configured') : null },
{ id: 'hid', label: t('settings.hid'), icon: Keyboard, status: config.value.hid_backend.toUpperCase() },
{ id: 'msd', label: t('settings.msd'), icon: HardDrive },
...(config.value.msd_enabled ? [{ id: 'msd', label: t('settings.msd'), icon: HardDrive }] : []),
{ id: 'atx', label: t('settings.atx'), icon: Power },
]
},
@@ -108,16 +110,13 @@ const navGroups = computed(() => [
title: t('settings.extensions'),
items: [
{ id: 'ext-rustdesk', label: t('extensions.rustdesk.title'), icon: ScreenShare },
{ id: 'ext-remote-access', label: t('extensions.remoteAccess.title'), icon: ExternalLink },
{ id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal },
{ id: 'ext-gostc', label: t('extensions.gostc.title'), icon: Globe },
{ id: 'ext-easytier', label: t('extensions.easytier.title'), icon: Network },
]
},
{
title: t('settings.system'),
title: t('settings.other'),
items: [
{ id: 'web-server', label: t('settings.webServer'), icon: Globe },
{ id: 'users', label: t('settings.users'), icon: Users },
{ id: 'about', label: t('settings.about'), icon: Info },
]
}
@@ -131,22 +130,28 @@ function selectSection(id: string) {
// Theme
const theme = ref<'light' | 'dark' | 'system'>('system')
// Password change
const showPasswordDialog = ref(false)
// Account settings
const usernameInput = ref('')
const usernamePassword = ref('')
const usernameSaving = ref(false)
const usernameSaved = ref(false)
const usernameError = ref('')
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const showPasswords = ref(false)
const passwordSaving = ref(false)
const passwordSaved = ref(false)
const passwordError = ref('')
const showPasswords = ref(false)
// User management
const users = ref<UserType[]>([])
const usersLoading = ref(false)
const showAddUserDialog = ref(false)
const showEditUserDialog = ref(false)
const editingUser = ref<UserType | null>(null)
const newUser = ref({ username: '', password: '', role: 'user' as 'admin' | 'user' })
const editUserData = ref({ username: '', role: 'user' as 'admin' | 'user' })
// Auth config state
const authConfig = ref<AuthConfig>({
session_timeout_secs: 3600 * 24,
single_user_allow_multiple_sessions: false,
totp_enabled: false,
totp_secret: undefined,
})
const authConfigLoading = ref(false)
// Extensions management
const extensions = ref<ExtensionsStatus | null>(null)
@@ -232,6 +237,13 @@ const config = ref({
hid_backend: 'ch9329',
hid_serial_device: '',
hid_serial_baudrate: 9600,
hid_otg_profile: 'full' as OtgHidProfile,
hid_otg_functions: {
keyboard: true,
mouse_relative: true,
mouse_absolute: true,
consumer: true,
} as OtgHidFunctions,
msd_enabled: false,
msd_dir: '',
network_port: 8080,
@@ -246,6 +258,13 @@ const config = ref({
// 跟踪服务器是否已配置 TURN 密码
const hasTurnPassword = ref(false)
const isHidFunctionSelectionValid = computed(() => {
if (config.value.hid_backend !== 'otg') return true
if (config.value.hid_otg_profile !== 'custom') return true
const f = config.value.hid_otg_functions
return !!(f.keyboard || f.mouse_relative || f.mouse_absolute || f.consumer)
})
// OTG Descriptor settings
const otgVendorIdHex = ref('1d6b')
const otgProductIdHex = ref('0104')
@@ -259,6 +278,12 @@ const validateHex = (event: Event, _field: string) => {
input.value = input.value.replace(/[^0-9a-fA-F]/g, '').toLowerCase()
}
watch(() => config.value.msd_enabled, (enabled) => {
if (!enabled && activeSection.value === 'msd') {
activeSection.value = 'hid'
}
})
// ATX config state
const atxConfig = ref({
enabled: false,
@@ -300,9 +325,6 @@ const selectedBackendFormats = computed(() => {
const isCh9329Backend = computed(() => config.value.hid_backend === 'ch9329')
// Video selection computed properties
import { watch } from 'vue'
const selectedDevice = computed(() => {
return devices.value.video.find(d => d.path === config.value.video_device)
})
@@ -384,6 +406,12 @@ watch(() => [config.value.video_width, config.value.video_height], () => {
}
})
watch(() => authStore.user, (value) => {
if (value) {
usernameInput.value = value
}
})
// Format bytes to human readable string
function formatBytes(bytes: number): string {
@@ -414,39 +442,71 @@ function handleLanguageChange(lang: string) {
}
}
// Password change
// Account updates
async function changeUsername() {
usernameError.value = ''
usernameSaved.value = false
if (usernameInput.value.length < 2) {
usernameError.value = t('auth.enterUsername')
return
}
if (!usernamePassword.value) {
usernameError.value = t('auth.enterPassword')
return
}
usernameSaving.value = true
try {
await authApi.changeUsername(usernameInput.value, usernamePassword.value)
usernameSaved.value = true
usernamePassword.value = ''
await authStore.checkAuth()
usernameInput.value = authStore.user || usernameInput.value
setTimeout(() => {
usernameSaved.value = false
}, 2000)
} catch (e) {
usernameError.value = t('auth.invalidPassword')
} finally {
usernameSaving.value = false
}
}
async function changePassword() {
passwordError.value = ''
passwordSaved.value = false
if (!currentPassword.value) {
passwordError.value = t('auth.enterPassword')
return
}
if (newPassword.value.length < 4) {
passwordError.value = t('setup.passwordHint')
return
}
if (newPassword.value !== confirmPassword.value) {
passwordError.value = t('setup.passwordMismatch')
return
}
passwordSaving.value = true
try {
await configApi.update({
current_password: currentPassword.value,
new_password: newPassword.value,
})
showPasswordDialog.value = false
await authApi.changePassword(currentPassword.value, newPassword.value)
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
passwordSaved.value = true
setTimeout(() => {
passwordSaved.value = false
}, 2000)
} catch (e) {
passwordError.value = t('auth.invalidPassword')
} finally {
passwordSaving.value = false
}
}
// MSD 开关变更处理
function onMsdEnabledChange(val: boolean) {
config.value.msd_enabled = val
}
// Save config - 使用域分离 API
async function saveConfig() {
loading.value = true
@@ -481,6 +541,20 @@ async function saveConfig() {
// HID 配置
if (activeSection.value === 'hid') {
if (!isHidFunctionSelectionValid.value) {
return
}
let desiredMsdEnabled = config.value.msd_enabled
if (config.value.hid_backend === 'otg') {
if (config.value.hid_otg_profile === 'full') {
desiredMsdEnabled = true
} else if (
config.value.hid_otg_profile === 'legacy_keyboard'
|| config.value.hid_otg_profile === 'legacy_mouse_relative'
) {
desiredMsdEnabled = false
}
}
const hidUpdate: any = {
backend: config.value.hid_backend as any,
ch9329_port: config.value.hid_serial_device || undefined,
@@ -495,15 +569,25 @@ async function saveConfig() {
product: otgProduct.value || 'One-KVM USB Device',
serial_number: otgSerialNumber.value || undefined,
}
hidUpdate.otg_profile = config.value.hid_otg_profile
hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
}
savePromises.push(hidConfigApi.update(hidUpdate))
if (config.value.msd_enabled !== desiredMsdEnabled) {
config.value.msd_enabled = desiredMsdEnabled
}
savePromises.push(
msdConfigApi.update({
enabled: desiredMsdEnabled,
})
)
}
// MSD 配置
if (activeSection.value === 'msd') {
savePromises.push(
msdConfigApi.update({
enabled: config.value.msd_enabled,
msd_dir: config.value.msd_dir || undefined,
})
)
}
@@ -538,6 +622,13 @@ async function loadConfig() {
hid_backend: hid.backend || 'none',
hid_serial_device: hid.ch9329_port || '',
hid_serial_baudrate: hid.ch9329_baudrate || 9600,
hid_otg_profile: (hid.otg_profile || 'full') as OtgHidProfile,
hid_otg_functions: {
keyboard: hid.otg_functions?.keyboard ?? true,
mouse_relative: hid.otg_functions?.mouse_relative ?? true,
mouse_absolute: hid.otg_functions?.mouse_absolute ?? true,
consumer: hid.otg_functions?.consumer ?? true,
} as OtgHidFunctions,
msd_enabled: msd.enabled || false,
msd_dir: msd.msd_dir || '',
network_port: 8080, // 从旧 API 加载
@@ -591,56 +682,29 @@ async function loadBackends() {
}
}
// User management functions
async function loadUsers() {
usersLoading.value = true
// Auth config functions
async function loadAuthConfig() {
authConfigLoading.value = true
try {
const result = await userApi.list()
users.value = result.users || []
authConfig.value = await authConfigApi.get()
} catch (e) {
console.error('Failed to load users:', e)
console.error('Failed to load auth config:', e)
} finally {
usersLoading.value = false
authConfigLoading.value = false
}
}
async function createUser() {
if (!newUser.value.username || !newUser.value.password) return
async function saveAuthConfig() {
authConfigLoading.value = true
try {
await userApi.create(newUser.value.username, newUser.value.password, newUser.value.role)
showAddUserDialog.value = false
newUser.value = { username: '', password: '', role: 'user' }
await loadUsers()
await authConfigApi.update({
single_user_allow_multiple_sessions: authConfig.value.single_user_allow_multiple_sessions,
})
await loadAuthConfig()
} catch (e) {
console.error('Failed to create user:', e)
}
}
function openEditUserDialog(user: UserType) {
editingUser.value = user
editUserData.value = { username: user.username, role: user.role }
showEditUserDialog.value = true
}
async function updateUser() {
if (!editingUser.value) return
try {
await userApi.update(editingUser.value.id, editUserData.value)
showEditUserDialog.value = false
editingUser.value = null
await loadUsers()
} catch (e) {
console.error('Failed to update user:', e)
}
}
async function confirmDeleteUser(user: UserType) {
if (!confirm(`Delete user "${user.username}"?`)) return
try {
await userApi.delete(user.id)
await loadUsers()
} catch (e) {
console.error('Failed to delete user:', e)
console.error('Failed to save auth config:', e)
} finally {
authConfigLoading.value = false
}
}
@@ -1052,7 +1116,7 @@ onMounted(async () => {
loadConfig(),
loadDevices(),
loadBackends(),
loadUsers(),
loadAuthConfig(),
loadExtensions(),
loadAtxConfig(),
loadAtxDevices(),
@@ -1060,6 +1124,7 @@ onMounted(async () => {
loadRustdeskPassword(),
loadWebServerConfig(),
])
usernameInput.value = authStore.user || ''
})
</script>
@@ -1171,6 +1236,63 @@ onMounted(async () => {
</Card>
</div>
<!-- Account Section -->
<div v-show="activeSection === 'account'" class="space-y-6">
<Card>
<CardHeader>
<CardTitle>{{ t('settings.username') }}</CardTitle>
<CardDescription>{{ t('settings.usernameDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="account-username">{{ t('settings.username') }}</Label>
<Input id="account-username" v-model="usernameInput" />
</div>
<div class="space-y-2">
<Label for="account-username-password">{{ t('settings.currentPassword') }}</Label>
<Input id="account-username-password" v-model="usernamePassword" type="password" />
</div>
<p v-if="usernameError" class="text-xs text-destructive">{{ usernameError }}</p>
<p v-else-if="usernameSaved" class="text-xs text-emerald-600">{{ t('common.success') }}</p>
<div class="flex justify-end">
<Button @click="changeUsername" :disabled="usernameSaving">
<Save class="h-4 w-4 mr-2" />
{{ t('common.save') }}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{{ t('settings.changePassword') }}</CardTitle>
<CardDescription>{{ t('settings.passwordDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="account-current-password">{{ t('settings.currentPassword') }}</Label>
<Input id="account-current-password" v-model="currentPassword" type="password" />
</div>
<div class="space-y-2">
<Label for="account-new-password">{{ t('settings.newPassword') }}</Label>
<Input id="account-new-password" v-model="newPassword" type="password" />
</div>
<div class="space-y-2">
<Label for="account-confirm-password">{{ t('auth.confirmPassword') }}</Label>
<Input id="account-confirm-password" v-model="confirmPassword" type="password" />
</div>
<p v-if="passwordError" class="text-xs text-destructive">{{ passwordError }}</p>
<p v-else-if="passwordSaved" class="text-xs text-emerald-600">{{ t('common.success') }}</p>
<div class="flex justify-end">
<Button @click="changePassword" :disabled="passwordSaving">
<Save class="h-4 w-4 mr-2" />
{{ t('common.save') }}
</Button>
</div>
</CardContent>
</Card>
</div>
<!-- Video Section -->
<div v-show="activeSection === 'video'" class="space-y-6">
<!-- Video Device Settings -->
@@ -1345,6 +1467,66 @@ onMounted(async () => {
<!-- OTG Descriptor Settings -->
<template v-if="config.hid_backend === 'otg'">
<Separator class="my-4" />
<div class="space-y-4">
<div>
<h4 class="text-sm font-medium">{{ t('settings.otgHidProfile') }}</h4>
<p class="text-sm text-muted-foreground">{{ t('settings.otgHidProfileDesc') }}</p>
</div>
<div class="space-y-2">
<Label for="otg-profile">{{ t('settings.profile') }}</Label>
<select id="otg-profile" v-model="config.hid_otg_profile" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="full">{{ t('settings.otgProfileFull') }}</option>
<option value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</option>
<option value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</option>
<option value="custom">{{ t('settings.otgProfileCustom') }}</option>
</select>
</div>
<div v-if="config.hid_otg_profile === 'custom'" class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionKeyboard') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionKeyboardDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.keyboard" />
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionMouseRelative') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseRelativeDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.mouse_relative" />
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionMouseAbsolute') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseAbsoluteDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.mouse_absolute" />
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionConsumer') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionConsumerDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.consumer" />
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMsdDesc') }}</p>
</div>
<Switch v-model="config.msd_enabled" />
</div>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgProfileWarning') }}
</p>
</div>
<Separator class="my-4" />
<div class="space-y-4">
<div>
@@ -1409,8 +1591,8 @@ onMounted(async () => {
</Card>
</div>
<!-- Web Server Section -->
<div v-show="activeSection === 'web-server'" class="space-y-6">
<!-- Access Section -->
<div v-show="activeSection === 'access'" class="space-y-6">
<Card>
<CardHeader>
<CardTitle>{{ t('settings.webServer') }}</CardTitle>
@@ -1452,51 +1634,36 @@ onMounted(async () => {
</div>
</CardContent>
</Card>
</div>
<!-- Users Section -->
<div v-show="activeSection === 'users'" class="space-y-6">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-4">
<div class="space-y-1.5">
<CardTitle>{{ t('settings.userManagement') }}</CardTitle>
<CardDescription>{{ t('settings.userManagementDesc') }}</CardDescription>
</div>
<Button size="sm" @click="showAddUserDialog = true">
<UserPlus class="h-4 w-4 mr-2" />{{ t('settings.addUser') }}
</Button>
<CardHeader>
<CardTitle>{{ t('settings.authSettings') }}</CardTitle>
<CardDescription>{{ t('settings.authSettingsDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<div v-if="usersLoading" class="text-center py-8">
<p class="text-sm text-muted-foreground">{{ t('settings.loadingUsers') }}</p>
</div>
<div v-else-if="users.length === 0" class="text-center py-8">
<User class="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p class="text-sm text-muted-foreground">{{ t('settings.noUsers') }}</p>
</div>
<div v-else class="divide-y">
<div v-for="user in users" :key="user.id" class="flex items-center justify-between py-3">
<div class="flex items-center gap-3">
<div class="h-8 w-8 rounded-full bg-muted flex items-center justify-center">
<User class="h-4 w-4" />
</div>
<div>
<p class="text-sm font-medium">{{ user.username }}</p>
<Badge variant="outline" class="text-xs">{{ user.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleUser') }}</Badge>
</div>
</div>
<div class="flex gap-1">
<Button size="icon" variant="ghost" class="h-8 w-8" @click="openEditUserDialog(user)"><Pencil class="h-4 w-4" /></Button>
<Button size="icon" variant="ghost" class="h-8 w-8 text-destructive" :disabled="user.role === 'admin' && users.filter(u => u.role === 'admin').length === 1" @click="confirmDeleteUser(user)"><Trash2 class="h-4 w-4" /></Button>
</div>
<CardContent class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label>{{ t('settings.allowMultipleSessions') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.allowMultipleSessionsDesc') }}</p>
</div>
<Switch
v-model="authConfig.single_user_allow_multiple_sessions"
:disabled="authConfigLoading"
/>
</div>
<Separator />
<p class="text-xs text-muted-foreground">{{ t('settings.singleUserSessionNote') }}</p>
<div class="flex justify-end pt-2">
<Button @click="saveAuthConfig" :disabled="authConfigLoading">
<Save class="h-4 w-4 mr-2" />
{{ t('common.save') }}
</Button>
</div>
</CardContent>
</Card>
</div>
<!-- MSD Section -->
<div v-show="activeSection === 'msd'" class="space-y-6">
<div v-show="activeSection === 'msd' && config.msd_enabled" class="space-y-6">
<Card>
<CardHeader>
<CardTitle>{{ t('settings.msdSettings') }}</CardTitle>
@@ -1507,19 +1674,6 @@ onMounted(async () => {
<p class="font-medium">{{ t('settings.msdCh9329Warning') }}</p>
<p class="text-xs text-amber-900/80">{{ t('settings.msdCh9329WarningDesc') }}</p>
</div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label for="msd-enabled">{{ t('settings.msdEnable') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.msdEnableDesc') }}</p>
</div>
<Switch
id="msd-enabled"
:disabled="isCh9329Backend"
:model-value="config.msd_enabled"
@update:model-value="onMsdEnabledChange"
/>
</div>
<Separator />
<div class="space-y-4">
<div class="space-y-2">
<Label for="msd-dir">{{ t('settings.msdDir') }}</Label>
@@ -1821,8 +1975,8 @@ onMounted(async () => {
</div>
</div>
<!-- gostc Section -->
<div v-show="activeSection === 'ext-gostc'" class="space-y-6">
<!-- Remote Access Section -->
<div v-show="activeSection === 'ext-remote-access'" class="space-y-6">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
@@ -1913,10 +2067,7 @@ onMounted(async () => {
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
</Button>
</div>
</div>
<!-- easytier Section -->
<div v-show="activeSection === 'ext-easytier'" class="space-y-6">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
@@ -2249,9 +2400,14 @@ onMounted(async () => {
<!-- Save Button (sticky) -->
<div v-if="['video', 'hid', 'msd'].includes(activeSection)" class="sticky bottom-0 pt-4 pb-2 bg-background border-t -mx-6 px-6 lg:-mx-8 lg:px-8">
<div class="flex justify-end">
<Button :disabled="loading" @click="saveConfig">
<div class="flex items-center gap-3">
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgFunctionMinWarning') }}
</p>
<Button :disabled="loading || (activeSection === 'hid' && !isHidFunctionSelectionValid)" @click="saveConfig">
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
</Button>
</Button>
</div>
</div>
</div>
@@ -2259,95 +2415,6 @@ onMounted(async () => {
</main>
</div>
<!-- Password Change Dialog -->
<Dialog v-model:open="showPasswordDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ t('settings.changePassword') }}</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<Label for="current-password">{{ t('settings.currentPassword') }}</Label>
<div class="relative">
<Input id="current-password" v-model="currentPassword" :type="showPasswords ? 'text' : 'password'" />
<button type="button" class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground" @click="showPasswords = !showPasswords">
<Eye v-if="!showPasswords" class="h-4 w-4" /><EyeOff v-else class="h-4 w-4" />
</button>
</div>
</div>
<div class="space-y-2">
<Label for="new-password">{{ t('settings.newPassword') }}</Label>
<Input id="new-password" v-model="newPassword" :type="showPasswords ? 'text' : 'password'" />
</div>
<div class="space-y-2">
<Label for="confirm-password">{{ t('setup.confirmPassword') }}</Label>
<Input id="confirm-password" v-model="confirmPassword" :type="showPasswords ? 'text' : 'password'" />
</div>
<p v-if="passwordError" class="text-sm text-destructive">{{ passwordError }}</p>
</div>
<DialogFooter>
<Button variant="outline" size="sm" @click="showPasswordDialog = false">{{ t('common.cancel') }}</Button>
<Button size="sm" @click="changePassword">{{ t('common.save') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Add User Dialog -->
<Dialog v-model:open="showAddUserDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ t('settings.addUser') }}</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<Label for="new-username">{{ t('settings.username') }}</Label>
<Input id="new-username" v-model="newUser.username" />
</div>
<div class="space-y-2">
<Label for="new-user-password">{{ t('settings.password') }}</Label>
<Input id="new-user-password" v-model="newUser.password" type="password" />
</div>
<div class="space-y-2">
<Label for="new-user-role">{{ t('settings.role') }}</Label>
<select id="new-user-role" v-model="newUser.role" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="user">{{ t('settings.roleUser') }}</option>
<option value="admin">{{ t('settings.roleAdmin') }}</option>
</select>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" @click="showAddUserDialog = false">{{ t('common.cancel') }}</Button>
<Button size="sm" @click="createUser">{{ t('settings.create') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Edit User Dialog -->
<Dialog v-model:open="showEditUserDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ t('settings.editUser') }}</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<Label for="edit-username">{{ t('settings.username') }}</Label>
<Input id="edit-username" v-model="editUserData.username" />
</div>
<div class="space-y-2">
<Label for="edit-user-role">{{ t('settings.role') }}</Label>
<select id="edit-user-role" v-model="editUserData.role" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="user">{{ t('settings.roleUser') }}</option>
<option value="admin">{{ t('settings.roleAdmin') }}</option>
</select>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" @click="showEditUserDialog = false">{{ t('common.cancel') }}</Button>
<Button size="sm" @click="updateUser">{{ t('common.save') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Terminal Dialog -->
<Dialog v-model:open="showTerminalDialog">
<DialogContent class="max-w-[95vw] w-[1200px] h-[600px] p-0 flex flex-col overflow-hidden">

View File

@@ -99,9 +99,7 @@ const otgUdc = ref('')
// Extension settings
const ttydEnabled = ref(false)
const rustdeskEnabled = ref(false)
const ttydAvailable = ref(false)
const rustdeskAvailable = ref(true) // RustDesk is built-in, always available
// Encoder backend settings
const encoderBackend = ref('auto')
@@ -139,7 +137,6 @@ interface DeviceInfo {
udc: Array<{ name: string }>
extensions: {
ttyd_available: boolean
rustdesk_available: boolean
}
}
@@ -150,7 +147,6 @@ const devices = ref<DeviceInfo>({
udc: [],
extensions: {
ttyd_available: false,
rustdesk_available: true,
},
})
@@ -351,7 +347,6 @@ onMounted(async () => {
// Set extension availability from devices API
if (result.extensions) {
ttydAvailable.value = result.extensions.ttyd_available
rustdeskAvailable.value = result.extensions.rustdesk_available
}
} catch {
// Use defaults
@@ -506,7 +501,6 @@ async function handleSetup() {
// Extension settings
setupData.ttyd_enabled = ttydEnabled.value
setupData.rustdesk_enabled = rustdeskEnabled.value
const success = await authStore.setup(setupData)
@@ -956,19 +950,6 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
<Switch v-model="ttydEnabled" :disabled="!ttydAvailable" />
</div>
<!-- RustDesk -->
<div class="flex items-center justify-between p-4 rounded-lg border">
<div class="space-y-1">
<div class="flex items-center gap-2">
<Label class="text-base font-medium">{{ t('setup.rustdeskTitle') }}</Label>
</div>
<p class="text-sm text-muted-foreground">
{{ t('setup.rustdeskDescription') }}
</p>
</div>
<Switch v-model="rustdeskEnabled" />
</div>
<p class="text-xs text-muted-foreground text-center pt-2">
{{ t('setup.extensionsHint') }}
</p>