289 lines
9.7 KiB
Vue
289 lines
9.7 KiB
Vue
<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:5666,frp 等内网转发同样填最终访问地址)或 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.com(frp 填转发后的地址)"
|
||
/>
|
||
</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> |