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.
 
 
 
 
 

402 lines
13 KiB

<script setup lang="ts">
import { useAuthSession } from '../../../composables/useAuthSession'
import {
formatOccurredOnDisplay,
occurredOnToIsoAttr,
occurredOnToLocalInputValue,
parseLocalDatetimeInput,
} from '../../../utils/timeline-datetime'
usePageTitle('时光机')
const { user } = useAuthSession()
const requestUrl = useRequestURL()
function timelineOrigin(): string {
if (import.meta.server) {
return requestUrl.origin
}
return window.location.origin
}
/** 公开接口只拉取 visibility=public 的记录,与账号角色无关 */
const publicHomeAlertDescription = computed(() => {
const slug = user.value?.publicSlug?.trim()
if (slug) {
return `「/@${slug}」只展示可见性为「公开」的事件。新建默认是「私密」,若要出现在个人主页,请改为「公开」或在下拉里修改已有条目。「仅链接」不会在主页列出,但在本条下方会显示可复制的分享地址(/p/${slug}/t/…)。`
}
return '个人公开主页只展示「公开」事件。新建默认是「私密」。「仅链接」条目会在列表中显示分享链接,但需要先在「资料」中设置 public slug。'
})
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()) {
return ''
}
const slug = user.value?.publicSlug?.trim()
if (!slug) {
return ''
}
return `${timelineOrigin()}/p/${encodeURIComponent(slug)}/t/${encodeURIComponent(e.shareToken)}`
}
function timelineUnlistedShareBlockedReason(e: Ev): string {
if (e.visibility !== 'unlisted') {
return ''
}
if (!e.shareToken?.trim()) {
return '分享令牌缺失,请先将可见性改为其他再改回「仅链接」重试。'
}
if (!user.value?.publicSlug?.trim()) {
return '已设为仅链接,但尚未在「资料」中设置 public slug,无法生成分享地址。设置后即可得到完整链接。'
}
return ''
}
const visibilitySelectItems = [
{ label: '私密(仅自己,不上主页)', value: 'private' },
{ label: '公开(出现在 /@ 个人主页)', value: 'public' },
{ label: '仅链接(凭分享链接访问)', value: 'unlisted' },
] as const
const { fetchData } = useClientApi()
const toast = useToast()
const events = ref<Ev[]>([])
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<{
occurredOn: string
title: string
bodyMarkdown: string
linkUrl: string
visibility: Visibility
}>({
occurredOn: occurredOnToLocalInputValue(new Date()),
title: '',
bodyMarkdown: '',
linkUrl: '',
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 {
const { events: list } = await fetchData<{ events: Ev[] }>('/api/me/timeline')
events.value = list
} finally {
loading.value = false
}
}
onMounted(load)
async function add() {
if (!canSubmit.value) {
return
}
let occurredOnIso: string
try {
occurredOnIso = parseLocalDatetimeInput(form.occurredOn).toISOString()
} catch {
return
}
submitLoading.value = true
try {
await fetchData('/api/me/timeline', {
method: 'POST',
body: {
occurredOn: occurredOnIso,
title: form.title,
bodyMarkdown: form.bodyMarkdown || null,
linkUrl: form.linkUrl || null,
visibility: form.visibility,
},
})
form.title = ''
form.bodyMarkdown = ''
form.linkUrl = ''
form.occurredOn = occurredOnToLocalInputValue(new Date())
await load()
toast.add({ title: '已添加事件', color: 'success' })
} finally {
submitLoading.value = false
}
}
async function quickSubmitFromTitle() {
if (submitLoading.value || !canSubmit.value) {
return
}
await add()
}
async function removeEvent(e: Ev) {
if (!confirm(`确定删除「${e.title}」?此操作不可恢复。`)) {
return
}
deletingId.value = e.id
try {
await fetchData(`/api/me/timeline/${e.id}`, { method: 'DELETE' })
await load()
toast.add({ title: '已删除', color: 'success' })
} finally {
deletingId.value = null
}
}
function visibilitySelectDisabled(e: Ev) {
if (updatingVisibilityId.value !== null && updatingVisibilityId.value !== e.id) {
return true
}
if (deletingId.value !== null) {
return true
}
return false
}
function deleteDisabled(e: Ev) {
if (updatingVisibilityId.value !== null) {
return true
}
if (deletingId.value !== null && deletingId.value !== e.id) {
return true
}
return false
}
async function updateVisibility(e: Ev, visibility: Visibility) {
if (visibility === e.visibility) {
return
}
updatingVisibilityId.value = e.id
try {
await fetchData(`/api/me/timeline/${e.id}`, {
method: 'PUT',
body: { visibility },
})
await load()
toast.add({ title: '可见性已更新', color: 'success' })
} finally {
updatingVisibilityId.value = null
}
}
</script>
<template>
<UContainer class="max-w-6xl py-8">
<div class="space-y-6">
<header>
<h1 class="text-2xl font-semibold text-highlighted">
时光机
</h1>
<p class="mt-1 text-sm text-muted">
发生时间精确到秒展示按你本机时区格式化
</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"
/>
<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>
<UFormField label="标题" name="title" required class="min-w-0">
<UInput
v-model="form.title"
placeholder="简短标题"
class="w-full"
autofocus
@keydown.enter.prevent="quickSubmitFromTitle"
/>
</UFormField>
<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>
<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="visibilityFilter"
:items="[...visibilityFilterItems]"
class="w-full sm:w-44"
/>
</div>
</div>
<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
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>
<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)"
/>
</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>