@@ -1,8 +1,8 @@
< script setup lang = "ts" >
import { onMounted , ref } from 'vue'
import { nextTick , onBeforeUnmount , onMounted , ref } from 'vue'
import { Archive , ArchiveRestore , CalendarHeart , Pencil , Pin , Plus , Trash2 , X } from 'lucide-vue-next'
import { csrfHeader } from './lib/csrf'
import { calendarModeLabel , countdownDayText , countdownKindLabel , dateKey } from './lib/mvp-utils'
import { calendarModeLabel , countdownDayText , countdownKindLabel , dateKey , formatApiErrorDetail } from './lib/mvp-utils'
type Countdown = {
id : string ; title : string ; event _date : string ; display _date : string ; kind : 'countdown' | 'anniversary' | 'birthday'
@@ -18,6 +18,28 @@ const editingId = ref<string|null>(null), error = ref('')
const currentYear = new Date ( ) . getFullYear ( )
const freshForm = ( ) : Form => ( { title : '' , event _date : dateKey ( new Date ( ) ) , kind : 'countdown' , repeat _rule : 'none' , icon : '📅' , calendar _mode : 'solar' , lunar _year : currentYear , lunar _month : 1 , lunar _day : 1 , leap _month : false , ignore _year : false } )
const form = ref < Form > ( freshForm ( ) )
const titleInput = ref < HTMLInputElement | 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
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 ) {
/ / 农 历 项 主 信 息 用 农 历 ; 否 则 公 历 。 忽 略 年 份 / 每 年 重 复 时 副 行 给 出 换 算 公 历 日 期 。
@@ -42,7 +64,10 @@ async function request(path:string, options:RequestInit={}) {
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 ( body . detail || '请求失败' ) }
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 } }
@@ -52,6 +77,7 @@ function edit(item:Countdown) {
editingId . value = item . id
form . value = { title : item . title , event _date : item . event _date , kind : item . kind , repeat _rule : item . repeat _rule , icon : item . icon , 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 ( )
}
async function save ( ) {
if ( ! form . value . title . trim ( ) ) return
@@ -60,7 +86,7 @@ async function save() {
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 ) } )
open . value = false ; await load ( ) ; emit ( 'notice' , editingId . value ? '倒数日已更新' : '倒数日已添加' )
closeDialog ( ) ; await load ( ) ; emit ( 'notice' , editingId . value ? '倒数日已更新' : '倒数日已添加' )
} )
}
async function pin ( item : Countdown ) { await safe ( async ( ) => { await request ( ` /countdowns/ ${ item . id } /pin ` , { method : 'POST' } ) ; await load ( ) ; emit ( 'notice' , '已置顶' ) } ) }
@@ -70,12 +96,14 @@ async function purge(item:Countdown){if(!confirm(`永久删除“${item.title}
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 onAdd ( ) { editingId . value = null ; form . value = freshForm ( ) ; open . value = true }
function onAdd ( ) { editingId . value = null ; form . value = freshForm ( ) ; open . value = true ; focusDialog ( ) }
onMounted ( load )
onBeforeUnmount ( ( ) => { previousFocus = null } )
< / script >
< template >
< section class = "countdown-view" : class = "{ loading:busy }" >
< div class = "countdown-content" :inert = "open" : aria -hidden = " open ? ' true ' : undefined " >
< header class = "countdown-hero" > < div > < small > 记住值得期待与纪念的日子 < / small > < h2 > 倒数日 < / h2 > < / div > < button class = "countdown-add" @click ="onAdd" > < Plus / > 添加日子 < / button > < / header >
< p v-if = "error" class="inline-error" > {{ error }} < / p >
< div class = "countdown-grid" >
@@ -90,9 +118,10 @@ onMounted(load)
< 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"><span>{{item.icon}}</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><button @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)" > < Trash2 / > 永久删除 < / button > < / article > < / div >
< button class = "fab countdown-fab mobile-only" aria -label = " 添加倒数日 " @click ="add" > < Plus / > < / button >
< div v-if = "open" class="countdown-modal-mask" @click.self="open=false"><form class="countdown-modal" role="dialog" aria-modal="true" @submit.prevent="save" >
< header > < div > < small > { { editingId ? '调整重要日子' : '记下重要日子' } } < / small > < h3 > { { editingId ? '编辑倒数日' : '新建倒数日' } } < / h3 > < / div > < button type = "button" aria -label = " 关闭 " @click ="open=false" > < X / > < / button > < / header >
< label > 图标与名称 < div class = "countdown-title-fields" > < input v-model = "form.icon" maxlength="8" aria-label="图标"><input v-model="form.title" maxlength="200" required placeholder="例如:去北海道旅行" autofocus > < / div > < / label >
< / div >
< div v-if = "open" class="countdown-modal-mask" @click.self="closeDialog"><form class="countdown-modal" role="dialog" aria-modal="true" aria-labelledby="countdown-dialog-title" @submit.prevent="save" @keydown.esc="closeDialog" @keydown="trapDialogFocus" >
< header > < div > < small > { { editingId ? '调整重要日子' : '记下重要日子' } } < / small > < h3 id = "countdown-dialog-title" > { { editingId ? '编辑倒数日' : '新建倒数日' } } < / h3 > < / div > < button type = "button" aria -label = " 关闭 " @click ="closeDialog" > < X / > < / button > < / header >
< label > 图标与名称 < div class = "countdown-title-fields" > < input v-model = "form.icon" maxlength="8" aria-label="图标"><input ref="titleInput" v-model="form.title" maxlength="200" required placeholder="例如:去北海道旅行" autofocus > < / div > < / label >
< label > 历法 < select v-model = "form.calendar_mode"><option value="solar">{{calendarModeLabel('solar')}}</option><option value="lunar" > {{ calendarModeLabel ( ' lunar ' ) }} < / option > < / select > < / label >
< label v-if = "form.calendar_mode==='solar'">日期<input v-model="form.event_date" type="date" required > < / label >
< div v-else class = "countdown-title-fields" >
@@ -104,7 +133,7 @@ onMounted(load)
< label > < input v-model = "form.ignore_year" type="checkbox"> 忽略年份<span v-if="form.calendar_mode==='lunar'" > , 每年按农历计算 < / span > < / label >
< label > 类型 < select v-model = "form.kind"><option value="countdown">倒数日</option><option value="anniversary">纪念日</option><option value="birthday" > 生日 < / option > < / select > < / label >
< label > 重复 < select v-model = "form.repeat_rule"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly" > 每年 < / option > < / select > < / label >
< footer > < button type = "button" class = "secondary" @click ="open=false " > 取消 < / button > < button class = "primary-small" > 保存 < / button > < / footer >
< footer > < button type = "button" class = "secondary" @click ="closeDialog " > 取消 < / button > < button class = "primary-small" > 保存 < / button > < / footer >
< / form > < / div >
< / section >
< / template >