Files
dodo/frontend/src/CountdownPanel.vue
T
bboysoul 98578b4f36
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m29s
perf: streamline view data loading
2026-09-10 08:06:52 +08:00

235 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { Archive, ArchiveRestore, CalendarHeart, ChevronDown, Pencil, Pin, Trash2, X } from 'lucide-vue-next'
import { csrfHeader } from './lib/csrf'
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail, getCountdownCacheGeneration, invalidateCountdownCache, isCountdownCacheGenerationCurrent, loadCountdownCache, readCountdownCache } from './lib/mvp-utils'
type Countdown = {
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
repeat_rule: 'none'|'weekly'|'monthly'|'yearly'; icon: string; pinned: boolean; archived_at: string|null; days: number
calendar_mode: 'solar'|'lunar'; lunar_year: number|null; lunar_month: number|null; lunar_day: number|null
ignore_year: boolean; lunar_text: string|null; updated_at: string
}
type Form = { title:string; event_date:string; kind:Countdown['kind']; repeat_rule:Countdown['repeat_rule']; calendar_mode:Countdown['calendar_mode']; lunar_year:number; lunar_month:number; lunar_day:number; leap_month:boolean; ignore_year:boolean }
type CountdownGroup = { key: string; title: string; items: Countdown[] }
const emit = defineEmits<{ notice: [message: string] }>()
const items = ref<Countdown[]>([]), archived = ref<Countdown[]>([])
const showArchived = ref(false), open = ref(false), busy = ref(false)
const editingId = ref<string|null>(null), editingItem = ref<Countdown|null>(null), error = ref('')
const detailItem = ref<Countdown|null>(null), showAdvanced = ref(false)
const currentYear = new Date().getFullYear()
const freshForm = (): Form => ({ title:'', event_date:dateKey(new Date()), kind:'countdown', repeat_rule:'none', calendar_mode:'solar', lunar_year:currentYear, lunar_month:1, lunar_day:1, leap_month:false, ignore_year:false })
const form = ref<Form>(freshForm())
const composerOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
const composerStyle = computed(() => ({ '--fab-origin-x': `${composerOrigin.value.x}px`, '--fab-origin-y': `${composerOrigin.value.y}px` }))
const titleInput = ref<HTMLInputElement | null>(null)
const detailCloseButton = ref<HTMLButtonElement | null>(null)
let previousFocus: HTMLElement | null = null
function focusDialog() {
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
void nextTick(() => titleInput.value?.focus())
}
function closeDialog() {
open.value = false
showAdvanced.value = false
void nextTick(() => previousFocus?.focus())
}
function trapDialogFocus(event: KeyboardEvent) {
if (event.key !== 'Tab') return
const dialog = event.currentTarget as HTMLElement
const controls = Array.from(dialog.querySelectorAll<HTMLElement>('button,input,select,textarea,[tabindex]:not([tabindex="-1"])'))
.filter((item) => !item.hasAttribute('disabled'))
if (!controls.length) return
const first = controls[0]
const last = controls[controls.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
function primaryDate(item: Countdown) {
// 农历项主信息用农历;否则公历。忽略年份/每年重复时副行给出换算公历日期。
if (item.calendar_mode === 'lunar' && item.lunar_text) return item.lunar_text
return formatDate(item.display_date)
}
function secondaryDate(item: Countdown) {
if (item.calendar_mode === 'lunar' && item.lunar_text) {
const suffix = item.ignore_year ? ' · 每年农历' : ''
return `${formatDate(item.display_date)}${suffix}`
}
return ''
}
function repeatBadge(item: Countdown) {
if (item.calendar_mode === 'lunar') return item.ignore_year ? '每年' : (item.repeat_rule !== 'none' ? repeatLabel(item.repeat_rule) : '')
return item.repeat_rule !== 'none' ? repeatLabel(item.repeat_rule) : ''
}
function visibleBadges(item: Countdown) {
return [countdownKindLabel(item.kind), item.calendar_mode === 'lunar' ? '农历' : '', repeatBadge(item)].filter(Boolean).slice(0, 2)
}
function sorted(itemsToSort: Countdown[]) {
return [...itemsToSort].sort((a, b) => Number(b.pinned) - Number(a.pinned) || a.days - b.days || a.title.localeCompare(b.title, 'zh-Hans-CN'))
}
const upcomingItems = computed(() => sorted(items.value.filter((item) => item.days >= 0)))
const focusItem = computed(() => sorted(items.value).find((item) => item.pinned) ?? upcomingItems.value[0] ?? sorted(items.value)[0] ?? null)
const countdownSummary = computed(() => {
if (!items.value.length) return '还没有记录重要日子'
const pinned = items.value.filter((item) => item.pinned).length
return pinned ? `${items.value.length} 个重要日子 · ${pinned} 个置顶` : `${items.value.length} 个重要日子`
})
const countdownGroups = computed<CountdownGroup[]>(() => {
const withoutFocus = items.value.filter((item) => item.id !== focusItem.value?.id)
const recent = sorted(withoutFocus.filter((item) => item.days >= 0 && item.days <= 30))
const later = sorted(withoutFocus.filter((item) => item.days > 30))
const past = sorted(withoutFocus.filter((item) => item.days < 0))
return [
{ key:'recent', title:'最近', items:recent },
{ key:'later', title:'稍后', items:later },
{ key:'past', title:'已经过去', items:past },
].filter((group) => group.items.length)
})
async function request(path:string, options:RequestInit={}) {
const headers:Record<string,string> = { ...(options.headers as Record<string,string> || {}) }
if (options.body) headers['Content-Type'] = 'application/json'
Object.assign(headers, csrfHeader(options.method))
const response = await fetch('/api/v1' + path, { credentials:'include', ...options, headers })
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(formatApiErrorDetail((body as { detail?: unknown }).detail ?? body))
}
return response.status === 204 ? null : response.json()
}
async function safe(work:()=>Promise<void>) { busy.value=true; error.value=''; try { await work() } catch(reason) { error.value=reason instanceof Error ? reason.message : '请求失败' } finally { busy.value=false } }
async function fetchCountdowns() {
const [active, archivedItems] = await Promise.all([
request('/countdowns') as Promise<Countdown[]>,
request('/countdowns?archived=true') as Promise<Countdown[]>,
])
return { items: active, archived: archivedItems }
}
async function load(force = false) {
const generation = getCountdownCacheGeneration()
const cached = readCountdownCache<Countdown>()
if (cached) { items.value=cached.items; archived.value=cached.archived }
if (!cached) busy.value=true
error.value=''
try {
const data = await loadCountdownCache(fetchCountdowns, { force })
if (!isCountdownCacheGenerationCurrent(generation)) return
items.value=data.items
archived.value=data.archived
} catch(reason) {
if (isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : '请求失败'
} finally {
if (isCountdownCacheGenerationCurrent(generation)) busy.value=false
}
}
function edit(item:Countdown) {
detailItem.value=null
editingId.value=item.id
editingItem.value=item
showAdvanced.value=false
form.value={ title:item.title, event_date:item.event_date, kind:item.kind, repeat_rule:item.repeat_rule, calendar_mode:item.calendar_mode, lunar_year:item.lunar_year || Number(item.event_date.slice(0,4)), lunar_month:Math.abs(item.lunar_month || 1), lunar_day:item.lunar_day || 1, leap_month:(item.lunar_month || 0)<0, ignore_year:item.ignore_year }
open.value=true
focusDialog()
}
function applyKindDefaults() {
if (form.value.kind === 'birthday' || form.value.kind === 'anniversary') form.value.repeat_rule='yearly'
if (form.value.kind === 'countdown') form.value.repeat_rule='none'
}
async function save() {
if (busy.value) return
if (!form.value.title.trim()) return
await safe(async()=>{
const payload:any={ title:form.value.title.trim(), event_date:form.value.calendar_mode==='lunar' ? `${form.value.lunar_year}-01-01` : form.value.event_date, kind:form.value.kind, repeat_rule:form.value.ignore_year ? 'yearly' : form.value.repeat_rule, calendar_mode:form.value.calendar_mode, ignore_year:form.value.ignore_year }
if (editingId.value) payload.expected_updated_at=editingItem.value?.updated_at
if (form.value.calendar_mode==='lunar') { payload.lunar_month=form.value.leap_month ? -form.value.lunar_month : form.value.lunar_month; payload.lunar_day=form.value.lunar_day }
const path=editingId.value ? `/countdowns/${editingId.value}` : '/countdowns'
await request(path,{ method:editingId.value?'PATCH':'POST', body:JSON.stringify(payload) })
invalidateCountdownCache(); closeDialog(); await load(true); emit('notice',editingId.value?'倒数日已更新':'倒数日已添加')
})
}
async function pin(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/pin`,{method:'POST'});invalidateCountdownCache();detailItem.value=null;await load(true);emit('notice','已置顶')})}
async function archiveItem(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}`,{method:'DELETE'});invalidateCountdownCache();detailItem.value=null;await load(true);emit('notice','已归档')})}
async function restore(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/restore`,{method:'POST'});invalidateCountdownCache();await load(true);emit('notice','已恢复')})}
async function purge(item:Countdown){if(!confirm(`永久删除“${item.title}”?这个操作不能撤销。`))return;await safe(async()=>{await request(`/countdowns/${item.id}/purge`,{method:'DELETE'});invalidateCountdownCache();await load(true);emit('notice','已永久删除')})}
function formatDate(value:string){const [y,m,d]=value.split('-');return `${y}${Number(m)}${Number(d)}日`}
function formatDateShort(value:string){const [y,m,d]=value.split('-');return `${y}/${Number(m)}/${Number(d)}`}
function repeatLabel(value:Countdown['repeat_rule']){return({none:'不重复',weekly:'每周',monthly:'每月',yearly:'每年'})[value]}
function openDetail(item:Countdown){detailItem.value=item;previousFocus=document.activeElement instanceof HTMLElement ? document.activeElement : null;void nextTick(() => detailCloseButton.value?.focus())}
function closeDetail(){detailItem.value=null;void nextTick(() => previousFocus?.focus())}
function trapDetailFocus(event: KeyboardEvent) {
if (event.key !== 'Tab') return
const dialog = event.currentTarget as HTMLElement
const controls = Array.from(dialog.querySelectorAll<HTMLElement>('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'))
.filter((item) => !item.hasAttribute('disabled'))
if (!controls.length) return
const first = controls[0]
const last = controls[controls.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
function openFromEmpty(){openCountdownComposer()}
function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;detailItem.value=null;editingId.value=null;editingItem.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true;focusDialog()}
defineExpose({ openCountdownComposer })
onMounted(() => { void load() })
onBeforeUnmount(() => { previousFocus = null })
</script>
<template>
<section class="countdown-view" :class="{ loading:busy }">
<div class="countdown-content" :inert="open || Boolean(detailItem)" :aria-hidden="open || detailItem ? 'true' : undefined">
<header class="countdown-hero"><small>{{countdownSummary}}</small></header>
<p v-if="error" class="inline-error">{{error}}</p>
<div v-if="items.length" class="countdown-layout">
<button v-if="focusItem" type="button" class="countdown-focus" :class="`kind-${focusItem.kind}`" @click="openDetail(focusItem)">
<small>{{focusItem.pinned?'置顶的重要日子':'下一个重要日子'}}</small>
<div class="countdown-number"><strong>{{focusItem.days===0?'今天':Math.abs(focusItem.days)}}</strong><span v-if="focusItem.days!==0"></span></div>
<h3>{{focusItem.title}}</h3><b class="countdown-copy">{{countdownDayText(focusItem.days)}}</b><p>{{primaryDate(focusItem)}}</p>
</button>
<div v-if="countdownGroups.length" class="countdown-timeline">
<section v-for="group in countdownGroups" :key="group.key" class="countdown-group">
<h3>{{group.title}}</h3>
<button v-for="item in group.items" :key="item.id" type="button" class="countdown-row" :class="[`kind-${item.kind}`, { pinned:item.pinned, today:item.days===0, past:item.days<0 }]" @click="openDetail(item)">
<span class="countdown-main"><b>{{item.title}}</b><small>{{primaryDate(item)}}<template v-if="secondaryDate(item)"> · {{secondaryDate(item)}}</template></small><span class="countdown-badges"><em v-for="badge in visibleBadges(item)" :key="badge">{{badge}}</em></span></span>
<span class="countdown-state"><strong>{{item.days===0?'今天':Math.abs(item.days)}}</strong><small>{{countdownDayText(item.days)}}</small></span>
<Pin v-if="item.pinned" class="pinned-icon"/>
</button>
</section>
</div>
</div>
<div v-else-if="!busy" class="countdown-empty"><CalendarHeart/><b>还没有重要日子</b><span>生日、纪念日,或一场期待已久的旅行</span><button type="button" class="primary-small" @click="openFromEmpty">添加第一个重要日子</button></div>
<button v-if="archived.length" class="archived-toggle" @click="showArchived=!showArchived"><ArchiveRestore/>已归档{{archived.length}}</button>
<div v-if="showArchived" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><b>{{item.title}}</b><small>{{formatDateShort(item.display_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template><template v-if="item.calendar_mode==='lunar'"> · 农历</template></small><button @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)"><Trash2/>永久删除</button></article></div>
</div>
<Transition name="countdown-detail">
<div v-if="detailItem" class="countdown-detail-mask app-sheet-mask" @click.self="closeDetail"><article class="countdown-detail-sheet app-sheet app-sheet--detail" role="dialog" aria-modal="true" aria-labelledby="countdown-detail-title" @keydown.esc="closeDetail" @keydown="trapDetailFocus">
<header class="app-sheet__header"><div><small>重要日子详情</small><h3 id="countdown-detail-title">{{detailItem.title}}</h3></div><button ref="detailCloseButton" type="button" aria-label="关闭详情" @click="closeDetail"><X/></button></header>
<div class="app-sheet__body"><div class="countdown-detail-days"><strong>{{detailItem.days===0?'今天':Math.abs(detailItem.days)}}</strong><span v-if="detailItem.days!==0"></span><b>{{countdownDayText(detailItem.days)}}</b></div>
<dl><div><dt>日期</dt><dd>{{primaryDate(detailItem)}}</dd></div><div v-if="secondaryDate(detailItem)"><dt>换算</dt><dd>{{secondaryDate(detailItem)}}</dd></div><div><dt>类型</dt><dd>{{countdownKindLabel(detailItem.kind)}} · {{detailItem.calendar_mode==='lunar'?'农历':'公历'}} · {{repeatBadge(detailItem) || '不重复'}}</dd></div></dl></div>
<footer class="app-sheet__footer"><button v-if="!detailItem.pinned" type="button" @click="pin(detailItem)"><Pin/>置顶</button><button type="button" @click="edit(detailItem)"><Pencil/>编辑</button><button type="button" class="danger-text" @click="archiveItem(detailItem)"><Archive/>归档</button></footer>
</article></div>
</Transition>
<Transition name="countdown-compose">
<div v-if="open" class="countdown-modal-mask app-sheet-mask" @click.self="closeDialog"><form class="countdown-modal app-sheet app-sheet--create" :style="composerStyle" role="dialog" aria-modal="true" aria-labelledby="countdown-dialog-title" @submit.prevent="save" @keydown.esc="closeDialog" @keydown="trapDialogFocus">
<header class="app-sheet__header"><div><small>{{editingId?'调整重要日子':'快速记下重要日子'}}</small><h3 id="countdown-dialog-title">{{editingId?'编辑倒数日':'新建倒数日'}}</h3></div><button type="button" aria-label="关闭" @click="closeDialog"><X/></button></header>
<div class="app-sheet__body"><label>名称<input ref="titleInput" v-model="form.title" maxlength="200" required placeholder="例如:去北海道旅行" autofocus></label>
<label v-if="form.calendar_mode==='solar'">日期<input v-model="form.event_date" type="date" required></label>
<label>类型<select v-model="form.kind" @change="applyKindDefaults"><option value="countdown">倒数日</option><option value="anniversary">纪念日</option><option value="birthday">生日</option></select></label>
<details class="countdown-advanced" :open="showAdvanced" @toggle="showAdvanced=($event.target as HTMLDetailsElement).open"><summary><span>更多设置</span><ChevronDown/></summary>
<label>历法<select v-model="form.calendar_mode"><option value="solar">{{calendarModeLabel('solar')}}</option><option value="lunar">{{calendarModeLabel('lunar')}}</option></select></label>
<div v-if="form.calendar_mode==='lunar'" class="countdown-title-fields">
<label>年份<input v-model.number="form.lunar_year" type="number" min="1900" max="2099" required></label>
<label>月份<select v-model.number="form.lunar_month"><option v-for="month in 12" :key="month" :value="month">{{month}}</option></select></label>
<label>日期<select v-model.number="form.lunar_day"><option v-for="day in 30" :key="day" :value="day">{{day}}</option></select></label>
</div>
<label v-if="form.calendar_mode==='lunar'"><input v-model="form.leap_month" type="checkbox"> 闰月如果是闰月勾选这里</label>
<label><input v-model="form.ignore_year" type="checkbox"> 忽略年份<span v-if="form.calendar_mode==='lunar'">每年按农历计算</span></label>
<label>重复<select v-model="form.repeat_rule" :disabled="form.ignore_year"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select></label>
</details></div>
<footer class="app-sheet__footer"><button type="button" class="secondary" :disabled="busy" @click="closeDialog">取消</button><button class="primary-small" :disabled="busy">保存</button></footer>
</form></div>
</Transition>
</section>
</template>