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.
 
 
 

330 lines
11 KiB

<script setup lang="ts">
import { useAuthSession } from '../../../composables/useAuthSession'
import {
formatOccurredOnDisplay,
occurredOnToIsoAttr,
occurredOnToLocalInputValue,
parseLocalDatetimeInput,
} from '../../../utils/timeline-datetime'
definePageMeta({ title: '时光机' })
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 Ev = { id: number; title: string; occurredOn: string; visibility: string; 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 form = reactive({
occurredOn: occurredOnToLocalInputValue(new Date()),
title: '',
bodyMarkdown: '',
linkUrl: '',
visibility: 'private',
})
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 (!form.title.trim()) {
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 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: string) {
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-3xl 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>
<UAlert
color="neutral"
variant="subtle"
icon="i-lucide-info"
title="个人主页如何展示时光机"
:description="publicHomeAlertDescription"
/>
<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>
<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="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">
<USelect
v-model="form.visibility"
class="w-full"
:items="[...visibilitySelectItems]"
/>
</UFormField>
<UButton
class="shrink-0 sm:self-end"
:loading="submitLoading"
@click="add"
>
添加
</UButton>
</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 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>
<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)"
/>
</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>
</UContainer>
</template>