You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

338 lines
11 KiB

<script setup lang="ts">
definePageMeta({ title: 'RSS' })
type Feed = {
id: number
feedUrl: string
title: string | null
lastError: string | null
lastFetchedAt: string | null
pollIntervalMinutes: number
nextSyncAt: string | null
}
type RssSyncMeta = { serverCheckIntervalMinutes: number }
type Item = {
id: number
title: string | null
canonicalUrl: string
visibility: string
feedId: number
shareToken: string | null
}
const feeds = ref<Feed[]>([])
const syncMeta = ref<RssSyncMeta>({ serverCheckIntervalMinutes: 60 })
const items = ref<Item[]>([])
const feedUrl = ref('')
const loading = ref(true)
const selectedFeedId = ref<number | null>(null)
const copiedItemId = ref<number | null>(null)
let copyResetTimer: ReturnType<typeof setTimeout> | undefined
const { user, refresh } = useAuthSession()
const { fetchData } = useClientApi()
const toast = useToast()
const filteredItems = computed(() => {
if (selectedFeedId.value === null) {
return items.value
}
return items.value.filter((it) => it.feedId === selectedFeedId.value)
})
const selectedFeedLabel = computed(() => {
if (selectedFeedId.value === null) {
return '全部订阅'
}
const f = feeds.value.find((x) => x.id === selectedFeedId.value)
return f?.title || f?.feedUrl || '当前订阅'
})
function formatNextSync(iso: string) {
return new Date(iso).toLocaleString('zh-CN', { dateStyle: 'short', timeStyle: 'short' })
}
const earliestNextSyncLabel = computed(() => {
const dates = feeds.value.map((f) => f.nextSyncAt).filter(Boolean) as string[]
if (!dates.length) {
return null
}
return formatNextSync(dates.reduce((a, b) => (a < b ? a : b)))
})
function feedNextLine(f: Feed) {
if (!f.nextSyncAt) {
return '尚未完成首次抓取,后台将自动重试'
}
return `预计下次同步:${formatNextSync(f.nextSyncAt)}`
}
function countForFeed(feedId: number) {
return items.value.filter((it) => it.feedId === feedId).length
}
function unlistedSharePath(publicSlug: string, token: string) {
return `/p/${encodeURIComponent(publicSlug)}/t/${encodeURIComponent(token)}`
}
function unlistedShareFullUrl(publicSlug: string, token: string) {
if (import.meta.client) {
return `${window.location.origin}${unlistedSharePath(publicSlug, token)}`
}
return unlistedSharePath(publicSlug, token)
}
async function load() {
loading.value = true
try {
const [f, i] = await Promise.all([
fetchData<{ feeds: Feed[]; sync: RssSyncMeta }>('/api/me/rss/feeds'),
fetchData<{ items: Item[] }>('/api/me/rss/items'),
])
syncMeta.value = f.sync
feeds.value = f.feeds
items.value = i.items
if (selectedFeedId.value !== null && !feeds.value.some((x) => x.id === selectedFeedId.value)) {
selectedFeedId.value = null
}
} finally {
loading.value = false
}
}
onMounted(async () => {
await Promise.all([refresh(), load()])
})
async function addFeed() {
await fetchData('/api/me/rss/feeds', { method: 'POST', body: { feedUrl: feedUrl.value } })
feedUrl.value = ''
await load()
toast.add({ title: '已添加订阅', color: 'success' })
}
async function syncAll() {
await fetchData('/api/me/rss/sync', { method: 'POST', body: {} })
await load()
toast.add({ title: '同步完成', color: 'success' })
}
async function removeFeed(id: number) {
await fetchData(`/api/me/rss/feeds/${id}`, { method: 'DELETE' })
if (selectedFeedId.value === id) {
selectedFeedId.value = null
}
await load()
toast.add({ title: '已删除订阅', color: 'success' })
}
async function setItemVis(id: number, visibility: string) {
await fetchData(`/api/me/rss/items/${id}`, { method: 'PATCH', body: { visibility } })
await load()
toast.add({ title: '可见性已更新', color: 'success' })
}
async function copyUnlistedLink(it: Item) {
const slug = user.value?.publicSlug?.trim()
if (!slug || !it.shareToken) {
return
}
const text = unlistedShareFullUrl(slug, it.shareToken)
try {
await navigator.clipboard.writeText(text)
copiedItemId.value = it.id
if (copyResetTimer) {
clearTimeout(copyResetTimer)
}
copyResetTimer = setTimeout(() => {
copiedItemId.value = null
}, 2000)
} catch {
// ignore
}
}
</script>
<template>
<UContainer class="py-8 space-y-8 max-w-4xl">
<h1 class="text-2xl font-semibold">
RSS 收件箱
</h1>
<p v-if="!loading" class="text-sm text-muted -mt-4">
服务器约每 {{ syncMeta.serverCheckIntervalMinutes }} 分钟检查一次到期的订阅;添加新订阅后会立即抓取一次。
<template v-if="feeds.length && earliestNextSyncLabel">
当前全部订阅中,预计最早下次同步时间为 {{ earliestNextSyncLabel }}(按各源上次抓取时间推算,仅供参考)。
</template>
</p>
<UCard>
<div class="flex flex-col sm:flex-row gap-2">
<UInput v-model="feedUrl" placeholder="https://example.com/feed.xml" class="flex-1" />
<UButton @click="addFeed">
添加订阅
</UButton>
<UButton color="neutral" variant="outline" @click="syncAll">
全部同步
</UButton>
</div>
</UCard>
<div v-if="loading" class="text-muted">
加载中…
</div>
<div v-else class="grid md:grid-cols-3 gap-6">
<UCard class="md:col-span-1">
<template #header>
订阅源(仅自己可见)
</template>
<UEmpty v-if="!feeds.length" title="暂无订阅" />
<ul v-else class="space-y-2 text-sm">
<li>
<button
type="button"
class="w-full text-left rounded-lg border px-3 py-2 transition-colors"
:class="selectedFeedId === null
? 'border-primary bg-primary/10 ring-2 ring-primary/30'
: 'border-default hover:bg-elevated/60'"
@click="selectedFeedId = null"
>
<div class="font-medium">
全部订阅
</div>
<div class="text-xs text-muted mt-0.5">
{{ items.length }} 条
</div>
</button>
</li>
<li v-for="f in feeds" :key="f.id">
<div
class="rounded-lg border transition-colors"
:class="selectedFeedId === f.id
? 'border-primary bg-primary/10 ring-2 ring-primary/30'
: 'border-default hover:bg-elevated/60'"
>
<button
type="button"
class="w-full text-left px-3 pt-2 pb-1"
@click="selectedFeedId = f.id"
>
<div class="font-medium truncate pr-1" :title="f.feedUrl">
{{ f.title || f.feedUrl }}
</div>
<div class="text-xs text-muted mt-0.5">
{{ countForFeed(f.id) }} 条
</div>
<div class="text-[11px] text-muted mt-1 leading-snug">
{{ feedNextLine(f) }}
</div>
</button>
<div v-if="f.lastError" class="px-3 text-error text-xs">
{{ f.lastError }}
</div>
<div class="px-3 pb-2">
<UButton size="xs" color="error" variant="ghost" class="-ml-2" @click.stop="removeFeed(f.id)">
删除订阅
</UButton>
</div>
</div>
</li>
</ul>
</UCard>
<UCard class="md:col-span-2">
<template #header>
条目
</template>
<p class="text-xs text-muted mb-2 -mt-1">
同步入库的新条目默认为「私密」。只有设为「公开」的条目会出现在你的公开主页「阅读」区块。
</p>
<p class="text-xs font-medium text-highlighted mb-3">
当前:{{ selectedFeedLabel }}({{ filteredItems.length }} 条)
</p>
<UEmpty v-if="!items.length" title="暂无条目" description="添加订阅并点击同步" />
<UEmpty
v-else-if="!filteredItems.length"
title="该订阅下暂无条目"
description="切换左侧订阅或先执行同步"
/>
<ul v-else class="space-y-3 text-sm">
<li v-for="it in filteredItems" :key="it.id" class="border border-default rounded-lg p-3">
<a
:href="it.canonicalUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary font-medium hover:underline"
>{{ it.title || '未命名' }}</a>
<div class="mt-2 text-xs text-muted">
谁能看到
</div>
<div class="mt-1 flex flex-wrap gap-1.5">
<UButton
size="xs"
:color="it.visibility === 'private' ? 'primary' : 'neutral'"
:variant="it.visibility === 'private' ? 'solid' : 'outline'"
@click="setItemVis(it.id, 'private')"
>
私密
</UButton>
<UButton
size="xs"
:color="it.visibility === 'public' ? 'primary' : 'neutral'"
:variant="it.visibility === 'public' ? 'solid' : 'outline'"
@click="setItemVis(it.id, 'public')"
>
公开
</UButton>
<UButton
size="xs"
:color="it.visibility === 'unlisted' ? 'primary' : 'neutral'"
:variant="it.visibility === 'unlisted' ? 'solid' : 'outline'"
@click="setItemVis(it.id, 'unlisted')"
>
仅链接
</UButton>
</div>
<div
v-if="it.visibility === 'unlisted'"
class="mt-3 rounded-md border border-default bg-elevated/40 px-3 py-2 space-y-2"
>
<p class="text-xs text-muted leading-relaxed">
「仅链接」不会出现在公开主页;把下面地址发给他人即可打开本条目的摘要页(无需登录)。
</p>
<template v-if="user?.publicSlug?.trim() && it.shareToken">
<div class="flex flex-col sm:flex-row gap-2">
<UInput
readonly
size="sm"
class="font-mono text-xs flex-1 min-w-0"
:model-value="unlistedShareFullUrl(user.publicSlug.trim(), it.shareToken)"
/>
<div class="flex shrink-0 gap-1.5">
<UButton size="sm" @click="copyUnlistedLink(it)">
{{ copiedItemId === it.id ? '已复制' : '复制链接' }}
</UButton>
<UButton
size="sm"
color="neutral"
variant="outline"
:to="unlistedSharePath(user.publicSlug.trim(), it.shareToken)"
target="_blank"
>
打开
</UButton>
</div>
</div>
</template>
<UAlert
v-else
color="warning"
variant="subtle"
title="无法生成分享地址"
description="请先在「个人资料」中设置公开主页标识(/@slug),保存后再将本条设为「仅链接」。"
/>
</div>
</li>
</ul>
</UCard>
</div>
</UContainer>
</template>