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.
 
 
 
 
 

596 lines
21 KiB

<script setup lang="ts">
usePageTitle('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)
const readingMode = ref(false)
const activeItemId = 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 || '当前订阅'
})
const activeItemIndex = computed(() => {
if (activeItemId.value === null) {
return -1
}
return filteredItems.value.findIndex((it) => it.id === activeItemId.value)
})
const activeItem = computed(() => {
if (activeItemIndex.value < 0) {
return null
}
return filteredItems.value[activeItemIndex.value] ?? null
})
const hasPrevItem = computed(() => activeItemIndex.value > 0)
const hasNextItem = computed(() => activeItemIndex.value >= 0 && activeItemIndex.value < filteredItems.value.length - 1)
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 feedLabelFor(feedId: number) {
const f = feeds.value.find((x) => x.id === feedId)
return f?.title || f?.feedUrl || '未知来源'
}
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) {
const idx = items.value.findIndex((it) => it.id === id)
if (idx < 0) {
return
}
const prev = items.value[idx].visibility
if (prev === visibility) {
return
}
// 乐观更新:避免整页重新加载导致的视觉抖动和滚动跳动
items.value[idx].visibility = visibility
try {
await fetchData(`/api/me/rss/items/${id}`, { method: 'PATCH', body: { visibility } })
toast.add({ title: '可见性已更新', color: 'success' })
} catch {
items.value[idx].visibility = prev
toast.add({ title: '更新失败,已恢复原状态', color: 'error' })
}
}
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
}
}
function openReadingMode() {
readingMode.value = true
if (!filteredItems.value.length) {
activeItemId.value = null
return
}
if (!filteredItems.value.some((it) => it.id === activeItemId.value)) {
activeItemId.value = filteredItems.value[0].id
}
}
function closeReadingMode() {
readingMode.value = false
}
function selectPrevItem() {
if (!hasPrevItem.value) {
return
}
const prev = filteredItems.value[activeItemIndex.value - 1]
if (prev) {
activeItemId.value = prev.id
}
}
function selectNextItem() {
if (!hasNextItem.value) {
return
}
const next = filteredItems.value[activeItemIndex.value + 1]
if (next) {
activeItemId.value = next.id
}
}
watch(filteredItems, (list) => {
if (!list.length) {
activeItemId.value = null
return
}
if (!list.some((it) => it.id === activeItemId.value)) {
activeItemId.value = list[0].id
}
}, { immediate: true })
</script>
<template>
<UContainer class="py-8 space-y-6 max-w-6xl">
<div class="relative overflow-hidden rounded-2xl border border-primary/30 bg-gradient-to-br from-primary/15 via-default to-elevated px-6 py-6">
<div class="pointer-events-none absolute -right-14 -top-16 h-48 w-48 rounded-full bg-primary/20 blur-3xl" />
<div class="pointer-events-none absolute -left-20 -bottom-20 h-56 w-56 rounded-full bg-primary/10 blur-3xl" />
<div class="relative space-y-4">
<div>
<p class="text-xs uppercase tracking-[0.2em] text-primary/80">
Reading Inbox
</p>
<h1 class="mt-1 text-2xl font-semibold text-highlighted sm:text-3xl">
RSS 收件箱
</h1>
<p class="mt-2 text-sm text-muted max-w-3xl">
服务器约每 {{ syncMeta.serverCheckIntervalMinutes }} 分钟检查一次到期订阅新增订阅会立即抓取一次
<template v-if="!loading && feeds.length && earliestNextSyncLabel">
当前最早下次同步时间:{{ earliestNextSyncLabel }}。
</template>
</p>
</div>
<div class="grid gap-2 sm:grid-cols-3">
<div class="rounded-xl border border-default bg-default/90 px-3 py-2">
<p class="text-xs text-muted">
订阅源
</p>
<p class="text-lg font-semibold text-highlighted">
{{ feeds.length }}
</p>
</div>
<div class="rounded-xl border border-default bg-default/90 px-3 py-2">
<p class="text-xs text-muted">
全部条目
</p>
<p class="text-lg font-semibold text-highlighted">
{{ items.length }}
</p>
</div>
<div class="rounded-xl border border-default bg-default/90 px-3 py-2">
<p class="text-xs text-muted">
当前筛选
</p>
<p class="truncate text-lg font-semibold text-highlighted" :title="selectedFeedLabel">
{{ selectedFeedLabel }}
</p>
</div>
</div>
</div>
</div>
<UCard class="border-primary/20">
<div class="flex flex-col gap-2 sm:flex-row">
<UInput v-model="feedUrl" placeholder="https://example.com/feed.xml" class="flex-1" />
<UButton icon="i-lucide-plus" @click="addFeed">
添加订阅
</UButton>
<UButton color="neutral" variant="outline" icon="i-lucide-refresh-cw" @click="syncAll">
全部同步
</UButton>
</div>
</UCard>
<div v-if="loading" class="rounded-xl border border-dashed border-default p-6 text-sm text-muted">
加载中…
</div>
<div v-else-if="readingMode" class="space-y-4">
<UCard class="border-primary/25">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<p class="text-xs text-muted">
阅读模式(信息密集)
</p>
<p class="text-sm font-medium text-highlighted">
{{ selectedFeedLabel }} · {{ filteredItems.length }} 条
</p>
</div>
<div class="flex gap-2">
<UButton size="sm" color="neutral" variant="outline" :disabled="!hasPrevItem" @click="selectPrevItem">
上一条
</UButton>
<UButton size="sm" color="neutral" variant="outline" :disabled="!hasNextItem" @click="selectNextItem">
下一条
</UButton>
<UButton size="sm" color="neutral" variant="soft" @click="closeReadingMode">
退出阅读模式
</UButton>
</div>
</div>
</UCard>
<div class="grid gap-4 lg:grid-cols-[360px_minmax(0,1fr)]">
<UCard class="border-default/80">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium">目录</span>
<UBadge size="sm" color="neutral" variant="soft">
{{ filteredItems.length }} 条
</UBadge>
</div>
</template>
<UEmpty v-if="!filteredItems.length" title="暂无条目" />
<ul v-else class="space-y-1.5 max-h-[70vh] overflow-auto pr-1">
<li v-for="it in filteredItems" :key="it.id">
<button
type="button"
class="w-full rounded-lg border px-3 py-2 text-left transition-all"
:class="activeItemId === it.id
? 'border-primary/60 bg-primary/12 ring-1 ring-primary/35'
: 'border-default hover:border-primary/30 hover:bg-elevated/55'"
@click="activeItemId = it.id"
>
<p class="line-clamp-2 text-sm font-medium text-highlighted">
{{ it.title || '未命名条目' }}
</p>
<p class="mt-1 truncate text-[11px] text-muted">
{{ feedLabelFor(it.feedId) }}
</p>
</button>
</li>
</ul>
</UCard>
<UCard class="border-default/80">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<p class="text-xs text-muted">
当前阅读
</p>
<p class="text-sm font-medium text-highlighted">
{{ activeItemIndex + 1 > 0 ? `${activeItemIndex + 1} / ${filteredItems.length}` : '未选择' }}
</p>
</div>
<UButton
v-if="activeItem"
size="sm"
color="neutral"
variant="outline"
:href="activeItem.canonicalUrl"
target="_blank"
rel="noopener noreferrer"
>
打开原文
</UButton>
</div>
</template>
<UEmpty v-if="!activeItem" title="暂无可阅读条目" />
<article v-else class="space-y-3">
<h2 class="text-xl font-semibold leading-snug text-highlighted">
{{ activeItem.title || '未命名条目' }}
</h2>
<div class="flex flex-wrap gap-2 text-xs">
<UBadge color="neutral" variant="soft">
来源:{{ feedLabelFor(activeItem.feedId) }}
</UBadge>
<UBadge color="neutral" variant="subtle">
可见性:{{ activeItem.visibility === 'private' ? '私密' : activeItem.visibility === 'public' ? '公开' : '仅链接' }}
</UBadge>
</div>
<p class="text-xs break-all text-muted">
{{ activeItem.canonicalUrl }}
</p>
<div class="rounded-lg border border-default bg-elevated/30 px-3 py-3">
<p class="text-xs leading-relaxed text-muted">
该模式聚焦连续阅读与快速切换,管理操作已收敛。若要批量调整可见性或管理订阅,请退出阅读模式。
</p>
</div>
</article>
</UCard>
</div>
</div>
<div v-else class="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
<UCard class="border-default/80 bg-default/70 backdrop-blur-sm lg:sticky lg:top-4 lg:max-h-[calc(100vh-2rem)]">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium">订阅源(仅自己可见)</span>
<UBadge size="sm" variant="subtle" color="neutral">
{{ feeds.length }} 个
</UBadge>
</div>
</template>
<UEmpty v-if="!feeds.length" title="暂无订阅" description="添加一个 feed URL 开始聚合" />
<ul v-else class="space-y-2.5 lg:max-h-[calc(100vh-10rem)] lg:overflow-auto lg:pr-1">
<li>
<button
type="button"
class="w-full rounded-xl border px-3 py-3 text-left transition-all"
:class="selectedFeedId === null
? 'border-primary/60 bg-primary/12 ring-1 ring-primary/40'
: 'border-default hover:border-primary/30 hover:bg-elevated/60'"
@click="selectedFeedId = null"
>
<p class="text-sm font-semibold text-highlighted">
全部订阅
</p>
<p class="mt-0.5 text-xs text-muted">
{{ items.length }} 条内容
</p>
</button>
</li>
<li v-for="f in feeds" :key="f.id">
<div
class="rounded-xl border p-3 transition-all"
:class="selectedFeedId === f.id
? 'border-primary/60 bg-primary/12 ring-1 ring-primary/40'
: 'border-default hover:border-primary/30 hover:bg-elevated/60'"
>
<button
type="button"
class="w-full text-left"
@click="selectedFeedId = f.id"
>
<p class="truncate text-sm font-semibold text-highlighted" :title="f.feedUrl">
{{ f.title || f.feedUrl }}
</p>
<p class="mt-0.5 text-xs text-muted">
{{ countForFeed(f.id) }} 条内容
</p>
<p class="mt-2 text-[11px] leading-snug text-muted">
{{ feedNextLine(f) }}
</p>
</button>
<p v-if="f.lastError" class="mt-2 rounded-md border border-error/40 bg-error/10 px-2 py-1 text-xs text-error">
{{ f.lastError }}
</p>
<div class="mt-2">
<UButton size="xs" color="error" variant="ghost" class="-ml-2" @click.stop="removeFeed(f.id)">
删除订阅
</UButton>
</div>
</div>
</li>
</ul>
</UCard>
<UCard class="border-default/80">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<p class="font-medium text-highlighted">
条目流
</p>
<p class="text-xs text-muted">
当前:{{ selectedFeedLabel }}({{ filteredItems.length }} 条)
</p>
</div>
<div class="flex items-center gap-2">
<UBadge size="sm" color="neutral" variant="soft">
默认私密,可随时切换
</UBadge>
<UButton
size="sm"
color="neutral"
variant="outline"
:disabled="!filteredItems.length"
@click="openReadingMode"
>
阅读模式
</UButton>
</div>
</div>
</template>
<UEmpty v-if="!items.length" title="暂无条目" description="添加订阅并点击同步" />
<UEmpty
v-else-if="!filteredItems.length"
title="该订阅下暂无条目"
description="切换左侧订阅或先执行同步"
/>
<ul v-else class="space-y-3">
<li v-for="it in filteredItems" :key="it.id">
<article class="rounded-xl border border-default bg-default/80 p-4 transition-all hover:border-primary/35 hover:shadow-sm hover:shadow-primary/10">
<a
:href="it.canonicalUrl"
target="_blank"
rel="noopener noreferrer"
class="line-clamp-2 text-base font-semibold text-highlighted transition-colors hover:text-primary"
>{{ it.title || '未命名条目' }}</a>
<div class="mt-3">
<p class="text-xs text-muted">
可见性
</p>
<div class="mt-1.5 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>
<div
v-if="it.visibility === 'unlisted'"
class="mt-3 space-y-2 rounded-lg border border-default bg-elevated/40 px-3 py-2"
>
<p class="text-xs leading-relaxed text-muted">
「仅链接」不会出现在公开主页;把下面地址发给他人即可打开本条目的摘要页(无需登录)。
</p>
<template v-if="user?.publicSlug?.trim() && it.shareToken">
<div class="flex flex-col gap-2 sm:flex-row">
<UInput
readonly
size="sm"
class="min-w-0 flex-1 font-mono text-xs"
: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>
</article>
</li>
</ul>
</UCard>
</div>
</UContainer>
</template>