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.
295 lines
9.8 KiB
295 lines
9.8 KiB
<script setup lang="ts">
|
|
import { request, unwrapApiBody, type ApiResponse } from '../../../utils/http/factory'
|
|
|
|
definePageMeta({ title: 'RSS' })
|
|
|
|
type Feed = { id: number; feedUrl: string; title: string | null; lastError: string | null }
|
|
type Item = {
|
|
id: number
|
|
title: string | null
|
|
canonicalUrl: string
|
|
visibility: string
|
|
feedId: number
|
|
shareToken: string | null
|
|
}
|
|
|
|
const feeds = ref<Feed[]>([])
|
|
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 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 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([
|
|
request<ApiResponse<{ feeds: Feed[] }>>('/api/me/rss/feeds'),
|
|
request<ApiResponse<{ items: Item[] }>>('/api/me/rss/items'),
|
|
])
|
|
feeds.value = unwrapApiBody(f).feeds
|
|
items.value = unwrapApiBody(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 request('/api/me/rss/feeds', { method: 'POST', body: { feedUrl: feedUrl.value } })
|
|
feedUrl.value = ''
|
|
await load()
|
|
}
|
|
|
|
async function syncAll() {
|
|
await request('/api/me/rss/sync', { method: 'POST', body: {} })
|
|
await load()
|
|
}
|
|
|
|
async function removeFeed(id: number) {
|
|
await request(`/api/me/rss/feeds/${id}`, { method: 'DELETE' })
|
|
if (selectedFeedId.value === id) {
|
|
selectedFeedId.value = null
|
|
}
|
|
await load()
|
|
}
|
|
|
|
async function setItemVis(id: number, visibility: string) {
|
|
await request(`/api/me/rss/items/${id}`, { method: 'PATCH', body: { visibility } })
|
|
await load()
|
|
}
|
|
|
|
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>
|
|
|
|
<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>
|
|
</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>
|
|
|