refactor(web): 精简前端结构与未使用代码

抽离远程访问设置逻辑,合并配置缓存、并发请求及错误处理,修复共享 loading 状态提前结束。统一控制台事件订阅与清理,移除未使用组件、旧 composable 和冗余状态。

已通过前端生产构建、模拟 API 浏览器检查和 OpenWrt ARMv7 部署验证。
This commit is contained in:
mofeng-git
2026-10-28 07:52:31 +08:00
parent 3c15168ddc
commit d6eaeaccbe
42 changed files with 621 additions and 1803 deletions

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { AvatarRoot } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<AvatarRoot
data-slot="avatar"
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
>
<slot />
</AvatarRoot>
</template>

View File

@@ -1,21 +0,0 @@
<script setup lang="ts">
import type { AvatarFallbackProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AvatarFallback } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
>
<slot />
</AvatarFallback>
</template>

View File

@@ -1,16 +0,0 @@
<script setup lang="ts">
import type { AvatarImageProps } from "reka-ui"
import { AvatarImage } from "reka-ui"
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage
data-slot="avatar-image"
v-bind="props"
class="aspect-square size-full"
>
<slot />
</AvatarImage>
</template>

View File

@@ -1,3 +0,0 @@
export { default as Avatar } from "./Avatar.vue"
export { default as AvatarFallback } from "./AvatarFallback.vue"
export { default as AvatarImage } from "./AvatarImage.vue"

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>

View File

@@ -1,5 +1,4 @@
export { default as Card } from "./Card.vue"
export { default as CardAction } from "./CardAction.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"

View File

@@ -1,59 +0,0 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { X } from "lucide-vue-next"
import { reactiveOmit } from "@vueuse/core"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="dialog-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>

View File

@@ -5,6 +5,4 @@ export { default as DialogDescription } from "./DialogDescription.vue"
export { default as DialogFooter } from "./DialogFooter.vue"
export { default as DialogHeader } from "./DialogHeader.vue"
export { default as DialogOverlay } from "./DialogOverlay.vue"
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
export { default as DialogTitle } from "./DialogTitle.vue"
export { default as DialogTrigger } from "./DialogTrigger.vue"

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { Check } from "lucide-vue-next"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuCheckboxItem,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
:class=" cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import type { DropdownMenuGroupProps } from "reka-ui"
import { DropdownMenuGroup } from "reka-ui"
const props = defineProps<DropdownMenuGroupProps>()
</script>
<template>
<DropdownMenuGroup
data-slot="dropdown-menu-group"
v-bind="props"
>
<slot />
</DropdownMenuGroup>
</template>

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
import type { DropdownMenuLabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DropdownMenuLabel, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, "class", "inset")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuLabel
data-slot="dropdown-menu-label"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)"
>
<slot />
</DropdownMenuLabel>
</template>

View File

@@ -1,21 +0,0 @@
<script setup lang="ts">
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from "reka-ui"
import {
DropdownMenuRadioGroup,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<DropdownMenuRadioGroupProps>()
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRadioGroup
data-slot="dropdown-menu-radio-group"
v-bind="forwarded"
>
<slot />
</DropdownMenuRadioGroup>
</template>

View File

@@ -1,40 +0,0 @@
<script setup lang="ts">
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { Circle } from "lucide-vue-next"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuRadioItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Circle class="size-2 fill-current" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
data-slot="dropdown-menu-shortcut"
:class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)"
>
<slot />
</span>
</template>

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from "reka-ui"
import {
DropdownMenuSub,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<DropdownMenuSubProps>()
const emits = defineEmits<DropdownMenuSubEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded">
<slot v-bind="slotProps" />
</DropdownMenuSub>
</template>

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
const consoleAppearance = useConsoleAppearance()
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuSubContent,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuSubContent
data-slot="dropdown-menu-sub-content"
:data-console-layout="consoleAppearance"
v-bind="forwarded"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] max-w-(--reka-dropdown-menu-content-available-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
>
<slot />
</DropdownMenuSubContent>
</template>

View File

@@ -1,31 +0,0 @@
<script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { ChevronRight } from "lucide-vue-next"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuSubTrigger,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, "class", "inset")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger"
v-bind="forwardedProps"
:data-inset="inset ? '' : undefined"
:class="cn(
`relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!`,
props.class,
)"
>
<slot />
<ChevronRight class="ml-auto size-4" />
</DropdownMenuSubTrigger>
</template>

View File

@@ -1,16 +1,7 @@
export { default as DropdownMenu } from "./DropdownMenu.vue"
export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue"
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue"
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue"
export { default as DropdownMenuItem } from "./DropdownMenuItem.vue"
export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue"
export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue"
export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue"
export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue"
export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue"
export { default as DropdownMenuSub } from "./DropdownMenuSub.vue"
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue"
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue"
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue"
export { DropdownMenuPortal } from "reka-ui"

View File

