feat: 新增 RTSP 设置菜单与配置面板

This commit is contained in:
mofeng-git
2026-02-11 17:01:25 +08:00
parent f912c977d0
commit fb975875f1
4 changed files with 358 additions and 0 deletions

View File

@@ -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',

View File

@@ -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: '连接统计',

View File

@@ -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,

View File

@@ -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>