41 lines
6.2 KiB
Vue
41 lines
6.2 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { NButton, NModal, NInput, NInputNumber, NSwitch, useDialog } from 'naive-ui'
|
||
import { Plus, RefreshCw, Rss, Pencil, Trash2, Clock3, ExternalLink } from '@lucide/vue'
|
||
import { api } from '../api'
|
||
import { useApp } from '../stores/app'
|
||
import { useTask } from '../composables/task'
|
||
import { subscriptionHost, date, bytes } from '../format'
|
||
import type { Subscription } from '../types'
|
||
import PageHeader from '../components/PageHeader.vue'
|
||
import EmptyState from '../components/EmptyState.vue'
|
||
const app = useApp(), dialog = useDialog()
|
||
const { busy, run } = useTask()
|
||
const items = ref<Subscription[]>([]), modal = ref(false), editing = ref('')
|
||
const form = reactive({ name: '', url: '', enabled: true, intervalHours: 6 })
|
||
const reload = async () => { items.value = await api<Subscription[]>('/subscriptions'); await app.reload() }
|
||
function open(item?: Subscription) { editing.value = item?.id || ''; Object.assign(form, { name: item?.name || '', url: item?.url || '', enabled: item?.enabled ?? true, intervalHours: item?.intervalHours || 6 }); modal.value = true }
|
||
async function save() {
|
||
const ok = await run(async () => {
|
||
const result = await api<{ id: string }>(editing.value ? `/subscriptions/${editing.value}` : '/subscriptions', editing.value ? 'PUT' : 'POST', form)
|
||
modal.value = false
|
||
await reload()
|
||
if (!editing.value) { await api(`/subscriptions/${result.id}/refresh`, 'POST', {}); await reload() }
|
||
}, editing.value ? '订阅设置已保存' : '订阅已添加并更新')
|
||
if (!ok) await reload().catch(() => {})
|
||
}
|
||
function remove(item: Subscription) { dialog.warning({ title: '删除订阅', content: `删除「${item.name}」及缓存节点?如果正在使用该订阅的节点,国外访问将停止,直到你选择其他节点。`, positiveText: '删除', negativeText: '取消', onPositiveClick: () => run(async () => { await api(`/subscriptions/${item.id}`, 'DELETE'); await reload() }, '订阅已删除') }) }
|
||
function refresh(item: Subscription) { return run(async () => { try { await api(`/subscriptions/${item.id}/refresh`, 'POST', {}) } finally { await reload() } }, '订阅已更新') }
|
||
function toggle(item: Subscription, value: boolean) { return run(async () => { await api(`/subscriptions/${item.id}`, 'PUT', { name: item.name, url: item.url, intervalHours: item.intervalHours, enabled: value }); await reload() }, '订阅状态已更新') }
|
||
function usage(raw: string) { const fields = Object.fromEntries(raw.split(';').map(x => x.trim().split('='))); const used = Number(fields.upload || 0) + Number(fields.download || 0); const total = Number(fields.total || 0); return total ? `${bytes(used)} / ${bytes(total)}` : '' }
|
||
onMounted(() => void run(reload))
|
||
</script>
|
||
<template>
|
||
<PageHeader title="订阅管理" description="集中管理订阅,让节点始终保持更新。" eyebrow="SUBSCRIPTIONS"><NButton type="primary" @click="open()"><template #icon><Plus :size="17" /></template>添加订阅</NButton></PageHeader>
|
||
<div class="section-toolbar"><span>{{ items.length }} 个订阅 <span class="muted">· Clash / Mihomo YAML</span></span><NButton text :loading="busy" @click="run(reload)"><template #icon><RefreshCw :size="15" /></template>刷新列表</NButton></div>
|
||
<section v-if="!items.length" class="panel"><EmptyState title="你的节点,从一个订阅开始" description="粘贴 Clash 订阅地址,我们会读取节点并保持定时更新。"><template #icon><Rss :size="28" /></template><NButton type="primary" @click="open()">添加第一个订阅</NButton></EmptyState></section>
|
||
<div v-else class="subscription-grid"><section v-for="item in items" :key="item.id" :class="['panel subscription-card', { disabled: !item.enabled }]"><div class="subscription-card-top"><span class="subscription-icon"><Rss :size="22" /></span><NSwitch :value="item.enabled" :disabled="busy" :aria-label="`启用 ${item.name}`" @update:value="v => toggle(item, v)" /></div><h3>{{ item.name }}</h3><p class="subscription-host"><ExternalLink :size="13" />{{ subscriptionHost(item.url) }}</p><div class="subscription-node-count">{{ item.nodeCount }}<span>个代理节点</span></div><div class="subscription-facts"><span><Clock3 :size="14" />每 {{ item.intervalHours }} 小时更新</span><span>最近更新 {{ date(item.updatedAt) }}</span><span v-if="usage(item.usage)">已用流量 {{ usage(item.usage) }}</span></div><div v-if="item.lastError" class="inline-error subscription-error">{{ item.lastError }}<small>已有节点和可用配置会保留。</small></div><div class="subscription-card-actions"><NButton size="small" :loading="busy || item.refreshing" @click="refresh(item)"><template #icon><RefreshCw :size="14" /></template>更新订阅</NButton><NButton quaternary size="small" :disabled="busy" :aria-label="`编辑 ${item.name}`" @click="open(item)"><template #icon><Pencil :size="15" /></template></NButton><NButton quaternary size="small" :disabled="busy" :aria-label="`删除 ${item.name}`" @click="remove(item)"><template #icon><Trash2 :size="15" /></template></NButton></div></section></div>
|
||
<div class="info-note"><Rss :size="17" /><p>订阅提供节点,分流和 DNS 由 Clash Web 统一管理。支持多个订阅;更新失败时继续使用最后可用版本。</p></div>
|
||
<NModal v-model:show="modal" preset="card" :title="editing ? '编辑订阅' : '添加订阅'" class="form-modal" :mask-closable="!busy"><form @submit.prevent="save"><label class="field-label">订阅名称<NInput v-model:value="form.name" placeholder="例如:我的主力订阅" :maxlength="160" /></label><label class="field-label">Clash 订阅地址<NInput v-model:value="form.url" type="textarea" placeholder="https://example.com/subscribe?token=…" :autosize="{ minRows: 2, maxRows: 4 }" /></label><label class="field-label">自动更新间隔(小时)<NInputNumber v-model:value="form.intervalHours" :min="1" :max="720" /></label><div class="form-switch-row"><span>启用此订阅</span><NSwitch v-model:value="form.enabled" /></div><div class="modal-actions"><NButton :disabled="busy" @click="modal = false">取消</NButton><NButton type="primary" :loading="busy" :disabled="!form.name.trim() || !form.url.trim()" attr-type="submit">{{ editing ? '保存设置' : '添加并更新' }}</NButton></div></form></NModal>
|
||
</template>
|