Browse Source

feat(rss): enhance RSS feed management with sync metadata and improved next sync display

main
npmrun 8 hours ago
parent
commit
177cbe837d
  1. 43
      app/pages/me/rss/index.vue
  2. BIN
      packages/drizzle-pkg/db.sqlite
  3. 8
      server/api/me/rss/feeds.get.ts
  4. 14
      server/api/me/rss/feeds.post.ts
  5. 40
      server/service/rss/index.ts

43
app/pages/me/rss/index.vue

@ -1,7 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({ title: 'RSS' }) definePageMeta({ title: 'RSS' })
type Feed = { id: number; feedUrl: string; title: string | null; lastError: string | null } 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 = { type Item = {
id: number id: number
title: string | null title: string | null
@ -12,6 +21,7 @@ type Item = {
} }
const feeds = ref<Feed[]>([]) const feeds = ref<Feed[]>([])
const syncMeta = ref<RssSyncMeta>({ serverCheckIntervalMinutes: 60 })
const items = ref<Item[]>([]) const items = ref<Item[]>([])
const feedUrl = ref('') const feedUrl = ref('')
const loading = ref(true) const loading = ref(true)
@ -38,6 +48,25 @@ const selectedFeedLabel = computed(() => {
return f?.title || f?.feedUrl || '当前订阅' 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) { function countForFeed(feedId: number) {
return items.value.filter((it) => it.feedId === feedId).length return items.value.filter((it) => it.feedId === feedId).length
} }
@ -57,9 +86,10 @@ async function load() {
loading.value = true loading.value = true
try { try {
const [f, i] = await Promise.all([ const [f, i] = await Promise.all([
fetchData<{ feeds: Feed[] }>('/api/me/rss/feeds'), fetchData<{ feeds: Feed[]; sync: RssSyncMeta }>('/api/me/rss/feeds'),
fetchData<{ items: Item[] }>('/api/me/rss/items'), fetchData<{ items: Item[] }>('/api/me/rss/items'),
]) ])
syncMeta.value = f.sync
feeds.value = f.feeds feeds.value = f.feeds
items.value = i.items items.value = i.items
if (selectedFeedId.value !== null && !feeds.value.some((x) => x.id === selectedFeedId.value)) { if (selectedFeedId.value !== null && !feeds.value.some((x) => x.id === selectedFeedId.value)) {
@ -128,6 +158,12 @@ async function copyUnlistedLink(it: Item) {
<h1 class="text-2xl font-semibold"> <h1 class="text-2xl font-semibold">
RSS 收件箱 RSS 收件箱
</h1> </h1>
<p v-if="!loading" class="text-sm text-muted -mt-4">
服务器约每 {{ syncMeta.serverCheckIntervalMinutes }} 分钟检查一次到期的订阅添加新订阅后会立即抓取一次
<template v-if="feeds.length && earliestNextSyncLabel">
当前全部订阅中预计最早下次同步时间为 {{ earliestNextSyncLabel }}按各源上次抓取时间推算仅供参考
</template>
</p>
<UCard> <UCard>
<div class="flex flex-col sm:flex-row gap-2"> <div class="flex flex-col sm:flex-row gap-2">
@ -186,6 +222,9 @@ async function copyUnlistedLink(it: Item) {
<div class="text-xs text-muted mt-0.5"> <div class="text-xs text-muted mt-0.5">
{{ countForFeed(f.id) }} {{ countForFeed(f.id) }}
</div> </div>
<div class="text-[11px] text-muted mt-1 leading-snug">
{{ feedNextLine(f) }}
</div>
</button> </button>
<div v-if="f.lastError" class="px-3 text-error text-xs"> <div v-if="f.lastError" class="px-3 text-error text-xs">
{{ f.lastError }} {{ f.lastError }}

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

8
server/api/me/rss/feeds.get.ts

@ -1,7 +1,9 @@
import { listFeedsForUser } from "#server/service/rss"; import { listFeedsForUser, meRssInboxSyncMeta, toMeRssFeedDto } from "#server/service/rss";
export default defineWrappedResponseHandler(async (event) => { export default defineWrappedResponseHandler(async (event) => {
const user = await event.context.auth.requireUser(); const user = await event.context.auth.requireUser();
const feeds = await listFeedsForUser(user.id); const rows = await listFeedsForUser(user.id);
return R.success({ feeds }); const feeds = rows.map(toMeRssFeedDto);
const sync = meRssInboxSyncMeta();
return R.success({ feeds, sync });
}); });

14
server/api/me/rss/feeds.post.ts

@ -1,12 +1,20 @@
import { addFeed } from "#server/service/rss"; import { addFeed, getFeedForUser, syncFeed, toMeRssFeedDto } from "#server/service/rss";
import { RssUrlUnsafeError } from "#server/utils/rss-url"; import { RssUrlUnsafeError } from "#server/utils/rss-url";
export default defineWrappedResponseHandler(async (event) => { export default defineWrappedResponseHandler(async (event) => {
const user = await event.context.auth.requireUser(); const user = await event.context.auth.requireUser();
const body = await readBody<{ feedUrl: string }>(event); const body = await readBody<{ feedUrl: string }>(event);
try { try {
const feed = await addFeed(user.id, body.feedUrl); const created = await addFeed(user.id, body.feedUrl);
return R.success({ feed }); if (!created) {
throw createError({ statusCode: 500, statusMessage: "订阅创建失败" });
}
await syncFeed(created.id);
const row = await getFeedForUser(user.id, created.id);
if (!row) {
throw createError({ statusCode: 500, statusMessage: "订阅创建后读取失败" });
}
return R.success({ feed: toMeRssFeedDto(row) });
} catch (e) { } catch (e) {
if (e instanceof RssUrlUnsafeError) { if (e instanceof RssUrlUnsafeError) {
throw createError({ statusCode: 400, statusMessage: e.message }); throw createError({ statusCode: 400, statusMessage: e.message });

40
server/service/rss/index.ts

@ -14,6 +14,46 @@ const DEFAULT_POLL_MINUTES = Number(process.env.RSS_SYNC_INTERVAL_MINUTES ?? 60)
const FETCH_TIMEOUT_MS = Number(process.env.RSS_FETCH_TIMEOUT_MS ?? 15_000); const FETCH_TIMEOUT_MS = Number(process.env.RSS_FETCH_TIMEOUT_MS ?? 15_000);
const MAX_BODY_BYTES = Number(process.env.RSS_MAX_RESPONSE_BYTES ?? 2_000_000); const MAX_BODY_BYTES = Number(process.env.RSS_MAX_RESPONSE_BYTES ?? 2_000_000);
export type MeRssFeedDto = {
id: number;
feedUrl: string;
title: string | null;
siteUrl: string | null;
lastError: string | null;
lastFetchedAt: string | null;
pollIntervalMinutes: number;
nextSyncAt: string | null;
};
export function toMeRssFeedDto(feed: (typeof rssFeeds.$inferSelect)): MeRssFeedDto {
const poll = feed.pollIntervalMinutes ?? DEFAULT_POLL_MINUTES;
const last = feed.lastFetchedAt;
const nextSyncAt = last ? new Date(last.getTime() + poll * 60_000).toISOString() : null;
return {
id: feed.id,
feedUrl: feed.feedUrl,
title: feed.title,
siteUrl: feed.siteUrl,
lastError: feed.lastError,
lastFetchedAt: last?.toISOString() ?? null,
pollIntervalMinutes: poll,
nextSyncAt,
};
}
export function meRssInboxSyncMeta() {
return { serverCheckIntervalMinutes: DEFAULT_POLL_MINUTES };
}
export async function getFeedForUser(userId: number, feedId: number) {
const [row] = await dbGlobal
.select()
.from(rssFeeds)
.where(and(eq(rssFeeds.id, feedId), eq(rssFeeds.userId, userId)))
.limit(1);
return row ?? null;
}
export async function listFeedsForUser(userId: number) { export async function listFeedsForUser(userId: number) {
return dbGlobal.select().from(rssFeeds).where(eq(rssFeeds.userId, userId)).orderBy(desc(rssFeeds.id)); return dbGlobal.select().from(rssFeeds).where(eq(rssFeeds.userId, userId)).orderBy(desc(rssFeeds.id));
} }

Loading…
Cancel
Save