Files
dodo/frontend/src/CountdownPanel.vue
T
bboysoul cb7d18e192
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 7m5s
style: refine dense task views
2026-09-17 12:20:59 +08:00

236 lines
18 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, 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'
import AppSheet from './components/AppSheet.vue'
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
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 operationGeneration = ref(0)
const detailGeneration = ref(0)
type OperationContext = { generation:number; detailId:string|null; detailGeneration:number }
let activeOperation:OperationContext|null = null
let mounted = true
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 appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
function closeDialog() {
open.value = false
showAdvanced.value = false
}
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 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()
}
function currentContext(context:OperationContext) {
return mounted && context.generation === operationGeneration.value && context.detailGeneration === detailGeneration.value && context.detailId === (detailItem.value?.id ?? null)
}
async function safe(detailId:string|null, work:(context:OperationContext)=>Promise<void>) {
if (activeOperation && currentContext(activeOperation)) return
const context={ generation:++operationGeneration.value, detailId, detailGeneration:detailGeneration.value }
activeOperation=context
busy.value=true
error.value=''
try {
await work(context)
} catch(reason) {
if (!currentContext(context)) return
error.value=reason instanceof Error ? reason.message : '请求失败'
} finally {
if (activeOperation === context) activeOperation=null
if (currentContext(context)) 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, manageBusy = true) {
const generation = getCountdownCacheGeneration()
const cached = readCountdownCache<Countdown>()
if (cached) { items.value=cached.items; archived.value=cached.archived }
if (!cached && manageBusy) busy.value=true
if (manageBusy) error.value=''
try {
const data = await loadCountdownCache(fetchCountdowns, { force })
if (!isCountdownCacheGenerationCurrent(generation)) return
items.value=data.items
archived.value=data.archived
} catch(reason) {
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : '请求失败'
} finally {
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) busy.value=false
}
}
function selectDetail(item:Countdown|null) {
detailGeneration.value += 1
detailItem.value=item
error.value=''
if (!activeOperation || !currentContext(activeOperation)) busy.value=false
}
function edit(item:Countdown) {
selectDetail(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
}
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(null, async(context)=>{
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) })
if (!currentContext(context)) return
invalidateCountdownCache(); closeDialog(); await load(true, false)
if (!currentContext(context)) return
emit('notice',editingId.value?'倒数日已更新':'倒数日已添加')
})
}
async function setPinned(item:Countdown){const nextPinned=!item.pinned;await safe(item.id,async(context)=>{await request(`/countdowns/${item.id}/pin`,{method:nextPinned?'POST':'DELETE'});invalidateCountdownCache();if(!currentContext(context)){void load(true,false);return}emit('notice',nextPinned?'已置顶':'已取消置顶');closeDetail();await load(true,false)})}
async function archiveItem(item:Countdown){await safe(item.id,async(context)=>{await request(`/countdowns/${item.id}`,{method:'DELETE'});invalidateCountdownCache();if(!currentContext(context)){void load(true,false);return}emit('notice','已归档');closeDetail();await load(true,false)})}
async function restore(item:Countdown){await safe(null,async(context)=>{await request(`/countdowns/${item.id}/restore`,{method:'POST'});invalidateCountdownCache();await load(true,false);if(!currentContext(context))return;emit('notice','已恢复')})}
async function purge(item:Countdown){if(busy.value)return;if(await appDialog.value?.show({title:`永久删除“${item.title}”?`,description:'这个操作不能撤销。',danger:true,confirmText:'永久删除'})!==true)return;if(busy.value)return;await safe(null,async(context)=>{await request(`/countdowns/${item.id}/purge`,{method:'DELETE'});invalidateCountdownCache();await load(true,false);if(!currentContext(context))return;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){selectDetail(item)}
function closeDetail(){selectDetail(null)}
function openFromEmpty(){openCountdownComposer()}
function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;selectDetail(null);editingId.value=null;editingItem.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true}
defineExpose({ openCountdownComposer })
onMounted(() => { void load() })
onBeforeUnmount(() => {
mounted = false
operationGeneration.value += 1
})
</script>
<template>
<section class="countdown-view" :class="{ loading:busy }">
<div class="countdown-content" :inert="open || Boolean(detailItem)" :aria-hidden="open || detailItem ? 'true' : undefined">
<small class="countdown-summary">{{countdownSummary}}</small>
<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)">
<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><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 v-if="group.title">{{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}}<small v-if="item.pinned" class="countdown-pinned-mark"> · 置顶</small></b><small>{{primaryDate(item)}}</small></span>
<span class="countdown-state"><strong>{{item.days===0?'今天':Math.abs(item.days)}}</strong><small>{{countdownDayText(item.days)}}</small></span>
</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" type="button" :disabled="busy" :aria-expanded="showArchived" aria-controls="archived-countdowns" @click="showArchived=!showArchived"><ArchiveRestore/>已归档{{archived.length}}</button>
<div v-if="showArchived" id="archived-countdowns" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><span><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></span><button :disabled="busy" @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" :disabled="busy" @click="purge(item)"><Trash2/>永久删除</button></article></div>
</div>
<AppSheet :open="Boolean(detailItem)" variant="detail" panel-class="countdown-detail-sheet" title-id="countdown-detail-title" initial-focus="button[aria-label='关闭详情']" :busy="busy" @close="closeDetail">
<template v-if="detailItem">
<header class="app-sheet__header"><div><h3 id="countdown-detail-title">{{detailItem.title}}</h3></div><button 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 type="button" :disabled="busy" @click="setPinned(detailItem)"><Pin/>{{detailItem.pinned?'取消置顶':'置顶'}}</button><button type="button" :disabled="busy" @click="edit(detailItem)"><Pencil/>编辑</button><button type="button" class="danger-text" :disabled="busy" @click="archiveItem(detailItem)"><Archive/>归档</button></footer>
</template>
</AppSheet>
<AppSheet :open="open" variant="create" panel-class="countdown-modal" title-id="countdown-dialog-title" initial-focus="input[aria-label='倒数日名称']" :busy="busy" :style="composerStyle" @close="closeDialog" @submit.prevent="save">
<header class="app-sheet__header"><div><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 v-model="form.title" aria-label="倒数日名称" maxlength="200" required placeholder="例如:去北海道旅行"></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>
</AppSheet>
<AppDialog ref="appDialog" />
</section>
</template>