mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-01-28 16:41:52 +08:00
feat: 完善架构优化性能
- 调整音视频架构,提升 RKMPP 编码 MJPEG-->H264 性能,同时解决丢帧马赛克问题; - 删除多用户逻辑,只保留单用户,支持设置 web 单会话; - 修复删除体验不好的的回退逻辑,前端页面菜单位置微调; - 增加 OTG USB 设备动态调整功能; - 修复 mdns 问题,webrtc 视频切换更顺畅。
This commit is contained in:
@@ -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
14
web/package-lock.json
generated
@@ -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
BIN
web/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -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 = {
|
||||
/**
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 网页终端',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user