Browse Source

feat(media, rss, timeline): enhance UI and functionality with new features

- Added card density options (compact and detailed) in the media page for improved layout flexibility.
- Introduced reading mode in the RSS page, allowing users to navigate through items with next/previous controls.
- Implemented visibility filtering in the timeline, enabling users to filter events based on visibility settings.
- Enhanced the event creation form with improved validation and quick submission functionality.

These updates improve user experience by providing more control over content display and interaction.
main
npmrun 2 months ago
parent
commit
8cdc6a6235
  1. 61
      app/pages/me/media/index.vue
  2. 512
      app/pages/me/rss/index.vue
  3. 338
      app/pages/me/timeline/index.vue
  4. BIN
      packages/drizzle-pkg/db.sqlite

61
app/pages/me/media/index.vue

@ -42,6 +42,7 @@ function resolveCopyOrigin(): string {
const page = ref(1)
const pageSize = ref(20)
const cardDensity = ref<'compact' | 'detailed'>('compact')
const items = ref<MediaAssetRow[]>([])
const total = ref(0)
const loading = ref(true)
@ -90,6 +91,8 @@ const pageSizeItems = [
{ label: '每页 50', value: 50 },
]
const isCompact = computed(() => cardDensity.value === 'compact')
function formatBytes(n: number): string {
if (n < 1024) {
return `${n} B`
@ -420,22 +423,48 @@ async function copyMarkdown(item: MediaAssetRow) {
<UFormField label="每页" class="w-40 shrink-0">
<USelect v-model="pageSize" :items="pageSizeItems" value-key="value" class="w-full" />
</UFormField>
<UFormField label="视图密度" class="w-48 shrink-0">
<div class="flex w-full rounded-md border border-default p-1">
<UButton
size="xs"
class="flex-1 justify-center"
:variant="isCompact ? 'solid' : 'ghost'"
:color="isCompact ? 'primary' : 'neutral'"
@click="cardDensity = 'compact'"
>
简洁
</UButton>
<UButton
size="xs"
class="flex-1 justify-center"
:variant="!isCompact ? 'solid' : 'ghost'"
:color="!isCompact ? 'primary' : 'neutral'"
@click="cardDensity = 'detailed'"
>
复杂
</UButton>
</div>
</UFormField>
</div>
<div class="min-h-[22rem] sm:min-h-[26rem]">
<div
v-if="loading"
class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
:class="
isCompact
? 'grid gap-3 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5'
: 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
"
aria-busy="true"
aria-label="加载中"
>
<div
v-for="n in skeletonPlaceholders"
:key="n"
class="rounded-lg border border-default bg-default p-4 space-y-3 shadow-sm"
class="rounded-lg border border-default bg-default p-3 space-y-2.5 shadow-sm"
>
<div class="relative w-full aspect-video rounded-md bg-elevated/80 animate-pulse" />
<div class="h-3 w-[85%] max-w-[14rem] rounded bg-elevated/80 animate-pulse" />
<div class="relative w-full aspect-[4/3] rounded-md bg-elevated/80 animate-pulse" />
<div class="h-3 w-[85%] max-w-[12rem] rounded bg-elevated/80 animate-pulse" />
<div class="flex gap-2">
<div class="h-3 w-16 rounded bg-elevated/60 animate-pulse" />
<div class="h-3 w-24 rounded bg-elevated/60 animate-pulse" />
@ -456,12 +485,16 @@ async function copyMarkdown(item: MediaAssetRow) {
/>
<div
v-else
class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
:class="{ 'opacity-60 pointer-events-none': listBusy }"
:class="[
isCompact
? 'grid gap-3 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5'
: 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3',
{ 'opacity-60 pointer-events-none': listBusy },
]"
>
<UCard v-for="item in items" :key="item.id">
<div class="space-y-3">
<div class="relative w-full aspect-video rounded-md border border-default overflow-hidden bg-elevated/30">
<UCard v-for="item in items" :key="item.id" class="p-0">
<div class="space-y-2.5 p-3">
<div class="relative w-full aspect-[4/3] rounded-md border border-default overflow-hidden bg-elevated/30">
<img
:src="imageSrc(item)"
:alt="item.storageKey"
@ -493,14 +526,14 @@ async function copyMarkdown(item: MediaAssetRow) {
>
{{ item.storageKey }}
</div>
<div class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted tabular-nums">
<div class="flex flex-wrap gap-x-2.5 gap-y-1 text-[11px] text-muted tabular-nums">
<span>{{ formatBytes(item.sizeBytes) }}</span>
<span>{{ formatDt(item.createdAt) }}</span>
</div>
<p class="text-xs text-default">
<p class="text-[11px] text-default">
引用数: {{ item.refCount }}
</p>
<div v-if="item.refContexts.length" class="text-xs space-y-1">
<div v-if="!isCompact && item.refContexts.length" class="text-xs space-y-1">
<UButton
size="xs"
variant="link"
@ -523,7 +556,7 @@ async function copyMarkdown(item: MediaAssetRow) {
</div>
</div>
</div>
<div class="text-xs space-y-1">
<div v-if="!isCompact" class="text-xs space-y-1">
<div class="text-muted">
文件描述
</div>
@ -578,7 +611,7 @@ async function copyMarkdown(item: MediaAssetRow) {
</span>
</div>
<p
v-if="!item.canDelete && item.deleteBlockedReason"
v-if="!isCompact && !item.canDelete && item.deleteBlockedReason"
class="text-[11px] text-muted leading-snug"
>
{{ deleteDisabledHint(item) }}

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

@ -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) {
// 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-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/40'
: 'border-default hover:border-primary/30 hover:bg-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/40'
: 'border-default hover:border-primary/30 hover:bg-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>

338
app/pages/me/timeline/index.vue

@ -28,7 +28,8 @@ const publicHomeAlertDescription = computed(() => {
return '个人公开主页只展示「公开」事件。新建默认是「私密」。「仅链接」条目会在列表中显示分享链接,但需要先在「资料」中设置 public slug。'
})
type Ev = { id: number; title: string; occurredOn: string; visibility: string; shareToken?: string | null }
type Visibility = 'private' | 'public' | 'unlisted'
type Ev = { id: number; title: string; occurredOn: string; visibility: Visibility; shareToken?: string | null }
function timelineUnlistedShareUrl(e: Ev): string {
if (e.visibility !== 'unlisted' || !e.shareToken?.trim()) {
@ -68,8 +69,16 @@ const loading = ref(true)
const submitLoading = ref(false)
const deletingId = ref<number | null>(null)
const updatingVisibilityId = ref<number | null>(null)
const searchQuery = ref('')
const visibilityFilter = ref<'all' | Visibility>('all')
const form = reactive({
const form = reactive<{
occurredOn: string
title: string
bodyMarkdown: string
linkUrl: string
visibility: Visibility
}>({
occurredOn: occurredOnToLocalInputValue(new Date()),
title: '',
bodyMarkdown: '',
@ -77,6 +86,30 @@ const form = reactive({
visibility: 'private',
})
const visibilityFilterItems = [
{ label: '全部可见性', value: 'all' },
{ label: '仅私密', value: 'private' },
{ label: '仅公开', value: 'public' },
{ label: '仅链接', value: 'unlisted' },
] as const
const canSubmit = computed(() => {
return Boolean(form.title.trim() && form.occurredOn)
})
const filteredEvents = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase()
return events.value.filter((event) => {
if (visibilityFilter.value !== 'all' && event.visibility !== visibilityFilter.value) {
return false
}
if (keyword && !event.title.toLowerCase().includes(keyword)) {
return false
}
return true
})
})
async function load() {
loading.value = true
try {
@ -90,7 +123,7 @@ async function load() {
onMounted(load)
async function add() {
if (!form.title.trim()) {
if (!canSubmit.value) {
return
}
let occurredOnIso: string
@ -122,6 +155,13 @@ async function add() {
}
}
async function quickSubmitFromTitle() {
if (submitLoading.value || !canSubmit.value) {
return
}
await add()
}
async function removeEvent(e: Ev) {
if (!confirm(`确定删除「${e.title}」?此操作不可恢复。`)) {
return
@ -156,7 +196,7 @@ function deleteDisabled(e: Ev) {
return false
}
async function updateVisibility(e: Ev, visibility: string) {
async function updateVisibility(e: Ev, visibility: Visibility) {
if (visibility === e.visibility) {
return
}
@ -175,7 +215,7 @@ async function updateVisibility(e: Ev, visibility: string) {
</script>
<template>
<UContainer class="max-w-3xl py-8">
<UContainer class="max-w-6xl py-8">
<div class="space-y-6">
<header>
<h1 class="text-2xl font-semibold text-highlighted">
@ -185,146 +225,178 @@ async function updateVisibility(e: Ev, visibility: string) {
发生时间精确到秒展示按你本机时区格式化
</p>
</header>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[22rem_minmax(0,1fr)] xl:grid-cols-[24rem_minmax(0,1fr)]">
<aside class="space-y-4 lg:sticky lg:top-6 lg:self-start">
<UAlert
color="neutral"
variant="subtle"
icon="i-lucide-info"
title="个人主页如何展示时光机"
:description="publicHomeAlertDescription"
/>
<UAlert
color="neutral"
variant="subtle"
icon="i-lucide-info"
title="个人主页如何展示时光机"
:description="publicHomeAlertDescription"
/>
<UCard class="ring-1 ring-default/60">
<template #header>
<div class="flex items-center justify-between gap-3">
<span class="font-medium text-highlighted">新建事件</span>
<span class="text-xs text-muted"> Enter 可快速添加</span>
</div>
</template>
<div class="space-y-5">
<UFormField label="发生时间" name="occurredOn" required>
<input
v-model="form.occurredOn"
type="datetime-local"
step="1"
required
class="w-full rounded-md border border-default bg-default px-3 py-2 text-sm text-highlighted shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
</UFormField>
<UCard class="ring-1 ring-default/60">
<template #header>
<span class="font-medium text-highlighted">新建事件</span>
</template>
<div class="space-y-5">
<UFormField label="发生时间" name="occurredOn" required>
<input
v-model="form.occurredOn"
type="datetime-local"
step="1"
required
class="w-full rounded-md border border-default bg-default px-3 py-2 text-sm text-highlighted shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
</UFormField>
<UFormField label="标题" name="title" required class="min-w-0">
<UInput
v-model="form.title"
placeholder="简短标题"
class="w-full"
autofocus
@keydown.enter.prevent="quickSubmitFromTitle"
/>
</UFormField>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<UFormField label="标题" name="title" required class="min-w-0">
<UInput v-model="form.title" placeholder="简短标题" class="w-full" />
</UFormField>
<UFormField label="链接(可选)" name="linkUrl" class="min-w-0">
<UInput v-model="form.linkUrl" placeholder="https://…" class="w-full" />
</UFormField>
</div>
<UFormField label="链接(可选)" name="linkUrl" class="min-w-0">
<UInput v-model="form.linkUrl" placeholder="https://…" class="w-full" />
</UFormField>
<UFormField label="正文(可选)" name="bodyMarkdown">
<UTextarea v-model="form.bodyMarkdown" placeholder="补充说明" :rows="4" class="w-full" />
</UFormField>
<UFormField label="正文(可选)" name="bodyMarkdown">
<UTextarea v-model="form.bodyMarkdown" placeholder="补充说明" :rows="4" class="w-full" />
</UFormField>
<div class="flex flex-col gap-3 border-t border-default pt-5 sm:flex-row sm:items-end sm:justify-between">
<UFormField label="可见性" name="visibility" class="w-full sm:max-w-xs">
<div class="flex flex-col gap-3 border-t border-default pt-5">
<UFormField label="可见性" name="visibility" class="w-full">
<USelect
v-model="form.visibility"
class="w-full"
:items="[...visibilitySelectItems]"
/>
</UFormField>
<UButton
block
:loading="submitLoading"
:disabled="!canSubmit"
@click="add"
>
添加
</UButton>
</div>
</div>
</UCard>
</aside>
<section aria-labelledby="timeline-list-heading" class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 id="timeline-list-heading" class="text-sm font-medium text-muted">
我的记录{{ filteredEvents.length }}/{{ events.length }}
</h2>
<div class="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
placeholder="搜索标题"
class="w-full sm:w-56"
/>
<USelect
v-model="form.visibility"
class="w-full"
:items="[...visibilitySelectItems]"
v-model="visibilityFilter"
:items="[...visibilityFilterItems]"
class="w-full sm:w-44"
/>
</UFormField>
<UButton
class="shrink-0 sm:self-end"
:loading="submitLoading"
@click="add"
>
添加
</UButton>
</div>
</div>
</div>
</UCard>
<section aria-labelledby="timeline-list-heading" class="space-y-3">
<h2 id="timeline-list-heading" class="text-sm font-medium text-muted">
我的记录
</h2>
<div v-if="loading" class="text-muted">
加载中
</div>
<UEmpty
v-else-if="!events.length"
title="还没有事件"
description="在上方填写发生时间与标题,添加第一条时光机记录。"
/>
<ul v-else class="relative space-y-0">
<li
v-for="(e, idx) in events"
:key="e.id"
class="relative flex gap-3 pb-6 pl-0.5 last:pb-0 sm:gap-4"
>
<div
v-if="idx < events.length - 1"
class="absolute left-[11px] top-6 bottom-0 w-px bg-default"
aria-hidden="true"
/>
<div class="relative z-[1] flex shrink-0 flex-col items-center pt-1">
<span class="size-3 rounded-full border-2 border-primary bg-default ring-4 ring-primary/15" />
</div>
<article
class="min-w-0 flex-1 rounded-xl border border-default bg-gradient-to-br from-elevated/90 to-default p-4 shadow-sm transition-colors hover:border-primary/25"
<div v-if="loading" class="text-muted">
加载中
</div>
<UEmpty
v-else-if="!events.length"
title="还没有事件"
description="在左侧填写发生时间与标题,添加第一条时光机记录。"
/>
<UEmpty
v-else-if="!filteredEvents.length"
title="没有匹配结果"
description="试试清空搜索词或切换可见性筛选。"
/>
<ul v-else class="relative space-y-0">
<li
v-for="(e, idx) in filteredEvents"
:key="e.id"
class="relative flex gap-2.5 pb-4 pl-0.5 last:pb-0 sm:gap-3"
>
<div class="flex gap-3 sm:items-start sm:justify-between">
<div class="min-w-0 flex-1 space-y-2">
<time
class="block text-xs font-medium tabular-nums tracking-tight text-muted"
:datetime="occurredOnToIsoAttr(e.occurredOn)"
>{{ formatOccurredOnDisplay(e.occurredOn) }}</time>
<h3 class="text-base font-semibold leading-snug text-highlighted">
{{ e.title }}
</h3>
</div>
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="sm"
label="删除"
class="shrink-0"
:loading="deletingId === e.id"
:disabled="deleteDisabled(e)"
@click="removeEvent(e)"
/>
<div
v-if="idx < filteredEvents.length - 1"
class="absolute left-[11px] top-6 bottom-0 w-px bg-default"
aria-hidden="true"
/>
<div class="relative z-[1] flex shrink-0 flex-col items-center pt-1">
<span class="size-3 rounded-full border-2 border-primary bg-default ring-4 ring-primary/15" />
</div>
<div class="mt-4 border-t border-default/60 pt-4">
<UFormField label="可见性" :name="`timeline-vis-${e.id}`" class="max-w-lg">
<USelect
:model-value="e.visibility"
class="w-full"
:items="[...visibilitySelectItems]"
:loading="updatingVisibilityId === e.id"
:disabled="visibilitySelectDisabled(e)"
@update:model-value="(v: string) => updateVisibility(e, v)"
<article
class="min-w-0 flex-1 rounded-lg border border-default/90 bg-default/95 p-3 shadow-xs transition-colors hover:border-primary/25"
>
<div class="flex gap-2 sm:items-start sm:justify-between">
<div class="min-w-0 flex-1 space-y-1.5">
<time
class="block text-[11px] font-medium tabular-nums tracking-tight text-muted"
:datetime="occurredOnToIsoAttr(e.occurredOn)"
>{{ formatOccurredOnDisplay(e.occurredOn) }}</time>
<h3 class="text-sm font-semibold leading-snug text-highlighted">
{{ e.title }}
</h3>
</div>
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="sm"
label="删除"
class="shrink-0 -mr-1"
:loading="deletingId === e.id"
:disabled="deleteDisabled(e)"
@click="removeEvent(e)"
/>
</UFormField>
<UAlert
v-if="timelineUnlistedShareUrl(e)"
class="mt-3"
color="neutral"
variant="subtle"
title="仅链接分享"
:description="timelineUnlistedShareUrl(e)"
/>
<UAlert
v-else-if="e.visibility === 'unlisted' && timelineUnlistedShareBlockedReason(e)"
class="mt-3"
color="warning"
variant="subtle"
title="暂时无法生成分享链接"
:description="timelineUnlistedShareBlockedReason(e)"
/>
</div>
</article>
</li>
</ul>
</section>
</div>
<div class="mt-3 border-t border-default/50 pt-3">
<UFormField label="可见性" :name="`timeline-vis-${e.id}`" class="max-w-md">
<USelect
:model-value="e.visibility"
class="w-full"
:items="[...visibilitySelectItems]"
:loading="updatingVisibilityId === e.id"
:disabled="visibilitySelectDisabled(e)"
@update:model-value="(v: Visibility) => updateVisibility(e, v)"
/>
</UFormField>
<UAlert
v-if="timelineUnlistedShareUrl(e)"
class="mt-2"
color="neutral"
variant="subtle"
title="仅链接分享"
:description="timelineUnlistedShareUrl(e)"
/>
<UAlert
v-else-if="e.visibility === 'unlisted' && timelineUnlistedShareBlockedReason(e)"
class="mt-2"
color="warning"
variant="subtle"
title="暂时无法生成分享链接"
:description="timelineUnlistedShareBlockedReason(e)"
/>
</div>
</article>
</li>
</ul>
</section>
</div>
</div>
</UContainer>
</template>

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.
Loading…
Cancel
Save