Files
Thing/src/modules/music/components/settings/ConnectionsSettings.vue
T
2026-09-15 17:17:16 +08:00

289 lines
9.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { toast } from 'vue-sonner'
import { Check, CircleCheck, Loader2, LogOut, Pencil, Plus, Trash2 } from '@lucide/vue'
import { useFeiniuStore, type FeiniuConnection } from '@/stores/feiniuStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
const store = useFeiniuStore()
const editing = ref<Partial<FeiniuConnection> | null>(null)
const editOpen = ref(false)
const password = ref('')
const testing = ref(false)
const saving = ref(false)
function newForm() {
editing.value = { name: '', kind: 'lan', baseUrl: '', username: '', accessCode: '', insecure: false, fnId: '' }
password.value = ''
editOpen.value = true
}
function editForm(c: FeiniuConnection) {
// 历史数据里的 frp 等内网转发方式已并入「局域网」
editing.value = { ...c, kind: c.kind === 'fnconnect' ? 'fnconnect' : 'lan' }
password.value = ''
editOpen.value = true
}
async function save() {
if (!editing.value || saving.value) return
const draft = { ...editing.value }
if (!draft.name?.trim()) {
toast.error('请填写名称')
return
}
// 局域网需要服务器地址;FnConnect 需要飞牛 ID(服务器地址在登录时自动解析)
if (draft.kind === 'fnconnect') {
if (!draft.fnId?.trim()) {
toast.error('请填写飞牛 ID')
return
}
} else if (!draft.baseUrl?.trim()) {
toast.error('请填写服务器地址')
return
}
const pw = password.value
saving.value = true
try {
const id = await store.saveConnection({
id: draft.id || '',
name: draft.name.trim(),
kind: draft.kind || 'lan',
baseUrl: draft.baseUrl?.trim() || '',
username: draft.username || '',
accessCode: draft.accessCode || '',
insecure: !!draft.insecure,
fnId: draft.fnId?.trim() || '',
relay: !!draft.relay
})
// 先关闭对话框再登录:FnConnect 登录要先探测可达地址(可能数秒),
// 若等待其完成才关窗,观感就是「已保存但对话框卡住」。
editOpen.value = false
toast.success('已保存')
if (pw && id) {
void store
.login(id, draft.username || '', pw)
.then(() => toast.success('登录成功'))
.catch((e) => toast.error(`登录失败:${e}`))
}
} catch (e) {
toast.error(String(e))
} finally {
saving.value = false
}
}
/** 测试当前对话框里的草案(无需先保存,新建连接也可以直接测) */
async function test() {
const d = editing.value
if (!d || testing.value) return
if (!password.value) {
toast.error('请输入密码再测试')
return
}
if (d.kind === 'fnconnect') {
if (!d.fnId?.trim()) {
toast.error('请填写飞牛 ID')
return
}
} else if (!d.baseUrl?.trim()) {
toast.error('请填写服务器地址')
return
}
testing.value = true
try {
await store.testConnection(
{
id: d.id || '',
name: d.name?.trim() || '测试',
kind: d.kind || 'lan',
baseUrl: d.baseUrl?.trim() || '',
username: d.username || '',
accessCode: d.accessCode || '',
insecure: !!d.insecure,
fnId: d.fnId?.trim() || '',
relay: !!d.relay
},
d.username || '',
password.value
)
toast.success('连接成功')
} catch (e) {
toast.error(String(e))
} finally {
testing.value = false
}
}
async function activate(c: FeiniuConnection) {
await store.activateConnection(c.id)
toast.success('已切换为激活连接')
}
async function logout(c: FeiniuConnection) {
await store.logout(c.id)
toast.success('已登出')
}
async function remove(c: FeiniuConnection) {
await store.deleteConnection(c.id)
toast.success('已删除')
}
/** kind → 展示名:局域网涵盖 frp / 内网转发等直连方式,其余为 FnConnect */
function kindLabel(k: string) {
return k === 'fnconnect' ? 'FnConnect' : '局域网'
}
onMounted(() => store.refreshConnections())
</script>
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<Label class="text-muted-foreground">飞牛音乐连接</Label>
<Button variant="outline" size="sm" @click="newForm">
<Plus class="size-4" /> 新建
</Button>
</div>
<div class="flex flex-col gap-2">
<div
v-for="c in store.connections"
:key="c.id"
class="flex items-center gap-3 rounded-lg border bg-card px-3 py-2"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-medium">{{ c.name }}</span>
<Badge variant="secondary" class="text-[10px]">{{ kindLabel(c.kind) }}</Badge>
<Badge v-if="store.activeId === c.id" variant="default" class="text-[10px]">激活</Badge>
</div>
<div class="truncate text-xs text-muted-foreground">
{{ c.baseUrl }}<template v-if="c.kind === 'fnconnect' && c.relay"> · 中继</template
><template v-if="c.username"> · {{ c.username }}</template>
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
<span v-if="c.loggedIn" class="mr-1 flex items-center gap-1 text-xs text-emerald-600">
<Check class="size-3.5" /> 已登录
</span>
<Button
v-if="store.activeId !== c.id"
variant="ghost"
size="icon"
class="size-8"
:title="`设为激活连接(${c.name}`"
:aria-label="`设为激活连接 ${c.name}`"
@click="activate(c)"
>
<CircleCheck class="size-4" />
</Button>
<Button variant="ghost" size="icon" class="size-8" title="编辑连接" aria-label="编辑连接" @click="editForm(c)">
<Pencil class="size-4" />
</Button>
<Button
v-if="c.loggedIn"
variant="ghost"
size="icon"
class="size-8"
title="登出"
aria-label="登出"
@click="logout(c)"
>
<LogOut class="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-8 text-destructive"
title="删除连接"
aria-label="删除连接"
@click="remove(c)"
>
<Trash2 class="size-4" />
</Button>
</div>
</div>
<p v-if="!store.connections.length" class="text-xs text-muted-foreground">
还没有连接新建一个并填写 NAS 地址局域网 http://192.168.x.x:5666frp 等内网转发同样填最终访问地址 FnConnect fnId
</p>
</div>
<Dialog v-model:open="editOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ editing?.id ? '编辑连接' : '新建连接' }}</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-3">
<div class="space-y-1.5">
<Label>名称</Label>
<Input v-model="editing!.name" placeholder="如:家里 NAS / FnConnect 远程" />
</div>
<div class="space-y-1.5">
<Label>类型</Label>
<div class="flex gap-2">
<Button
v-for="k in (['lan', 'fnconnect'] as const)"
:key="k"
type="button"
size="sm"
:variant="(editing!.kind ?? 'lan') === k ? 'default' : 'outline'"
@click="editing!.kind = k"
>
{{ kindLabel(k) }}
</Button>
</div>
</div>
<div v-if="editing!.kind === 'fnconnect'" class="space-y-1.5">
<Label>飞牛 ID</Label>
<Input v-model="editing!.fnId" placeholder="请输入飞牛 ID,如 abc123" />
<p class="text-xs text-muted-foreground">
fnos.net/ 后面的那段也可直接粘贴 fnos.net/abc123)。
保存后点登录会自动解析到可达地址内网 / 公网 / 中继),服务器地址会回填
</p>
</div>
<div v-else class="space-y-1.5">
<Label>服务器地址</Label>
<Input
v-model="editing!.baseUrl"
placeholder="http://192.168.1.10:5666 或 https://xxx.xxx.comfrp 填转发后的地址)"
/>
</div>
<div class="space-y-1.5">
<Label>账号</Label>
<Input v-model="editing!.username" placeholder="飞牛音乐账号" />
</div>
<div class="space-y-1.5">
<Label>密码{{ password ? '' : '(留空则保留已存 token' }}</Label>
<Input v-model="password" type="password" placeholder="登录密码" />
</div>
<div class="flex items-center justify-between">
<Label class="text-muted-foreground">忽略 HTTPS 证书校验自签证书时勾选</Label>
<Switch v-model:model-value="editing!.insecure" />
</div>
</div>
<DialogFooter class="gap-2">
<Button variant="outline" :disabled="testing || saving" @click="test">
<Loader2 v-if="testing" class="size-4 animate-spin" /> 测试
</Button>
<Button :disabled="saving" @click="save">
<Loader2 v-if="saving" class="size-4 animate-spin" /> 保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>