mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-03-15 07:26:44 +08:00
feat: 新增 RTSP 设置菜单与配置面板
This commit is contained in:
@@ -800,6 +800,25 @@ export default {
|
||||
keypairGenerated: 'Keypair Generated',
|
||||
noKeypair: 'No Keypair',
|
||||
},
|
||||
rtsp: {
|
||||
title: 'RTSP Streaming',
|
||||
desc: 'Configure RTSP video output service (H.264/H.265)',
|
||||
bind: 'Bind Address',
|
||||
port: 'Port',
|
||||
path: 'Stream Path',
|
||||
pathPlaceholder: 'live',
|
||||
pathHint: 'Example: rtsp://device-ip:8554/live',
|
||||
codec: 'Codec',
|
||||
codecHint: 'Enabling RTSP locks codec to selected value and disables MJPEG.',
|
||||
allowOneClient: 'Allow One Client Only',
|
||||
username: 'Username',
|
||||
usernamePlaceholder: 'Empty means no authentication',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: 'Enter new password',
|
||||
passwordSet: '••••••••',
|
||||
passwordHint: 'Leave empty to keep current password; enter a new value to update it.',
|
||||
urlPreview: 'RTSP URL Preview',
|
||||
},
|
||||
},
|
||||
stats: {
|
||||
title: 'Connection Stats',
|
||||
|
||||
@@ -800,6 +800,25 @@ export default {
|
||||
keypairGenerated: '密钥对已生成',
|
||||
noKeypair: '密钥对未生成',
|
||||
},
|
||||
rtsp: {
|
||||
title: 'RTSP 视频流',
|
||||
desc: '配置 RTSP 推流服务(H.264/H.265)',
|
||||
bind: '监听地址',
|
||||
port: '端口',
|
||||
path: '流路径',
|
||||
pathPlaceholder: 'live',
|
||||
pathHint: '访问路径,例如 rtsp://设备IP:8554/live',
|
||||
codec: '编码格式',
|
||||
codecHint: '启用 RTSP 后将锁定编码为所选项,并禁用 MJPEG。',
|
||||
allowOneClient: '仅允许单客户端',
|
||||
username: '用户名',
|
||||
usernamePlaceholder: '留空表示无需认证',
|
||||
password: '密码',
|
||||
passwordPlaceholder: '输入新密码',
|
||||
passwordSet: '••••••••',
|
||||
passwordHint: '留空表示保持当前密码;如需修改请输入新密码。',
|
||||
urlPreview: 'RTSP 地址预览',
|
||||
},
|
||||
},
|
||||
stats: {
|
||||
title: '连接统计',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
audioConfigApi,
|
||||
hidConfigApi,
|
||||
msdConfigApi,
|
||||
rtspConfigApi,
|
||||
rustdeskConfigApi,
|
||||
streamConfigApi,
|
||||
videoConfigApi,
|
||||
@@ -30,6 +31,9 @@ import type {
|
||||
WebConfigUpdate,
|
||||
} from '@/types/generated'
|
||||
import type {
|
||||
RtspConfigResponse as ApiRtspConfigResponse,
|
||||
RtspConfigUpdate as ApiRtspConfigUpdate,
|
||||
RtspStatusResponse as ApiRtspStatusResponse,
|
||||
RustDeskConfigResponse as ApiRustDeskConfigResponse,
|
||||
RustDeskConfigUpdate as ApiRustDeskConfigUpdate,
|
||||
RustDeskStatusResponse as ApiRustDeskStatusResponse,
|
||||
@@ -51,6 +55,8 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const stream = ref<StreamConfigResponse | null>(null)
|
||||
const web = ref<WebConfig | null>(null)
|
||||
const atx = ref<AtxConfig | null>(null)
|
||||
const rtspConfig = ref<ApiRtspConfigResponse | null>(null)
|
||||
const rtspStatus = ref<ApiRtspStatusResponse | null>(null)
|
||||
const rustdeskConfig = ref<ApiRustDeskConfigResponse | null>(null)
|
||||
const rustdeskStatus = ref<ApiRustDeskStatusResponse | null>(null)
|
||||
const rustdeskPassword = ref<ApiRustDeskPasswordResponse | null>(null)
|
||||
@@ -63,6 +69,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const streamLoading = ref(false)
|
||||
const webLoading = ref(false)
|
||||
const atxLoading = ref(false)
|
||||
const rtspLoading = ref(false)
|
||||
const rustdeskLoading = ref(false)
|
||||
|
||||
const authError = ref<string | null>(null)
|
||||
@@ -73,6 +80,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const streamError = ref<string | null>(null)
|
||||
const webError = ref<string | null>(null)
|
||||
const atxError = ref<string | null>(null)
|
||||
const rtspError = ref<string | null>(null)
|
||||
const rustdeskError = ref<string | null>(null)
|
||||
|
||||
let authPromise: Promise<AuthConfig> | null = null
|
||||
@@ -83,6 +91,8 @@ export const useConfigStore = defineStore('config', () => {
|
||||
let streamPromise: Promise<StreamConfigResponse> | null = null
|
||||
let webPromise: Promise<WebConfig> | null = null
|
||||
let atxPromise: Promise<AtxConfig> | null = null
|
||||
let rtspPromise: Promise<ApiRtspConfigResponse> | null = null
|
||||
let rtspStatusPromise: Promise<ApiRtspStatusResponse> | null = null
|
||||
let rustdeskPromise: Promise<ApiRustDeskConfigResponse> | null = null
|
||||
let rustdeskStatusPromise: Promise<ApiRustDeskStatusResponse> | null = null
|
||||
let rustdeskPasswordPromise: Promise<ApiRustDeskPasswordResponse> | null = null
|
||||
@@ -263,6 +273,51 @@ export const useConfigStore = defineStore('config', () => {
|
||||
return request
|
||||
}
|
||||
|
||||
async function refreshRtspConfig() {
|
||||
if (rtspLoading.value && rtspPromise) return rtspPromise
|
||||
rtspLoading.value = true
|
||||
rtspError.value = null
|
||||
const request = rtspConfigApi.get()
|
||||
.then((response) => {
|
||||
rtspConfig.value = response
|
||||
return response
|
||||
})
|
||||
.catch((error) => {
|
||||
rtspError.value = normalizeErrorMessage(error)
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
rtspLoading.value = false
|
||||
rtspPromise = null
|
||||
})
|
||||
|
||||
rtspPromise = request
|
||||
return request
|
||||
}
|
||||
|
||||
async function refreshRtspStatus() {
|
||||
if (rtspLoading.value && rtspStatusPromise) return rtspStatusPromise
|
||||
rtspLoading.value = true
|
||||
rtspError.value = null
|
||||
const request = rtspConfigApi.getStatus()
|
||||
.then((response) => {
|
||||
rtspStatus.value = response
|
||||
rtspConfig.value = response.config
|
||||
return response
|
||||
})
|
||||
.catch((error) => {
|
||||
rtspError.value = normalizeErrorMessage(error)
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
rtspLoading.value = false
|
||||
rtspStatusPromise = null
|
||||
})
|
||||
|
||||
rtspStatusPromise = request
|
||||
return request
|
||||
}
|
||||
|
||||
async function refreshRustdeskConfig() {
|
||||
if (rustdeskLoading.value && rustdeskPromise) return rustdeskPromise
|
||||
rustdeskLoading.value = true
|
||||
@@ -370,6 +425,11 @@ export const useConfigStore = defineStore('config', () => {
|
||||
return refreshAtx()
|
||||
}
|
||||
|
||||
function ensureRtspConfig() {
|
||||
if (rtspConfig.value) return Promise.resolve(rtspConfig.value)
|
||||
return refreshRtspConfig()
|
||||
}
|
||||
|
||||
function ensureRustdeskConfig() {
|
||||
if (rustdeskConfig.value) return Promise.resolve(rustdeskConfig.value)
|
||||
return refreshRustdeskConfig()
|
||||
@@ -423,6 +483,12 @@ export const useConfigStore = defineStore('config', () => {
|
||||
return response
|
||||
}
|
||||
|
||||
async function updateRtsp(update: ApiRtspConfigUpdate) {
|
||||
const response = await rtspConfigApi.update(update)
|
||||
rtspConfig.value = response
|
||||
return response
|
||||
}
|
||||
|
||||
async function updateRustdesk(update: ApiRustDeskConfigUpdate) {
|
||||
const response = await rustdeskConfigApi.update(update)
|
||||
rustdeskConfig.value = response
|
||||
@@ -450,6 +516,8 @@ export const useConfigStore = defineStore('config', () => {
|
||||
stream,
|
||||
web,
|
||||
atx,
|
||||
rtspConfig,
|
||||
rtspStatus,
|
||||
rustdeskConfig,
|
||||
rustdeskStatus,
|
||||
rustdeskPassword,
|
||||
@@ -461,6 +529,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||
streamLoading,
|
||||
webLoading,
|
||||
atxLoading,
|
||||
rtspLoading,
|
||||
rustdeskLoading,
|
||||
authError,
|
||||
videoError,
|
||||
@@ -470,6 +539,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||
streamError,
|
||||
webError,
|
||||
atxError,
|
||||
rtspError,
|
||||
rustdeskError,
|
||||
refreshAuth,
|
||||
refreshVideo,
|
||||
@@ -479,6 +549,8 @@ export const useConfigStore = defineStore('config', () => {
|
||||
refreshStream,
|
||||
refreshWeb,
|
||||
refreshAtx,
|
||||
refreshRtspConfig,
|
||||
refreshRtspStatus,
|
||||
refreshRustdeskConfig,
|
||||
refreshRustdeskStatus,
|
||||
refreshRustdeskPassword,
|
||||
@@ -490,6 +562,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||
ensureStream,
|
||||
ensureWeb,
|
||||
ensureAtx,
|
||||
ensureRtspConfig,
|
||||
ensureRustdeskConfig,
|
||||
updateAuth,
|
||||
updateVideo,
|
||||
@@ -499,6 +572,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||
updateStream,
|
||||
updateWeb,
|
||||
updateAtx,
|
||||
updateRtsp,
|
||||
updateRustdesk,
|
||||
regenerateRustdeskId,
|
||||
regenerateRustdeskPassword,
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
type RustDeskConfigResponse,
|
||||
type RustDeskStatusResponse,
|
||||
type RustDeskPasswordResponse,
|
||||
type RtspStatusResponse,
|
||||
type RtspConfigUpdate,
|
||||
type WebConfig,
|
||||
} from '@/api'
|
||||
import type {
|
||||
@@ -70,6 +72,7 @@ import {
|
||||
ExternalLink,
|
||||
Copy,
|
||||
ScreenShare,
|
||||
Radio,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
@@ -106,6 +109,7 @@ const navGroups = computed(() => [
|
||||
title: t('settings.extensions'),
|
||||
items: [
|
||||
{ id: 'ext-rustdesk', label: t('extensions.rustdesk.title'), icon: ScreenShare },
|
||||
{ id: 'ext-rtsp', label: t('extensions.rtsp.title'), icon: Radio },
|
||||
{ id: 'ext-remote-access', label: t('extensions.remoteAccess.title'), icon: ExternalLink },
|
||||
{ id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal },
|
||||
]
|
||||
@@ -187,6 +191,26 @@ const rustdeskLocalConfig = ref({
|
||||
relay_key: '',
|
||||
})
|
||||
|
||||
// RTSP config state
|
||||
const rtspStatus = ref<RtspStatusResponse | null>(null)
|
||||
const rtspLoading = ref(false)
|
||||
const rtspLocalConfig = ref<RtspConfigUpdate & { password?: string }>({
|
||||
enabled: false,
|
||||
bind: '0.0.0.0',
|
||||
port: 8554,
|
||||
path: 'live',
|
||||
allow_one_client: true,
|
||||
codec: 'h264',
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
const rtspStreamUrl = computed(() => {
|
||||
const host = window.location.hostname || '127.0.0.1'
|
||||
const path = (rtspLocalConfig.value.path || 'live').trim().replace(/^\/+|\/+$/g, '') || 'live'
|
||||
const port = Number(rtspLocalConfig.value.port) || 8554
|
||||
return `rtsp://${host}:${port}/${path}`
|
||||
})
|
||||
|
||||
// Web server config state
|
||||
const webServerConfig = ref<WebConfig>({
|
||||
http_port: 8080,
|
||||
@@ -996,6 +1020,10 @@ function normalizeRustdeskServer(value: string, defaultPort: number): string | u
|
||||
return `${trimmed}:${defaultPort}`
|
||||
}
|
||||
|
||||
function normalizeRtspPath(path: string): string {
|
||||
return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
|
||||
}
|
||||
|
||||
function normalizeBindAddresses(addresses: string[]): string[] {
|
||||
return addresses.map(addr => addr.trim()).filter(Boolean)
|
||||
}
|
||||
@@ -1225,6 +1253,108 @@ function getRustdeskStatusClass(status: string | null | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRtspConfig() {
|
||||
rtspLoading.value = true
|
||||
try {
|
||||
const status = await configStore.refreshRtspStatus()
|
||||
rtspStatus.value = status
|
||||
rtspLocalConfig.value = {
|
||||
enabled: status.config.enabled,
|
||||
bind: status.config.bind,
|
||||
port: status.config.port,
|
||||
path: status.config.path,
|
||||
allow_one_client: status.config.allow_one_client,
|
||||
codec: status.config.codec,
|
||||
username: status.config.username || '',
|
||||
password: '',
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load RTSP config:', e)
|
||||
} finally {
|
||||
rtspLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRtspConfig() {
|
||||
loading.value = true
|
||||
saved.value = false
|
||||
try {
|
||||
const update: RtspConfigUpdate = {
|
||||
enabled: !!rtspLocalConfig.value.enabled,
|
||||
bind: rtspLocalConfig.value.bind?.trim() || '0.0.0.0',
|
||||
port: Number(rtspLocalConfig.value.port) || 8554,
|
||||
path: normalizeRtspPath(rtspLocalConfig.value.path || 'live'),
|
||||
allow_one_client: !!rtspLocalConfig.value.allow_one_client,
|
||||
codec: rtspLocalConfig.value.codec || 'h264',
|
||||
username: (rtspLocalConfig.value.username || '').trim(),
|
||||
}
|
||||
|
||||
const nextPassword = (rtspLocalConfig.value.password || '').trim()
|
||||
if (nextPassword) {
|
||||
update.password = nextPassword
|
||||
}
|
||||
|
||||
await configStore.updateRtsp(update)
|
||||
await loadRtspConfig()
|
||||
rtspLocalConfig.value.password = ''
|
||||
saved.value = true
|
||||
setTimeout(() => (saved.value = false), 2000)
|
||||
} catch (e) {
|
||||
console.error('Failed to save RTSP config:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startRtsp() {
|
||||
rtspLoading.value = true
|
||||
try {
|
||||
await configStore.updateRtsp({ enabled: true })
|
||||
rtspLocalConfig.value.enabled = true
|
||||
await loadRtspConfig()
|
||||
} catch (e) {
|
||||
console.error('Failed to start RTSP:', e)
|
||||
} finally {
|
||||
rtspLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRtsp() {
|
||||
rtspLoading.value = true
|
||||
try {
|
||||
await configStore.updateRtsp({ enabled: false })
|
||||
rtspLocalConfig.value.enabled = false
|
||||
await loadRtspConfig()
|
||||
} catch (e) {
|
||||
console.error('Failed to stop RTSP:', e)
|
||||
} finally {
|
||||
rtspLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getRtspServiceStatusText(status: string | undefined): string {
|
||||
if (!status) return t('extensions.stopped')
|
||||
switch (status) {
|
||||
case 'running': return t('extensions.running')
|
||||
case 'starting': return t('extensions.starting')
|
||||
case 'stopped': return t('extensions.stopped')
|
||||
default:
|
||||
if (status.startsWith('error:')) return t('extensions.failed')
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
function getRtspStatusClass(status: string | undefined): string {
|
||||
switch (status) {
|
||||
case 'running': return 'bg-green-500'
|
||||
case 'starting': return 'bg-yellow-500'
|
||||
case 'stopped': return 'bg-gray-400'
|
||||
default:
|
||||
if (status?.startsWith('error:')) return 'bg-red-500'
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// Load theme preference
|
||||
@@ -1244,6 +1374,7 @@ onMounted(async () => {
|
||||
loadAtxDevices(),
|
||||
loadRustdeskConfig(),
|
||||
loadRustdeskPassword(),
|
||||
loadRtspConfig(),
|
||||
loadWebServerConfig(),
|
||||
])
|
||||
usernameInput.value = authStore.user || ''
|
||||
@@ -2341,6 +2472,121 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RTSP Section -->
|
||||
<div v-show="activeSection === 'ext-rtsp'" class="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1.5">
|
||||
<CardTitle>{{ t('extensions.rtsp.title') }}</CardTitle>
|
||||
<CardDescription>{{ t('extensions.rtsp.desc') }}</CardDescription>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge :variant="rtspStatus?.service_status === 'running' ? 'default' : 'secondary'">
|
||||
{{ getRtspServiceStatusText(rtspStatus?.service_status) }}
|
||||
</Badge>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.refresh')" @click="loadRtspConfig" :disabled="rtspLoading">
|
||||
<RefreshCw :class="['h-4 w-4', rtspLoading ? 'animate-spin' : '']" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div :class="['w-2 h-2 rounded-full', getRtspStatusClass(rtspStatus?.service_status)]" />
|
||||
<span class="text-sm">{{ getRtspServiceStatusText(rtspStatus?.service_status) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="rtspStatus?.service_status !== 'running'"
|
||||
size="sm"
|
||||
@click="startRtsp"
|
||||
:disabled="rtspLoading"
|
||||
>
|
||||
<Play class="h-4 w-4 mr-1" />
|
||||
{{ t('extensions.start') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="stopRtsp"
|
||||
:disabled="rtspLoading"
|
||||
>
|
||||
<Square class="h-4 w-4 mr-1" />
|
||||
{{ t('extensions.stop') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>{{ t('extensions.autoStart') }}</Label>
|
||||
<Switch v-model="rtspLocalConfig.enabled" />
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rtsp.bind') }}</Label>
|
||||
<Input v-model="rtspLocalConfig.bind" class="sm:col-span-3" placeholder="0.0.0.0" />
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rtsp.port') }}</Label>
|
||||
<Input v-model.number="rtspLocalConfig.port" class="sm:col-span-3" type="number" min="1" max="65535" />
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rtsp.path') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input v-model="rtspLocalConfig.path" :placeholder="t('extensions.rtsp.pathPlaceholder')" />
|
||||
<p class="text-xs text-muted-foreground">{{ t('extensions.rtsp.pathHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rtsp.codec') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<select v-model="rtspLocalConfig.codec" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
||||
<option value="h264">H.264</option>
|
||||
<option value="h265">H.265</option>
|
||||
</select>
|
||||
<p class="text-xs text-muted-foreground">{{ t('extensions.rtsp.codecHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>{{ t('extensions.rtsp.allowOneClient') }}</Label>
|
||||
<Switch v-model="rtspLocalConfig.allow_one_client" />
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rtsp.username') }}</Label>
|
||||
<Input v-model="rtspLocalConfig.username" class="sm:col-span-3" :placeholder="t('extensions.rtsp.usernamePlaceholder')" />
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rtsp.password') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input
|
||||
v-model="rtspLocalConfig.password"
|
||||
type="password"
|
||||
:placeholder="rtspStatus?.config?.has_password ? t('extensions.rtsp.passwordSet') : t('extensions.rtsp.passwordPlaceholder')"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">{{ t('extensions.rtsp.passwordHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="rounded-md border p-3 bg-muted/20 space-y-1">
|
||||
<p class="text-sm font-medium">{{ t('extensions.rtsp.urlPreview') }}</p>
|
||||
<code class="font-mono text-sm break-all">{{ rtspStreamUrl }}</code>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="flex justify-end">
|
||||
<Button :disabled="loading || rtspLoading" @click="saveRtspConfig">
|
||||
<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>
|
||||
|
||||
<!-- RustDesk Section -->
|
||||
<div v-show="activeSection === 'ext-rustdesk'" class="space-y-6">
|
||||
<Card>
|
||||
|
||||
Reference in New Issue
Block a user