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