fix: harden countdown validation and recurrence
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m23s

This commit is contained in:
2026-09-09 12:40:40 +08:00
parent e163f6f368
commit 489d120159
4 changed files with 288 additions and 40 deletions
+16
View File
@@ -52,6 +52,22 @@ describe('countdown modal accessibility', () => {
expect(source).not.toContain("Promise.all([request('/countdowns'),request('/countdowns?archived=true')])")
})
it('prevents duplicate submits and sends the edit precondition', () => {
expect(source).toContain('if (busy.value) return')
expect(source).toContain('payload.expected_updated_at=editingItem.value?.updated_at')
expect(source).toContain(':disabled="busy"')
})
it('keeps weekly and monthly recurrence available for lunar dates', () => {
expect(source).toContain('<option value="weekly">每周</option>')
expect(source).toContain('<option value="monthly">每月</option>')
})
it('does not repeat the lunar label in the converted date line', () => {
expect(source).toContain("return `${formatDate(item.display_date)}${suffix}`")
expect(source).not.toContain("`${formatDate(item.display_date)} · ${item.lunar_text}${suffix}`")
})
it('keeps the empty state directly actionable', () => {
expect(source).toContain('添加第一个重要日子')
expect(source).toContain('@click="openFromEmpty"')
+8 -5
View File
@@ -8,14 +8,14 @@ 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
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), error = ref('')
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 })
@@ -55,7 +55,7 @@ function primaryDate(item: Countdown) {
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 `${formatDate(item.display_date)}${suffix}`
}
return ''
}
@@ -122,6 +122,7 @@ async function load() {
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
@@ -132,9 +133,11 @@ function applyKindDefaults() {
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) })
@@ -162,7 +165,7 @@ function trapDetailFocus(event: KeyboardEvent) {
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;showAdvanced.value=false;form.value=freshForm();open.value=true;focusDialog()}
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(load)
onBeforeUnmount(() => { previousFocus = null })
@@ -219,7 +222,7 @@ onBeforeUnmount(() => { previousFocus = null })
<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" @click="closeDialog">取消</button><button class="primary-small">保存</button></footer>
<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>