Files
clash-web/web/src/views/Subscriptions.vue
T

41 lines
6.2 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, 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>