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
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>
|
|
|