@@ -1,54 +0,0 @@
<script setup lang="ts">
import type { AcceptableValue } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { ChevronDownIcon } from "lucide-vue-next"
import { reactiveOmit, useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<{ modelValue?: AcceptableValue | AcceptableValue[], class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
{ size: "default" },
)
const emit = defineEmits<{
"update:modelValue": [value: AcceptableValue]
}>()
const modelValue = useVModel(props, "modelValue", emit, {
passive: true,
defaultValue: "",
})
const delegatedProps = reactiveOmit(props, "class", "size")
</script>
<template>
<div
class="group/native-select relative w-full has-[select:disabled]:opacity-50"
data-slot="native-select-wrapper"
>
<select
v-bind="{ ...$attrs, ...delegatedProps }"
v-model="modelValue"
data-slot="native-select"
:data-size="size"
:class="cn(
'border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 data-[size=default]:h-9 data-[size=sm]:h-8 w-full min-w-0 appearance-none rounded-md border bg-transparent px-3 py-2 pr-9 text-sm shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)"
>
<slot />
</select>
<ChevronDownIcon
class="text-muted-foreground pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 opacity-50 select-none"
aria-hidden="true"
data-slot="native-select-icon"
/>
</div>
</template>

View File

@@ -1,12 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<option data-slot="native-select-option" :class="cn('bg-popover text-popover-foreground', props.class)">
<slot />
</option>
</template>

View File

@@ -1,2 +0,0 @@
export { default as NativeSelect } from "./NativeSelect.vue"
export { default as NativeSelectOption } from "./NativeSelectOption.vue"

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import type { SelectGroupProps } from "reka-ui"
import { SelectGroup } from "reka-ui"
const props = defineProps<SelectGroupProps>()
</script>
<template>
<SelectGroup
data-slot="select-group"
v-bind="props"
>
<slot />
</SelectGroup>
</template>

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import type { SelectItemTextProps } from "reka-ui"
import { SelectItemText } from "reka-ui"
const props = defineProps<SelectItemTextProps>()
</script>
<template>
<SelectItemText
data-slot="select-item-text"
v-bind="props"
>
<slot />
</SelectItemText>
</template>

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import type { SelectLabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { SelectLabel } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>()
</script>
<template>
<SelectLabel
data-slot="select-label"
:class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)"
>
<slot />
</SelectLabel>
</template>

View File

@@ -1,19 +0,0 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SelectSeparator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<SelectSeparator
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
/>
</template>

View File

@@ -1,11 +1,7 @@
export { default as Select } from "./Select.vue"
export { default as SelectContent } from "./SelectContent.vue"
export { default as SelectGroup } from "./SelectGroup.vue"
export { default as SelectItem } from "./SelectItem.vue"
export { default as SelectItemText } from "./SelectItemText.vue"
export { default as SelectLabel } from "./SelectLabel.vue"
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue"
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue"
export { default as SelectSeparator } from "./SelectSeparator.vue"
export { default as SelectTrigger } from "./SelectTrigger.vue"
export { default as SelectValue } from "./SelectValue.vue"

View File

@@ -1,35 +0,0 @@
<script setup lang="ts">
import type { ToggleEmits, ToggleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ToggleVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { Toggle, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
import { toggleVariants } from "."
const props = withDefaults(defineProps<ToggleProps & {
class?: HTMLAttributes["class"]
variant?: ToggleVariants["variant"]
size?: ToggleVariants["size"]
}>(), {
variant: "default",
size: "default",
disabled: false,
})
const emits = defineEmits<ToggleEmits>()
const delegatedProps = reactiveOmit(props, "class", "size", "variant")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<Toggle
v-slot="slotProps"
data-slot="toggle"
v-bind="forwarded"
:class="cn(toggleVariants({ variant, size }), props.class)"
>
<slot v-bind="slotProps" />
</Toggle>
</template>

View File

@@ -1,7 +1,6 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Toggle } from "./Toggle.vue"
export const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",

View File

@@ -0,0 +1,433 @@
import { computed, onScopeDispose, ref, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import {
rtspConfigApi, rustdeskConfigApi, vncConfigApi,
type RustDeskConfigResponse, type RustDeskStatusResponse, type RustDeskPasswordResponse,
type RtspStatusResponse, type RtspConfigUpdate, type VncConfigUpdate, type VncStatusResponse,
} from '@/api'
import { useConfigStore } from '@/stores/config'
import { useClipboard } from '@/composables/useClipboard'
import { formatHostForUrl } from '@/lib/url'
type RemoteService = 'rtsp' | 'vnc' | 'rustdesk'
interface RemoteAccessSettingsOptions {
loading: Ref<boolean>
showValidationError: (message: string) => boolean
}
export function useRemoteAccessSettings({ loading, showValidationError }: RemoteAccessSettingsOptions) {
const { t } = useI18n()
const configStore = useConfigStore()
const { copy: clipboardCopy } = useClipboard()
const serviceSaving = ref<RemoteService | null>(null)
let copiedTimer: ReturnType<typeof setTimeout> | undefined
onScopeDispose(() => clearTimeout(copiedTimer))
const rustdeskConfig = ref<RustDeskConfigResponse | null>(null)
const rustdeskStatus = ref<RustDeskStatusResponse | null>(null)
const rustdeskPassword = ref<RustDeskPasswordResponse | null>(null)
const rustdeskLoading = ref(false)
const rustdeskCopied = ref<'id' | 'password' | null>(null)
const rustdeskLocalConfig = ref({
enabled: false,
mode: 'id' as 'id' | 'direct_ip',
codec: 'h264' as 'h264' | 'h265',
direct_access_port: 21118,
rendezvous_server: '',
relay_server: '',
relay_key: '',
})
const rustdeskValidationMessage = computed(() => {
if (
rustdeskLocalConfig.value.mode === 'id' &&
!rustdeskLocalConfig.value.rendezvous_server?.trim()
) {
return t('extensions.rustdesk.rendezvousServerRequired')
}
if (
rustdeskLocalConfig.value.mode === 'direct_ip' &&
(rustdeskLocalConfig.value.direct_access_port < 1 ||
rustdeskLocalConfig.value.direct_access_port > 65535)
) {
return t('extensions.rustdesk.directAccessPortInvalid')
}
return ''
})
const rtspStatus = ref<RtspStatusResponse | null>(null)
const rtspLoading = ref(false)
const rtspLocalConfig = ref<RtspConfigUpdate>({
enabled: false,
bind: '0.0.0.0',
port: 8554,
path: 'live',
allow_one_client: true,
codec: 'h264',
username: '',
password: '',
})
const vncStatus = ref<VncStatusResponse | null>(null)
const vncLoading = ref(false)
const vncLocalConfig = ref<VncConfigUpdate>({
enabled: false,
bind: '0.0.0.0',
port: 5900,
encoding: 'tight_jpeg',
allow_one_client: true,
password: '',
})
const rtspStreamUrl = computed(() => {
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
const path = normalizeRtspPath(rtspLocalConfig.value.path || 'live')
const port = Number(rtspLocalConfig.value.port) || 8554
return `rtsp://${host}:${port}/${path}`
})
const vncStreamUrl = computed(() => {
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
const port = Number(vncLocalConfig.value.port) || 5900
return `${host}:${port}`
})
function applyRustdeskStatus(status: RustDeskStatusResponse) {
const config = status.config
rustdeskConfig.value = config
rustdeskStatus.value = status
rustdeskLocalConfig.value = {
enabled: config.enabled,
mode: config.mode || 'id',
codec: config.codec || 'h264',
direct_access_port: config.direct_access_port || 21118,
rendezvous_server: config.rendezvous_server,
relay_server: config.relay_server || '',
relay_key: config.relay_key || '',
}
}
async function loadRustdeskConfig() {
rustdeskLoading.value = true
try {
const status = await configStore.refreshRustdeskStatus()
applyRustdeskStatus(status)
} catch {
} finally {
rustdeskLoading.value = false
}
}
async function loadRustdeskPassword() {
try {
rustdeskPassword.value = await configStore.refreshRustdeskPassword()
} catch {}
}
function normalizeRustdeskServer(value: string, defaultPort: number): string {
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed.includes(':')) return trimmed
return `${trimmed}:${defaultPort}`
}
/** Strip line breaks from pasted keys. */
function normalizeRustdeskRelayKey(value: string): string {
return value.replace(/\r?\n/g, '').trim()
}
function validateRustdeskConfig(): boolean {
return !rustdeskValidationMessage.value || showValidationError(rustdeskValidationMessage.value)
}
function validateVncConfig(enabled = vncLocalConfig.value.enabled): boolean {
const password = (vncLocalConfig.value.password || '').trim()
if (enabled && !vncStatus.value?.config.has_password && !password) {
return showValidationError(t('extensions.vnc.passwordRequired'))
}
if (password.length > 8) {
return showValidationError(t('extensions.vnc.passwordMaxLength'))
}
return true
}
function normalizeRtspPath(path: string): string {
return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
}
function rustdeskUpdatePayload(enabled = rustdeskLocalConfig.value.enabled) {
return {
enabled,
mode: rustdeskLocalConfig.value.mode,
codec: rustdeskLocalConfig.value.codec,
direct_access_port: rustdeskLocalConfig.value.direct_access_port,
rendezvous_server: normalizeRustdeskServer(rustdeskLocalConfig.value.rendezvous_server, 21116),
relay_server: normalizeRustdeskServer(rustdeskLocalConfig.value.relay_server, 21117),
relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key),
}
}
async function saveService(
service: RemoteService,
persist: () => Promise<unknown>,
reload: () => Promise<void>,
) {
serviceSaving.value = service
loading.value = true
try {
await persist()
await reload()
toast.success(t('settingsUi.saveDone'))
} catch {
// API requests report their own errors.
} finally {
loading.value = false
serviceSaving.value = null
}
}
async function saveRustdeskConfig() {
if (
(rustdeskLocalConfig.value.enabled || rustdeskLocalConfig.value.mode === 'direct_ip') &&
!validateRustdeskConfig()
)
return
await saveService(
'rustdesk', () => configStore.updateRustdesk(rustdeskUpdatePayload()), loadRustdeskConfig,
)
}
async function regenerateRustdeskId() {
if (!confirm(t('extensions.rustdesk.confirmRegenerateId'))) return
rustdeskLoading.value = true
try {
await configStore.regenerateRustdeskId()
await loadRustdeskConfig()
await loadRustdeskPassword()
} catch {
} finally {
rustdeskLoading.value = false
}
}
async function regenerateRustdeskPassword() {
if (!confirm(t('extensions.rustdesk.confirmRegeneratePassword'))) return
rustdeskLoading.value = true
try {
await configStore.regenerateRustdeskPassword()
await loadRustdeskConfig()
await loadRustdeskPassword()
} catch {
} finally {
rustdeskLoading.value = false
}
}
async function startRustdesk() {
if (!validateRustdeskConfig()) return
rustdeskLoading.value = true
try {
await configStore.updateRustdesk(rustdeskUpdatePayload())
const status = await rustdeskConfigApi.start()
applyRustdeskStatus(status)
} catch {
} finally {
rustdeskLoading.value = false
}
}
async function stopRustdesk() {
rustdeskLoading.value = true
try {
const status = await rustdeskConfigApi.stop()
applyRustdeskStatus(status)
} catch {
} finally {
rustdeskLoading.value = false
}
}
async function copyToClipboard(text: string, type: 'id' | 'password') {
const success = await clipboardCopy(text)
if (success) {
rustdeskCopied.value = type
clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => (rustdeskCopied.value = null), 2000)
}
}
function applyRtspStatus(status: RtspStatusResponse) {
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: status.config.password || '',
}
}
async function loadRtspConfig() {
rtspLoading.value = true
try {
const status = await configStore.refreshRtspStatus()
applyRtspStatus(status)
} catch {
} finally {
rtspLoading.value = false
}
}
function rtspUpdatePayload(enabled = !!rtspLocalConfig.value.enabled): RtspConfigUpdate {
return {
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(),
password: (rtspLocalConfig.value.password || '').trim(),
}
}
async function saveRtspConfig() {
await saveService(
'rtsp', () => configStore.updateRtsp(rtspUpdatePayload()), loadRtspConfig,
)
}
async function startRtsp() {
rtspLoading.value = true
try {
await configStore.updateRtsp(rtspUpdatePayload())
const status = await rtspConfigApi.start()
applyRtspStatus(status)
} catch {
} finally {
rtspLoading.value = false
}
}
async function stopRtsp() {
rtspLoading.value = true
try {
const status = await rtspConfigApi.stop()
applyRtspStatus(status)
} catch {
} finally {
rtspLoading.value = false
}
}
function applyVncStatus(status: VncStatusResponse) {
vncStatus.value = status
vncLocalConfig.value = {
enabled: status.config.enabled,
bind: status.config.bind,
port: status.config.port,
encoding: status.config.encoding,
allow_one_client: status.config.allow_one_client,
password: '',
}
}
async function loadVncConfig() {
vncLoading.value = true
try {
const status = await configStore.refreshVncStatus()
applyVncStatus(status)
} catch {
} finally {
vncLoading.value = false
}
}
function vncUpdatePayload(enabled = !!vncLocalConfig.value.enabled): VncConfigUpdate {
const update: VncConfigUpdate = {
enabled,
bind: vncLocalConfig.value.bind?.trim() || '0.0.0.0',
port: Number(vncLocalConfig.value.port) || 5900,
encoding: vncLocalConfig.value.encoding || 'tight_jpeg',
allow_one_client: !!vncLocalConfig.value.allow_one_client,
}
const password = (vncLocalConfig.value.password || '').trim()
if (password) update.password = password
return update
}
async function saveVncConfig() {
if (!validateVncConfig()) return
await saveService(
'vnc', () => configStore.updateVnc(vncUpdatePayload()), loadVncConfig,
)
}
async function startVnc() {
if (!validateVncConfig(true)) return
vncLoading.value = true
try {
await configStore.updateVnc(vncUpdatePayload())
const status = await vncConfigApi.start()
applyVncStatus(status)
} catch {
} finally {
vncLoading.value = false
}
}
async function stopVnc() {
vncLoading.value = true
try {
const status = await vncConfigApi.stop()
applyVncStatus(status)
} catch {
} finally {
vncLoading.value = false
}
}
return {
rustdeskConfig,
rustdeskStatus,
rustdeskPassword,
rustdeskLoading,
rustdeskCopied,
rustdeskLocalConfig,
rustdeskValidationMessage,
rtspStatus,
rtspLoading,
rtspLocalConfig,
vncStatus,
vncLoading,
vncLocalConfig,
rtspStreamUrl,
vncStreamUrl,
serviceSaving,
loadRustdeskConfig,
loadRustdeskPassword,
saveRustdeskConfig,
regenerateRustdeskId,
regenerateRustdeskPassword,
startRustdesk,
stopRustdesk,
copyToClipboard,
loadRtspConfig,
saveRtspConfig,
startRtsp,
stopRtsp,
loadVncConfig,
saveVncConfig,
startVnc,
stopVnc,
}
}

