@ -27,6 +27,8 @@ 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 ( )
@ -48,6 +50,23 @@ const selectedFeedLabel = computed(() => {
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' } )
}
@ -71,6 +90,11 @@ 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 ) } `
}
@ -127,9 +151,25 @@ async function removeFeed(id: number) {
}
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' } )
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 ) {
@ -151,85 +191,279 @@ async function copyUnlistedLink(it: Item) {
/ / i g n o r e
}
}
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-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" >
< 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 @click ="addFeed" >
< UButton icon = "i-lucide-plus" @click ="addFeed" >
添加订阅
< / UButton >
< UButton color = "neutral" variant = "outline" @click ="syncAll" >
< UButton color = "neutral" variant = "outline" icon = "i-lucide-refresh-cw" @click ="syncAll" >
全部同步
< / UButton >
< / div >
< / UCard >
< div v-if ="loading" class="text-muted" >
< div v-if ="loading" class="rounded-xl border border-dashed border-default p-6 text-sm text-muted" >
加载中 …
< / div >
< div v -else class = "grid md:grid-cols-3 gap-6" >
< UCard class = "md:col-span-1" >
< 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 = "暂无订阅" / >
< ul v -else class = "space-y-2 text-sm" >
< 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 text-left rounded-lg border px-3 py-2 transition-colors"
class = "w-full rounded-xl border px-3 py-3 text-left transition-all "
: class = " selectedFeedId === null
? 'border-primary bg-primary/10 ring-2 ring-primary/30'
: 'border-default hover:bg-elevated/60' "
? 'border-primary/60 bg-primary/12 ring-1 ring-primary/4 0'
: 'border-default hover:border-primary/30 hover:b g-elevated/60' "
@ click = "selectedFeedId = null"
>
< div class = "font-medium" >
< p class = "text-sm font-semibold text-highlighted ">
全部订阅
< / div >
< div class = "text-xs text-muted mt-0.5" >
{ { items . length } } 条
< / div >
< / 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-lg border transition-colors"
class = "rounded-xl border p-3 transition-all "
: class = " selectedFeedId === f . id
? 'border-primary bg-primary/10 ring-2 ring-primary/30'
: 'border-default hover:bg-elevated/60' "
? 'border-primary/60 bg-primary/12 ring-1 ring-primary/4 0'
: 'border-default hover:border-primary/30 hover:b g-elevated/60' "
>
< button
type = "button"
class = "w-full text-left px-3 pt-2 pb-1"
class = "w-full text-left"
@ click = "selectedFeedId = f.id"
>
< div class = "font-medium truncate pr-1" :title ="f.feedUrl" >
< p class = "truncate text-sm font-semibold text-highlighted " :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" >
< / 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 ) } }
< / div >
< / p >
< / button >
< div v-if ="f.lastError" class="px-3 text-error text-xs" >
< 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 } }
< / div >
< div class = "px-3 pb-2" >
< / p >
< div class = "mt -2" >
< UButton size = "xs" color = "error" variant = "ghost" class = "-ml-2" @click.stop ="removeFeed(f.id)" >
删除订阅
< / UButton >
@ -238,98 +472,122 @@ async function copyUnlistedLink(it: Item) {
< / li >
< / ul >
< / UCard >
< UCard class = "md:col-span-2" >
< 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 >
< 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')"
< 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"
>
仅链接
< / 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
< 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"
color = "neutral"
variant = "outline"
: to = "unlistedSharePath(user.publicSlug.trim(), it.shareToken)"
target = "_blank"
>
打开
< / UButton >
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 >
< / div >
< / template >
< UAlert
v - else
color = "warning"
variant = "subtle"
title = "无法生成分享地址"
description = "请先在「个人资料」中设置公开主页标识(/@slug),保存后再将本条设为「仅链接」。"
/ >
< / div >
< / template >
< UAlert
v - else
color = "warning"
variant = "subtle "
title = "无法生成分享地址 "
description = "请先在「个人资料」中设置公开主页标识(/@slug),保存后再将本条设为「仅链接」。 "
/ >
< / div >
< / article >
< / li >
< / ul >
< / UCard >