Files
dodo/frontend/src/CountdownPanel.vue
T
bboysoul ec90e9155e
ci / docker (push) Successful in 3m30s
fix: resolve ui audit bugs
2026-09-07 09:08:22 +08:00

140 lines
12 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 { 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, formatApiErrorDetail } 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
}
type Form = { title:string; event_date:string; kind:Countdown['kind']; repeat_rule:Countdown['repeat_rule']; icon:string; calendar_mode:Countdown['calendar_mode']; lunar_year:number; lunar_month:number; lunar_day:number; leap_month:boolean; ignore_year:boolean }
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), 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) {
// 农历项主信息用农历;否则公历。忽略年份/每年重复时副行给出换算公历日期。
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)} · ${item.lunar_text}${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) : ''
}
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 load() { await safe(async()=>{ const [a,b]=await Promise.all([request('/countdowns'),request('/countdowns?archived=true')]); items.value=a; archived.value=b }) }
function add() { onAdd() }
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
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.repeat_rule, icon:form.value.icon, calendar_mode:form.value.calendar_mode, ignore_year:form.value.ignore_year }
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) })
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','已置顶')})}
async function archiveItem(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}`,{method:'DELETE'});await load();emit('notice','已归档')})}
async function restore(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/restore`,{method:'POST'});await load();emit('notice','已恢复')})}
async function purge(item:Countdown){if(!confirm(`永久删除“${item.title}”?这个操作不能撤销。`))return;await safe(async()=>{await request(`/countdowns/${item.id}/purge`,{method:'DELETE'});await load();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 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">
<article v-for="item in items" :key="item.id" class="countdown-card" :class="{ pinned:item.pinned }">
<div class="countdown-card-head"><span class="countdown-icon">{{item.icon}}</span><span class="countdown-type">{{countdownKindLabel(item.kind)}}</span><span v-if="item.calendar_mode==='lunar'" class="countdown-type">农历</span><span v-if="repeatBadge(item)" class="countdown-type">{{repeatBadge(item)}}</span><Pin v-if="item.pinned" class="pinned-icon"/></div>
<h3>{{item.title}}</h3><div class="countdown-number"><strong>{{Math.abs(item.days)}}</strong><span v-if="item.days!==0">天</span></div><b class="countdown-copy">{{countdownDayText(item.days)}}</b>
<p><b>{{primaryDate(item)}}</b><span v-if="secondaryDate(item)"> · {{secondaryDate(item)}}</span></p>
<div class="countdown-actions"><button v-if="!item.pinned" @click="pin(item)"><Pin/>置顶</button><button @click="edit(item)"><Pencil/>编辑</button><button @click="archiveItem(item)"><Archive/>归档</button></div>
</article>
<button v-if="!items.length&&!busy" class="countdown-empty" @click="onAdd"><CalendarHeart/><b>添加第一个重要日子</b><span>生日纪念日或一场期待已久的旅行</span></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"><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>
<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">
<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.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="closeDialog">取消</button><button class="primary-small">保存</button></footer>
</form></div>
</section>
</template>