View File

@@ -1,60 +0,0 @@
import { ref, watch, type Ref } from 'vue'
export interface UseConfigPopoverOptions {
/** Reactive open state from props */
open: Ref<boolean>
/** Load device list callback */
loadDevices?: () => Promise<void>
/** Initialize from current config callback */
initializeFromCurrent?: () => void
}
export function useConfigPopover(options: UseConfigPopoverOptions) {
const applying = ref(false)
const loadingDevices = ref(false)
watch(options.open, async (isOpen) => {
if (isOpen) {
options.initializeFromCurrent?.()
if (options.loadDevices) {
loadingDevices.value = true
try {
await options.loadDevices()
} finally {
loadingDevices.value = false
}
}
}
})
async function applyConfig(applyFn: () => Promise<void>) {
applying.value = true
try {
await applyFn()
} catch (e) {
console.info('[ConfigPopover] Apply failed:', e)
} finally {
applying.value = false
}
}
// Refresh devices
async function refreshDevices() {
if (!options.loadDevices) return
loadingDevices.value = true
try {
await options.loadDevices()
} finally {
loadingDevices.value = false
}
}
return {
applying,
loadingDevices,
applyConfig,
refreshDevices,
}
}

View File

@@ -1,6 +1,6 @@
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { useSystemStore } from '@/stores/system'
import { useSystemStore, type DeviceInfoEvent } from '@/stores/system'
import type { StreamDeviceLostEventData, StreamStateChangedEventData } from '@/types/websocket'
import { useWebSocket } from '@/composables/useWebSocket'
import { isAudioStreamDeviceLostPayload } from '@/lib/streamSignal'
@@ -17,14 +17,14 @@ export interface ConsoleEventHandlers {
onStreamDeviceLost?: (data: StreamDeviceLostEventData) => void
onStreamReconnecting?: (data: { device: string; attempt: number }) => void
onStreamRecovered?: (data: { device: string }) => void
onDeviceInfo?: (data: any) => void
onDeviceInfo?: (data: DeviceInfoEvent) => void
}
export function useConsoleEvents(handlers: ConsoleEventHandlers) {
const { t } = useI18n()
const systemStore = useSystemStore()
const { on, off, connect } = useWebSocket()
const noop = () => {}
let subscribed = false
function handleStreamDeviceLost(data: StreamDeviceLostEventData) {
const audioLost = isAudioStreamDeviceLostPayload(data)
@@ -41,53 +41,44 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
handlers.onStreamDeviceLost?.(data)
}
function handleStreamReconnecting(data: { device: string; attempt: number }) {
handlers.onStreamReconnecting?.(data)
}
function handleStreamRecovered(_data: { device: string }) {
function handleStreamRecovered(data: { device: string }) {
if (systemStore.stream) {
systemStore.stream.online = true
}
handlers.onStreamRecovered?.(_data)
handlers.onStreamRecovered?.(data)
}
function handleStreamStateChangedForward(data: StreamStateChangedEventData) {
handlers.onStreamStateChanged?.(data)
}
// Keep registration and cleanup together so every subscription is removed.
const subscriptions = [
['stream.config_changing', handlers.onStreamConfigChanging],
['stream.config_applied', handlers.onStreamConfigApplied],
['stream.stats_update', handlers.onStreamStatsUpdate],
['stream.mode_changed', handlers.onStreamModeChanged],
['stream.mode_switching', handlers.onStreamModeSwitching],
['stream.mode_ready', handlers.onStreamModeReady],
['stream.webrtc_ready', handlers.onWebRTCReady],
['stream.state_changed', handlers.onStreamStateChanged],
['stream.device_lost', handleStreamDeviceLost],
['stream.reconnecting', handlers.onStreamReconnecting],
['stream.recovered', handleStreamRecovered],
['system.device_info', handlers.onDeviceInfo],
] as const
function subscribe() {
on('stream.config_changing', handlers.onStreamConfigChanging ?? noop)
on('stream.config_applied', handlers.onStreamConfigApplied ?? noop)
on('stream.stats_update', handlers.onStreamStatsUpdate ?? noop)
on('stream.mode_changed', handlers.onStreamModeChanged ?? noop)
on('stream.mode_switching', handlers.onStreamModeSwitching ?? noop)
on('stream.mode_ready', handlers.onStreamModeReady ?? noop)
on('stream.webrtc_ready', handlers.onWebRTCReady ?? noop)
on('stream.state_changed', handleStreamStateChangedForward)
on('stream.device_lost', handleStreamDeviceLost)
on('stream.reconnecting', handleStreamReconnecting)
on('stream.recovered', handleStreamRecovered)
on('system.device_info', handlers.onDeviceInfo ?? noop)
if (subscribed) return
subscribed = true
for (const [event, handler] of subscriptions) {
if (handler) on(event, handler)
}
connect()
}
function unsubscribe() {
off('stream.config_changing', handlers.onStreamConfigChanging ?? noop)
off('stream.config_applied', handlers.onStreamConfigApplied ?? noop)
off('stream.stats_update', handlers.onStreamStatsUpdate ?? noop)
off('stream.mode_changed', handlers.onStreamModeChanged ?? noop)
off('stream.mode_switching', handlers.onStreamModeSwitching ?? noop)
off('stream.mode_ready', handlers.onStreamModeReady ?? noop)
off('stream.webrtc_ready', handlers.onWebRTCReady ?? noop)
off('stream.state_changed', handleStreamStateChangedForward)
off('stream.device_lost', handleStreamDeviceLost)
off('stream.reconnecting', handleStreamReconnecting)
off('stream.recovered', handleStreamRecovered)
off('system.device_info', handlers.onDeviceInfo ?? noop)
if (!subscribed) return
subscribed = false
for (const [event, handler] of subscriptions) {
if (handler) off(event, handler)
}
}
return {

View File

@@ -1,20 +1,11 @@
// Manages audio playback across different video modes (MJPEG/WebSocket and H264/WebRTC)
// Provides a single interface for volume control and audio source switching
import { ref, watch, type Ref } from 'vue'
import { ref, watch } from 'vue'
import { getAudioPlayer } from './useAudioPlayer'
export type AudioMode = 'ws' | 'webrtc'
export interface UnifiedAudioState {
audioMode: Ref<AudioMode>
volume: Ref<number>
muted: Ref<boolean>
connected: Ref<boolean>
playing: Ref<boolean>
error: Ref<string | null>
}
export function useUnifiedAudio() {
const audioMode = ref<AudioMode>('ws')
const volume = ref(0) // 0-1, default muted (browser autoplay policy)

View File

@@ -1,217 +0,0 @@
// Legacy MJPEG-only streaming composable.
// Deprecated: Console now uses useVideoSession for all switching/connection logic.
import { ref, computed, type Ref } from 'vue'
import { useSystemStore } from '@/stores/system'
import { streamApi } from '@/api'
import { generateUUID } from '@/lib/utils'
export type VideoMode = 'mjpeg'
export interface VideoStreamState {
mode: Ref<VideoMode>
loading: Ref<boolean>
error: Ref<boolean>
errorMessage: Ref<string>
restarting: Ref<boolean>
fps: Ref<number>
mjpegUrl: Ref<string>
clientId: string
}
const BASE_RETRY_DELAY = 2000
const GRACE_PERIOD = 2000
const MAX_CONSECUTIVE_ERRORS = 2
/** @deprecated Use useVideoSession + ConsoleView instead. */
export function useVideoStream() {
const systemStore = useSystemStore()
const videoMode = ref<VideoMode>('mjpeg')
const videoLoading = ref(true)
const videoError = ref(false)
const videoErrorMessage = ref('')
const videoRestarting = ref(false)
const backendFps = ref(0)
const mjpegTimestamp = ref(0)
const clientId = generateUUID()
const clientsStats = ref<Record<string, { id: string; fps: number; connected_secs: number }>>({})
let retryTimeoutId: number | null = null
let retryCount = 0
let gracePeriodTimeoutId: number | null = null
let consecutiveErrors = 0
let isRefreshingVideo = false
let initialDeviceInfoReceived = false
const mjpegUrl = computed(() => {
if (videoMode.value !== 'mjpeg') return ''
if (mjpegTimestamp.value === 0) return ''
return `${streamApi.getMjpegUrl(clientId)}&t=${mjpegTimestamp.value}`
})
function refreshVideo() {
backendFps.value = 0
videoError.value = false
videoErrorMessage.value = ''
isRefreshingVideo = true
videoLoading.value = true
mjpegTimestamp.value = Date.now()
setTimeout(() => {
isRefreshingVideo = false
if (videoLoading.value) {
videoLoading.value = false
}
}, 1500)
}
function handleVideoLoad() {
if (videoMode.value === 'mjpeg') {
systemStore.setStreamOnline(true)
}
if (!videoLoading.value) return
clearRetryTimers()
videoLoading.value = false
videoError.value = false
videoErrorMessage.value = ''
videoRestarting.value = false
retryCount = 0
consecutiveErrors = 0
}
function handleVideoError() {
if (videoMode.value !== 'mjpeg') return
if (isRefreshingVideo) return
consecutiveErrors += 1
if (consecutiveErrors > MAX_CONSECUTIVE_ERRORS && gracePeriodTimeoutId !== null) {
clearTimeout(gracePeriodTimeoutId)
gracePeriodTimeoutId = null
videoRestarting.value = false
}
if (videoRestarting.value || gracePeriodTimeoutId !== null) return
if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
}
videoLoading.value = true
retryCount += 1
const delay = BASE_RETRY_DELAY * Math.pow(1.5, Math.min(retryCount - 1, 5))
retryTimeoutId = window.setTimeout(() => {
retryTimeoutId = null
refreshVideo()
}, delay)
}
function clearRetryTimers() {
if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
}
if (gracePeriodTimeoutId !== null) {
clearTimeout(gracePeriodTimeoutId)
gracePeriodTimeoutId = null
}
}
function handleStreamConfigChanging() {
clearRetryTimers()
videoRestarting.value = true
videoLoading.value = true
videoError.value = false
retryCount = 0
consecutiveErrors = 0
backendFps.value = 0
}
function handleStreamConfigApplied() {
consecutiveErrors = 0
gracePeriodTimeoutId = window.setTimeout(() => {
gracePeriodTimeoutId = null
consecutiveErrors = 0
}, GRACE_PERIOD)
videoRestarting.value = false
refreshVideo()
}
function handleStreamStatsUpdate(data: { clients?: number; clients_stat?: Record<string, { fps: number }> }) {
if (typeof data.clients === 'number') {
systemStore.updateStreamClients(data.clients)
}
if (data.clients_stat) {
clientsStats.value = data.clients_stat as any
const myStats = data.clients_stat[clientId]
if (myStats) {
backendFps.value = myStats.fps || 0
} else {
const fpsList = Object.values(data.clients_stat)
.map((s) => s?.fps || 0)
.filter(f => f > 0)
backendFps.value = fpsList.length > 0 ? Math.min(...fpsList) : 0
}
} else {
backendFps.value = 0
}
}
function handleDeviceInfo(data: any) {
systemStore.updateFromDeviceInfo(data)
if (data.video?.config_changing) return
if (!initialDeviceInfoReceived) {
initialDeviceInfoReceived = true
if (data.video?.stream_mode === 'mjpeg') {
setTimeout(() => refreshVideo(), 100)
}
}
}
function handleModeChange(mode: VideoMode) {
if (mode !== 'mjpeg') return
if (mode === videoMode.value) return
videoMode.value = mode
localStorage.setItem('videoMode', mode)
refreshVideo()
}
function cleanup() {
clearRetryTimers()
}
const state: VideoStreamState = {
mode: videoMode,
loading: videoLoading,
error: videoError,
errorMessage: videoErrorMessage,
restarting: videoRestarting,
fps: backendFps,
mjpegUrl,
clientId,
}
return {
state,
clientsStats,
refreshVideo,
handleVideoLoad,
handleVideoError,
handleStreamConfigChanging,
handleStreamConfigApplied,
handleStreamStatsUpdate,
handleDeviceInfo,
handleModeChange,
cleanup,
}
}

View File

@@ -0,0 +1,54 @@
import { ref, type Ref } from 'vue'
/** Shared request state for a service with several independently cached endpoints. */
export function createRequestState() {
const loading = ref(false)
const error = ref<string | null>(null)
let pending = 0
async function run<T>(request: () => Promise<T>): Promise<T> {
pending++
loading.value = true
error.value = null
try {
return await request()
} catch (cause) {
error.value = cause instanceof Error ? cause.message
: typeof cause === 'string' ? cause : 'Unknown error'
throw cause
} finally {
loading.value = --pending > 0
}
}
return { loading, error, run }
}
/** Cache successful reads and coalesce concurrent refreshes of the same endpoint. */
export function createConfigResource<T>(
fetcher: () => Promise<T>,
state = createRequestState(),
onLoaded?: (response: T) => void,
) {
const data = ref(null) as Ref<T | null>
let inFlight: Promise<T> | null = null
function refresh(): Promise<T> {
if (inFlight) return inFlight
inFlight = state.run(async () => {
const response = await fetcher()
data.value = response
onLoaded?.(response)
return response
}).finally(() => {
inFlight = null
})
return inFlight
}
function ensure(): Promise<T> {
return data.value === null ? refresh() : Promise.resolve(data.value)
}
return { data, loading: state.loading, error: state.error, refresh, ensure }
}

4
web/src/lib/url.ts Normal file
View File

@@ -0,0 +1,4 @@
export function formatHostForUrl(hostname: string): string {
if (!hostname) return '127.0.0.1'
return hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname
}

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ref } from 'vue'
import { authApi, systemApi, type AuthLoginResponse } from '@/api'
export const useAuthStore = defineStore('auth', () => {
@@ -11,8 +11,6 @@ export const useAuthStore = defineStore('auth', () => {
const error = ref<string | null>(null)
let pendingUsername: string | null = null
const isLoggedIn = computed(() => isAuthenticated.value && user.value !== null)
async function checkSetupStatus() {
try {
const status = await systemApi.setupStatus()
@@ -136,7 +134,6 @@ export const useAuthStore = defineStore('auth', () => {
needsSetup,
loading,
error,
isLoggedIn,
checkSetupStatus,
checkAuth,
beginLogin,

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { createConfigResource, createRequestState } from '@/lib/configResource'
import {
authConfigApi,
atxConfigApi,
@@ -15,488 +15,88 @@ import {
webConfigApi,
} from '@/api'
import type {
AtxConfig,
AtxConfigUpdate,
AudioConfig,
AudioConfigUpdate,
AuthConfig,
AuthConfigUpdate,
HidConfig,
HidConfigUpdate,
MsdConfig,
MsdConfigUpdate,
OtgConfigResponse,
OtgConfigUpdate,
StreamConfigResponse,
StreamConfigUpdate,
VideoConfig,
VideoConfigUpdate,
} from '@/types/generated'
import type {
RtspConfigResponse as ApiRtspConfigResponse,
RtspConfigUpdate as ApiRtspConfigUpdate,
RtspStatusResponse as ApiRtspStatusResponse,
RustDeskConfigResponse as ApiRustDeskConfigResponse,
RustDeskConfigUpdate as ApiRustDeskConfigUpdate,
RustDeskStatusResponse as ApiRustDeskStatusResponse,
RustDeskPasswordResponse as ApiRustDeskPasswordResponse,
VncConfigResponse as ApiVncConfigResponse,
VncConfigUpdate as ApiVncConfigUpdate,
VncStatusResponse as ApiVncStatusResponse,
WebConfig,
WebConfigUpdate,
} from '@/api'
function normalizeErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === 'string') return error
return 'Unknown error'
}
export const useConfigStore = defineStore('config', () => {
const auth = ref<AuthConfig | null>(null)
const video = ref<VideoConfig | null>(null)
const audio = ref<AudioConfig | null>(null)
const hid = ref<HidConfig | null>(null)
const msd = ref<MsdConfig | null>(null)
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 vncConfig = ref<ApiVncConfigResponse | null>(null)
const vncStatus = ref<ApiVncStatusResponse | null>(null)
const rustdeskConfig = ref<ApiRustDeskConfigResponse | null>(null)
const rustdeskStatus = ref<ApiRustDeskStatusResponse | null>(null)
const rustdeskPassword = ref<ApiRustDeskPasswordResponse | null>(null)
const {
data: auth, loading: authLoading, error: authError,
refresh: refreshAuth, ensure: ensureAuth,
} = createConfigResource(authConfigApi.get)
const {
data: video, loading: videoLoading, error: videoError,
refresh: refreshVideo, ensure: ensureVideo,
} = createConfigResource(videoConfigApi.get)
const {
data: audio, loading: audioLoading, error: audioError,
refresh: refreshAudio, ensure: ensureAudio,
} = createConfigResource(audioConfigApi.get)
const {
data: hid, loading: hidLoading, error: hidError,
refresh: refreshHid, ensure: ensureHid,
} = createConfigResource(hidConfigApi.get)
const {
data: msd, loading: msdLoading, error: msdError,
refresh: refreshMsd, ensure: ensureMsd,
} = createConfigResource(msdConfigApi.get)
const {
data: stream, loading: streamLoading, error: streamError,
refresh: refreshStream, ensure: ensureStream,
} = createConfigResource(streamConfigApi.get)
const {
data: web, loading: webLoading, error: webError,
refresh: refreshWeb, ensure: ensureWeb,
} = createConfigResource(webConfigApi.get)
const {
data: atx, loading: atxLoading, error: atxError,
refresh: refreshAtx, ensure: ensureAtx,
} = createConfigResource(atxConfigApi.get)
const authLoading = ref(false)
const videoLoading = ref(false)
const audioLoading = ref(false)
const hidLoading = ref(false)
const msdLoading = ref(false)
const streamLoading = ref(false)
const webLoading = ref(false)
const atxLoading = ref(false)
const rtspLoading = ref(false)
const vncLoading = ref(false)
const rustdeskLoading = ref(false)
const authError = ref<string | null>(null)
const videoError = ref<string | null>(null)
const audioError = ref<string | null>(null)
const hidError = ref<string | null>(null)
const msdError = ref<string | null>(null)
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 vncError = ref<string | null>(null)
const rustdeskError = ref<string | null>(null)
let authPromise: Promise<AuthConfig> | null = null
let videoPromise: Promise<VideoConfig> | null = null
let audioPromise: Promise<AudioConfig> | null = null
let hidPromise: Promise<HidConfig> | null = null
let msdPromise: Promise<MsdConfig> | null = null
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 vncPromise: Promise<ApiVncConfigResponse> | null = null
let vncStatusPromise: Promise<ApiVncStatusResponse> | null = null
let rustdeskPromise: Promise<ApiRustDeskConfigResponse> | null = null
let rustdeskStatusPromise: Promise<ApiRustDeskStatusResponse> | null = null
let rustdeskPasswordPromise: Promise<ApiRustDeskPasswordResponse> | null = null
async function refreshAuth() {
if (authLoading.value && authPromise) return authPromise
authLoading.value = true
authError.value = null
const request = authConfigApi.get()
.then((response) => {
auth.value = response
return response
})
.catch((error) => {
authError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
authLoading.value = false
authPromise = null
})
authPromise = request
return request
}
async function refreshVideo() {
if (videoLoading.value && videoPromise) return videoPromise
videoLoading.value = true
videoError.value = null
const request = videoConfigApi.get()
.then((response) => {
video.value = response
return response
})
.catch((error) => {
videoError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
videoLoading.value = false
videoPromise = null
})
videoPromise = request
return request
}
async function refreshAudio() {
if (audioLoading.value && audioPromise) return audioPromise
audioLoading.value = true
audioError.value = null
const request = audioConfigApi.get()
.then((response) => {
audio.value = response
return response
})
.catch((error) => {
audioError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
audioLoading.value = false
audioPromise = null
})
audioPromise = request
return request
}
async function refreshHid() {
if (hidLoading.value && hidPromise) return hidPromise
hidLoading.value = true
hidError.value = null
const request = hidConfigApi.get()
.then((response) => {
hid.value = response
return response
})
.catch((error) => {
hidError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
hidLoading.value = false
hidPromise = null
})
hidPromise = request
return request
}
async function refreshMsd() {
if (msdLoading.value && msdPromise) return msdPromise
msdLoading.value = true
msdError.value = null
const request = msdConfigApi.get()
.then((response) => {
msd.value = response
return response
})
.catch((error) => {
msdError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
msdLoading.value = false
msdPromise = null
})
msdPromise = request
return request
}
async function refreshStream() {
if (streamLoading.value && streamPromise) return streamPromise
streamLoading.value = true
streamError.value = null
const request = streamConfigApi.get()
.then((response) => {
stream.value = response
return response
})
.catch((error) => {
streamError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
streamLoading.value = false
streamPromise = null
})
streamPromise = request
return request
}
async function refreshWeb() {
if (webLoading.value && webPromise) return webPromise
webLoading.value = true
webError.value = null
const request = webConfigApi.get()
.then((response) => {
web.value = response
return response
})
.catch((error) => {
webError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
webLoading.value = false
webPromise = null
})
webPromise = request
return request
}
async function refreshAtx() {
if (atxLoading.value && atxPromise) return atxPromise
atxLoading.value = true
atxError.value = null
const request = atxConfigApi.get()
.then((response) => {
atx.value = response
return response
})
.catch((error) => {
atxError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
atxLoading.value = false
atxPromise = null
})
atxPromise = request
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
const rtspRequests = createRequestState()
const { loading: rtspLoading, error: rtspError } = rtspRequests
const { data: rtspConfig, refresh: refreshRtspConfig, ensure: ensureRtspConfig } =
createConfigResource(rtspConfigApi.get, rtspRequests)
const { data: rtspStatus, refresh: refreshRtspStatus } = createConfigResource(
rtspConfigApi.getStatus, rtspRequests, (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 refreshVncConfig() {
if (vncLoading.value && vncPromise) return vncPromise
vncLoading.value = true
vncError.value = null
const request = vncConfigApi.get()
.then((response) => {
vncConfig.value = response
return response
})
.catch((error) => {
vncError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
vncLoading.value = false
vncPromise = null
})
vncPromise = request
return request
}
async function refreshVncStatus() {
if (vncLoading.value && vncStatusPromise) return vncStatusPromise
vncLoading.value = true
vncError.value = null
const request = vncConfigApi.getStatus()
.then((response) => {
vncStatus.value = response
const vncRequests = createRequestState()
const { loading: vncLoading, error: vncError } = vncRequests
const { data: vncConfig, refresh: refreshVncConfig, ensure: ensureVncConfig } =
createConfigResource(vncConfigApi.get, vncRequests)
const { data: vncStatus, refresh: refreshVncStatus } = createConfigResource(
vncConfigApi.getStatus, vncRequests, (response) => {
vncConfig.value = response.config
return response
})
.catch((error) => {
vncError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
vncLoading.value = false
vncStatusPromise = null
})
},
)
vncStatusPromise = request
return request
}
async function refreshRustdeskConfig() {
if (rustdeskLoading.value && rustdeskPromise) return rustdeskPromise
rustdeskLoading.value = true
rustdeskError.value = null
const request = rustdeskConfigApi.get()
.then((response) => {
rustdeskConfig.value = response
return response
})
.catch((error) => {
rustdeskError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
rustdeskLoading.value = false
rustdeskPromise = null
})
rustdeskPromise = request
return request
}
async function refreshRustdeskStatus() {
if (rustdeskLoading.value && rustdeskStatusPromise) return rustdeskStatusPromise
rustdeskLoading.value = true
rustdeskError.value = null
const request = rustdeskConfigApi.getStatus()
.then((response) => {
rustdeskStatus.value = response
const rustdeskRequests = createRequestState()
const { loading: rustdeskLoading, error: rustdeskError } = rustdeskRequests
const { data: rustdeskConfig, refresh: refreshRustdeskConfig, ensure: ensureRustdeskConfig } =
createConfigResource(rustdeskConfigApi.get, rustdeskRequests)
const { data: rustdeskStatus, refresh: refreshRustdeskStatus } = createConfigResource(
rustdeskConfigApi.getStatus, rustdeskRequests, (response) => {
rustdeskConfig.value = response.config
return response
})
.catch((error) => {
rustdeskError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
rustdeskLoading.value = false
rustdeskStatusPromise = null
})
rustdeskStatusPromise = request
return request
}
async function refreshRustdeskPassword() {
if (rustdeskLoading.value && rustdeskPasswordPromise) return rustdeskPasswordPromise
rustdeskLoading.value = true
rustdeskError.value = null
const request = rustdeskConfigApi.getPassword()
.then((response) => {
rustdeskPassword.value = response
return response
})
.catch((error) => {
rustdeskError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
rustdeskLoading.value = false
rustdeskPasswordPromise = null
})
rustdeskPasswordPromise = request
return request
}
function ensureAuth() {
if (auth.value) return Promise.resolve(auth.value)
return refreshAuth()
}
function ensureVideo() {
if (video.value) return Promise.resolve(video.value)
return refreshVideo()
}
function ensureAudio() {
if (audio.value) return Promise.resolve(audio.value)
return refreshAudio()
}
function ensureHid() {
if (hid.value) return Promise.resolve(hid.value)
return refreshHid()
}
function ensureMsd() {
if (msd.value) return Promise.resolve(msd.value)
return refreshMsd()
}
function ensureStream() {
if (stream.value) return Promise.resolve(stream.value)
return refreshStream()
}
function ensureWeb() {
if (web.value) return Promise.resolve(web.value)
return refreshWeb()
}
function ensureAtx() {
if (atx.value) return Promise.resolve(atx.value)
return refreshAtx()
}
function ensureRtspConfig() {
if (rtspConfig.value) return Promise.resolve(rtspConfig.value)
return refreshRtspConfig()
}
function ensureVncConfig() {
if (vncConfig.value) return Promise.resolve(vncConfig.value)
return refreshVncConfig()
}
function ensureRustdeskConfig() {
if (rustdeskConfig.value) return Promise.resolve(rustdeskConfig.value)
return refreshRustdeskConfig()
}
},
)
const { data: rustdeskPassword, refresh: refreshRustdeskPassword } =
createConfigResource(rustdeskConfigApi.getPassword, rustdeskRequests)
async function updateAuth(update: AuthConfigUpdate) {
const response = await authConfigApi.update(update)

View File

@@ -1,3 +0,0 @@
export { useAuthStore } from './auth'
export { useConfigStore } from './config'
export { useSystemStore } from './system'

View File

@@ -1,31 +1,3 @@
import { ref, type Ref } from 'vue'
/** WebSocket connection state */
export interface WsConnectionState {
connected: Ref<boolean>
reconnectAttempts: Ref<number>
networkError: Ref<boolean>
networkErrorMessage: Ref<string | null>
}
/** Create a new WebSocket connection state */
export function createWsConnectionState(): WsConnectionState {
return {
connected: ref(false),
reconnectAttempts: ref(0),
networkError: ref(false),
networkErrorMessage: ref(null),
}
}
/** Reset connection state to initial values */
export function resetWsConnectionState(state: WsConnectionState) {
state.connected.value = false
state.reconnectAttempts.value = 0
state.networkError.value = false
state.networkErrorMessage.value = null
}
/** Build WebSocket URL from current location */
export function buildWsUrl(path: string): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -73,11 +45,3 @@ export interface StreamStateChangedEventData {
reason?: StreamSignalReason | null
next_retry_ms?: number | null
}
/** WebSocket ready states */
export const WS_STATE = {
CONNECTING: WebSocket.CONNECTING,
OPEN: WebSocket.OPEN,
CLOSING: WebSocket.CLOSING,
CLOSED: WebSocket.CLOSED,
} as const

View File

@@ -3036,9 +3036,6 @@ function handleToggleMouseMode() {
accumulatedDelta = { x: 0, y: 0 }
lastMousePosition.value = { x: 0, y: 0 }
mousePosition.value = { x: 0, y: 0 }
if (mouseMode.value === 'relative') {
}
}
async function refreshUacAvailability() {

View File

@@ -27,22 +27,12 @@ import {
atxConfigApi,
extensionsApi,
redfishConfigApi,
rtspConfigApi,
rustdeskConfigApi,
systemApi,
updateApi,
usbApi,
vncConfigApi,
watchdogConfigApi,
type EncoderBackendInfo,
type AuthConfig,
type RustDeskConfigResponse,
type RustDeskStatusResponse,
type RustDeskPasswordResponse,
type RtspStatusResponse,
type RtspConfigUpdate,
type VncConfigUpdate,
type VncStatusResponse,
type WebConfig,
type UpdateOverviewResponse,
type UpdateStatusResponse,
@@ -68,6 +58,8 @@ import type {
import { EasytierConfigMode, FrpProxyType, FrpcConfigMode } from '@/types/generated'
import { toConfigFps } from '@/lib/fps'
import { useClipboard } from '@/composables/useClipboard'
import { useRemoteAccessSettings } from '@/composables/settings/useRemoteAccessSettings'
import { formatHostForUrl } from '@/lib/url'
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
import { useTheme } from '@/composables/useTheme'
import { useConsoleLayout, type ConsoleLayout } from '@/composables/useConsoleLayout'
@@ -200,7 +192,6 @@ const onlineUpgradeSupported = computed(() => systemStore.platform?.mode === 'li
const activeSection = ref<SettingsSectionId>('appearance')
const loading = ref(false)
const saved = ref(false)
const saveError = ref('')
const SETTINGS_SECTION_IDS = [
'appearance',
@@ -571,80 +562,41 @@ const frpcValidationMessage = computed(() => {
return ''
})
const rustdeskConfig = ref<RustDeskConfigResponse | null>(null)
const rustdeskStatus = ref<RustDeskStatusResponse | null>(null)
const rustdeskPassword = ref<RustDeskPasswordResponse | null>(null)
const rustdeskLoading = ref(false)
const rustdeskCopied = ref<'id' | 'password' | null>(null)
const { copy: clipboardCopy } = useClipboard()
const rustdeskLocalConfig = ref({
enabled: false,
mode: 'id' as 'id' | 'direct_ip',
codec: 'h264' as 'h264' | 'h265',
direct_access_port: 21118,
rendezvous_server: '',
relay_server: '',
relay_key: '',
})
const rustdeskValidationMessage = computed(() => {
if (
rustdeskLocalConfig.value.mode === 'id' &&
!rustdeskLocalConfig.value.rendezvous_server?.trim()
) {
return t('extensions.rustdesk.rendezvousServerRequired')
}
if (
rustdeskLocalConfig.value.mode === 'direct_ip' &&
(rustdeskLocalConfig.value.direct_access_port < 1 ||
rustdeskLocalConfig.value.direct_access_port > 65535)
) {
return t('extensions.rustdesk.directAccessPortInvalid')
}
return ''
})
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 vncStatus = ref<VncStatusResponse | null>(null)
const vncLoading = ref(false)
const vncLocalConfig = ref<VncConfigUpdate & { password?: string }>({
enabled: false,
bind: '0.0.0.0',
port: 5900,
encoding: 'tight_jpeg',
allow_one_client: true,
password: '',
})
function formatHostForUrl(hostname: string): string {
if (!hostname) return '127.0.0.1'
return hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname
}
const rtspStreamUrl = computed(() => {
const host = formatHostForUrl(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}`
})
const vncStreamUrl = computed(() => {
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
const port = Number(vncLocalConfig.value.port) || 5900
return `${host}:${port}`
})
const {
rustdeskConfig,
rustdeskStatus,
rustdeskPassword,
rustdeskLoading,
rustdeskCopied,
rustdeskLocalConfig,
rustdeskValidationMessage,
rtspStatus,
rtspLoading,
rtspLocalConfig,
vncStatus,
vncLoading,
vncLocalConfig,
rtspStreamUrl,
vncStreamUrl,
serviceSaving,
loadRustdeskConfig,
loadRustdeskPassword,
saveRustdeskConfig,
regenerateRustdeskId,
regenerateRustdeskPassword,
startRustdesk,
stopRustdesk,
copyToClipboard,
loadRtspConfig,
saveRtspConfig,
startRtsp,
stopRtsp,
loadVncConfig,
saveVncConfig,
startVnc,
stopVnc,
} = useRemoteAccessSettings({ loading, showValidationError })
const webServerConfig = ref<WebConfig>({
http_port: 8080,
@@ -1512,7 +1464,6 @@ async function changePassword() {
}
}
const serviceSaving = ref<'rtsp' | 'vnc' | 'rustdesk' | null>(null)
const videoSaving = ref<'capture' | 'encoder' | 'connection' | null>(null)
async function saveVideoCard(card: 'capture' | 'encoder' | 'connection') {
if (videoSaving.value) return
@@ -1555,7 +1506,6 @@ async function saveHidSettings(scope: 'features' | 'identity' = 'features') {
if (loading.value) return
hidSaveScope.value = scope
loading.value = true
saved.value = false
saveError.value = ''
try {
@@ -1630,8 +1580,6 @@ async function saveHidSettings(scope: 'features' | 'identity' = 'features') {
if (activeSection.value !== 'hid') {
await loadSectionData(activeSection.value)
}
saved.value = true
setTimeout(() => (saved.value = false), 2000)
} catch (error) {
saveError.value = error instanceof Error ? error.message : t('api.operationFailedDesc')
} finally {
@@ -2126,51 +2074,6 @@ watch(
},
)
function applyRustdeskStatus(status: RustDeskStatusResponse) {
const config = status.config
rustdeskConfig.value = config
rustdeskStatus.value = status
rustdeskLocalConfig.value = {
enabled: config.enabled,
mode: config.mode || 'id',
codec: config.codec || 'h264',
direct_access_port: config.direct_access_port || 21118,
rendezvous_server: config.rendezvous_server,
relay_server: config.relay_server || '',
relay_key: config.relay_key || '',
}
}
async function loadRustdeskConfig() {
rustdeskLoading.value = true
try {
const status = await configStore.refreshRustdeskStatus()
applyRustdeskStatus(status)
} catch {
} finally {
rustdeskLoading.value = false
}
}
async function loadRustdeskPassword() {
try {
rustdeskPassword.value = await configStore.refreshRustdeskPassword()
} catch {}
}
function normalizeRustdeskServer(value: string, defaultPort: number): string {
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed.includes(':')) return trimmed
return `${trimmed}:${defaultPort}`
}
/** Strip line breaks from pasted keys. */
function normalizeRustdeskRelayKey(value: string): string {
const cleaned = value.replace(/\r?\n/g, '').trim()
return cleaned
}
function showValidationError(message: string): boolean {
toast.error(t('api.operationFailed'), {
description: message,
@@ -2192,25 +2095,6 @@ function validateExtensionConfig(id: ValidatedExtensionConfigId): boolean {
return !message || showValidationError(message)
}
function validateRustdeskConfig(): boolean {
return !rustdeskValidationMessage.value || showValidationError(rustdeskValidationMessage.value)
}
function validateVncConfig(enabled = vncLocalConfig.value.enabled): boolean {
const password = (vncLocalConfig.value.password || '').trim()
if (enabled && !vncStatus.value?.config.has_password && !password) {
return showValidationError(t('extensions.vnc.passwordRequired'))
}
if (password.length > 8) {
return showValidationError(t('extensions.vnc.passwordMaxLength'))
}
return true
}
function normalizeRtspPath(path: string): string {
return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
}
function normalizeBindAddresses(addresses: string[]): string[] {
return addresses.map((addr) => addr.trim()).filter(Boolean)
}
@@ -2542,252 +2426,6 @@ function updateStatusBadgeText(): string {
)
}
function rustdeskUpdatePayload(enabled = rustdeskLocalConfig.value.enabled) {
return {
enabled,
mode: rustdeskLocalConfig.value.mode,
codec: rustdeskLocalConfig.value.codec,
direct_access_port: rustdeskLocalConfig.value.direct_access_port,
rendezvous_server: normalizeRustdeskServer(rustdeskLocalConfig.value.rendezvous_server, 21116),
relay_server: normalizeRustdeskServer(rustdeskLocalConfig.value.relay_server, 21117),
relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key),
}
}
async function saveRustdeskConfig() {
if (
(rustdeskLocalConfig.value.enabled || rustdeskLocalConfig.value.mode === 'direct_ip') &&
!validateRustdeskConfig()
)
return
serviceSaving.value = 'rustdesk'
loading.value = true
saved.value = false
try {
await configStore.updateRustdesk(rustdeskUpdatePayload())
await loadRustdeskConfig()
toast.success(t('settingsUi.saveDone'))
} catch {
} finally {
loading.value = false
serviceSaving.value = null
}
}
async function regenerateRustdeskId() {
if (!confirm(t('extensions.rustdesk.confirmRegenerateId'))) return
rustdeskLoading.value = true
try {
await configStore.regenerateRustdeskId()
await loadRustdeskConfig()
await loadRustdeskPassword()
} catch {
} finally {
rustdeskLoading.value = false
}
}
async function regenerateRustdeskPassword() {
if (!confirm(t('extensions.rustdesk.confirmRegeneratePassword'))) return
rustdeskLoading.value = true
try {
await configStore.regenerateRustdeskPassword()
await loadRustdeskConfig()
await loadRustdeskPassword()
} catch {
} finally {
rustdeskLoading.value = false
}
}
async function startRustdesk() {
if (!validateRustdeskConfig()) return
rustdeskLoading.value = true
try {
await configStore.updateRustdesk(rustdeskUpdatePayload())
const status = await rustdeskConfigApi.start()
applyRustdeskStatus(status)
} catch {
} finally {
rustdeskLoading.value = false
}
}
async function stopRustdesk() {
rustdeskLoading.value = true
try {
const status = await rustdeskConfigApi.stop()
applyRustdeskStatus(status)
} catch {
} finally {
rustdeskLoading.value = false
}
}
async function copyToClipboard(text: string, type: 'id' | 'password') {
const success = await clipboardCopy(text)
if (success) {
rustdeskCopied.value = type
setTimeout(() => (rustdeskCopied.value = null), 2000)
}
}
function applyRtspStatus(status: RtspStatusResponse) {
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: status.config.password || '',
}
}
async function loadRtspConfig() {
rtspLoading.value = true
try {
const status = await configStore.refreshRtspStatus()
applyRtspStatus(status)
} catch {
} finally {
rtspLoading.value = false
}
}
function rtspUpdatePayload(enabled = !!rtspLocalConfig.value.enabled): RtspConfigUpdate {
return {
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(),
password: (rtspLocalConfig.value.password || '').trim(),
}
}
async function saveRtspConfig() {
serviceSaving.value = 'rtsp'
loading.value = true
saved.value = false
try {
await configStore.updateRtsp(rtspUpdatePayload())
await loadRtspConfig()
toast.success(t('settingsUi.saveDone'))
} catch {
} finally {
loading.value = false
serviceSaving.value = null
}
}
async function startRtsp() {
rtspLoading.value = true
try {
await configStore.updateRtsp(rtspUpdatePayload())
const status = await rtspConfigApi.start()
applyRtspStatus(status)
} catch {
} finally {
rtspLoading.value = false
}
}
async function stopRtsp() {
rtspLoading.value = true
try {
const status = await rtspConfigApi.stop()
applyRtspStatus(status)
} catch {
} finally {
rtspLoading.value = false
}
}
function applyVncStatus(status: VncStatusResponse) {
vncStatus.value = status
vncLocalConfig.value = {
enabled: status.config.enabled,
bind: status.config.bind,
port: status.config.port,
encoding: status.config.encoding,
allow_one_client: status.config.allow_one_client,
password: '',
}
}
async function loadVncConfig() {
vncLoading.value = true
try {
const status = await configStore.refreshVncStatus()
applyVncStatus(status)
} catch {
} finally {
vncLoading.value = false
}
}
function vncUpdatePayload(enabled = !!vncLocalConfig.value.enabled): VncConfigUpdate {
const update: VncConfigUpdate = {
enabled,
bind: vncLocalConfig.value.bind?.trim() || '0.0.0.0',
port: Number(vncLocalConfig.value.port) || 5900,
encoding: vncLocalConfig.value.encoding || 'tight_jpeg',
allow_one_client: !!vncLocalConfig.value.allow_one_client,
}
const password = (vncLocalConfig.value.password || '').trim()
if (password) update.password = password
return update
}
async function saveVncConfig() {
if (!validateVncConfig()) return
serviceSaving.value = 'vnc'
loading.value = true
saved.value = false
try {
await configStore.updateVnc(vncUpdatePayload())
await loadVncConfig()
toast.success(t('settingsUi.saveDone'))
} catch {
} finally {
loading.value = false
serviceSaving.value = null
}
}
async function startVnc() {
if (!validateVncConfig(true)) return
vncLoading.value = true
try {
await configStore.updateVnc(vncUpdatePayload())
const status = await vncConfigApi.start()
applyVncStatus(status)
} catch {
} finally {
vncLoading.value = false
}
}
async function stopVnc() {
vncLoading.value = true
try {
const status = await vncConfigApi.stop()
applyVncStatus(status)
} catch {
} finally {
vncLoading.value = false
}
}
onMounted(async () => {
const initialSection = normalizeSettingsSection(route.query.tab)
if (initialSection) {
@@ -5759,7 +5397,6 @@ watch(isWindows, () => {
:aria-label="t('extensions.start') + ' ' + t('extensions.rtsp.title')"
:disabled="
rtspLoading ||
rtspStatus?.service_status === 'starting' ||
rtspStatus?.service_status === 'running' ||
rtspStatus?.service_status === 'starting'
"
@@ -5927,7 +5564,6 @@ watch(isWindows, () => {
:aria-label="t('extensions.start') + ' ' + t('extensions.vnc.title')"
:disabled="
vncLoading ||
vncStatus?.service_status === 'starting' ||
vncStatus?.service_status === 'running' ||
vncStatus?.service_status === 'starting'
"