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
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>
|
|
|