feat: lunar calendar support for countdowns
ci / docker (push) Successful in 4m56s

This commit is contained in:
2026-09-06 16:09:54 +08:00
parent afc78dba6c
commit a06b80604a
12 changed files with 333 additions and 77 deletions
+54 -68
View File
@@ -2,103 +2,89 @@
import { onMounted, ref } from 'vue'
import { Archive, ArchiveRestore, CalendarHeart, Pencil, Pin, Plus, Trash2, X } from 'lucide-vue-next'
import { csrfHeader } from './lib/csrf'
import { countdownDayText, countdownKindLabel, dateKey } from './lib/mvp-utils'
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey } 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[]>([])
const archived = ref<Countdown[]>([])
const showArchived = ref(false)
const open = ref(false)
const editingId = ref<string|null>(null)
const busy = ref(false)
const error = ref('')
const form = ref({ title: '', event_date: dateKey(new Date()), kind: 'countdown' as Countdown['kind'], repeat_rule: 'none' as Countdown['repeat_rule'], icon: '📅' })
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())
async function request(path: string, options: RequestInit = {}) {
const headers: Record<string,string> = { ...(options.headers as Record<string,string> || {}) }
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 })
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 || '请求失败') }
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 [activeRows, archivedRows] = await Promise.all([request('/countdowns'), request('/countdowns?archived=true')])
items.value = activeRows; archived.value = archivedRows
})
}
function add() {
editingId.value = null
form.value = { title: '', event_date: dateKey(new Date()), kind: 'countdown', repeat_rule: 'none', icon: '📅' }
open.value = true
}
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 }
open.value = true
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() { editingId.value=null; form.value=freshForm(); open.value=true }
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
}
async function save() {
if (!form.value.title.trim()) return
await safe(async () => {
const path = editingId.value ? `/countdowns/${editingId.value}` : '/countdowns'
await request(path, { method: editingId.value ? 'PATCH' : 'POST', body: JSON.stringify({ ...form.value, title: form.value.title.trim() }) })
open.value = false; await load(); emit('notice', editingId.value ? '倒数日已更新' : '倒数日已添加')
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) })
open.value=false; 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 [year, month, day] = value.split('-')
return `${year}${Number(month)}${Number(day)}`
}
function repeatLabel(value: Countdown['repeat_rule']) { return ({ none: '不重复', weekly: '每周', monthly: '每月', yearly: '每年' })[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 repeatLabel(value:Countdown['repeat_rule']){return({none:'不重复',weekly:'每周',monthly:'每月',yearly:'每年'})[value]}
onMounted(load)
</script>
<template>
<section class="countdown-view" :class="{ loading: busy }">
<header class="countdown-hero">
<div><small>记住值得期待与纪念的日子</small><h2>倒数日</h2></div>
<button class="countdown-add" @click="add"><Plus/>添加日子</button>
</header>
<section class="countdown-view" :class="{ loading:busy }">
<header class="countdown-hero"><div><small>记住值得期待与纪念的日子</small><h2>倒数日</h2></div><button class="countdown-add" @click="add"><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><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>
<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><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>{{formatDate(item.display_date)}}<span v-if="item.repeat_rule!=='none'"> · {{repeatLabel(item.repeat_rule)}}</span></p>
<p v-if="item.lunar_text"><span class="countdown-type">{{item.lunar_text}}</span><span v-if="item.ignore_year"> · 每年农历</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="add"><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>{{formatDate(item.event_date)}}</small><button @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)"><Trash2/>永久删除</button></article></div>
<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>
<label>日期<input v-model="form.event_date" type="date" required></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>
</form>
</div>
<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>{{formatDate(item.event_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template></small><button @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)"><Trash2/>永久删除</button></article></div>
<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>
<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="open=false">取消</button><button class="primary-small">保存</button></footer>
</form></div>
</section>
</template>
+3 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { countdownDayText, countdownKindLabel, dateKey, defaultView, habitWeek, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, shouldToggleRowSwipe } from './mvp-utils'
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, defaultView, habitWeek, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, shouldToggleRowSwipe } from './mvp-utils'
describe('MVP view utilities', () => {
it('formats a local date as YYYY-MM-DD', () => {
@@ -70,6 +70,8 @@ describe('MVP view utilities', () => {
expect(countdownKindLabel('countdown')).toBe('倒数日')
expect(countdownKindLabel('anniversary')).toBe('纪念日')
expect(countdownKindLabel('birthday')).toBe('生日')
expect(calendarModeLabel('solar')).toBe('公历')
expect(calendarModeLabel('lunar')).toBe('农历')
})
it('toggles a row for a deliberate mostly-horizontal swipe', () => {
+4
View File
@@ -86,6 +86,10 @@ export function countdownKindLabel(kind: string) {
return ({ countdown: '倒数日', anniversary: '纪念日', birthday: '生日' } as Record<string, string>)[kind] ?? '倒数日'
}
export function calendarModeLabel(mode: string) {
return mode === 'lunar' ? '农历' : '公历'
}
export function shouldToggleRowSwipe(deltaX: number, deltaY: number) {
return deltaX >= 64 && deltaX > Math.abs(deltaY) * 1.5
}