This commit is contained in:
+32
-11
@@ -9,12 +9,12 @@ import { dateKey, habitWeek, mergePage, moveDueDate } from './lib/mvp-utils'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
|
||||
type View = 'calendar'|'habits'|'settings'
|
||||
type Task = { id:string; title:string; due_at:string|null; version:number }
|
||||
type Habit = { id:string; name:string; kind?:string; target?:number; unit?:string; logs?: Array<{day:string;value:number|boolean}>; stats?: Record<string,number> }
|
||||
type Task = { id:string; title:string; due_at:string|null; version:number; recurrence_id?:string|null }
|
||||
type Habit = { id:string; name:string; kind?:string; target?:number; max_value?:number|null; unit?:string; cells?: Array<{day:string; scheduled?:boolean; paused?:boolean; value:number|boolean}>; stats?: Record<string,number> }
|
||||
type Session = { id:string; created_at?:string; last_seen_at?:string; current?:boolean; user_agent?:string }
|
||||
const props = defineProps<{ view:View; tasks:Task[] }>()
|
||||
const props = defineProps<{ view:View }>()
|
||||
const emit = defineEmits<{ changed:[]; notice:[message:string] }>()
|
||||
const habits = ref<Habit[]>([]), sessions = ref<Session[]>([]), audit = ref<any[]>([])
|
||||
const habits = ref<Habit[]>([]), calendarTasks = ref<Task[]>([]), sessions = ref<Session[]>([]), audit = ref<any[]>([])
|
||||
const busy = ref(false), error = ref(''), habitName = ref(''), habitType = ref('boolean'), habitTarget = ref(1)
|
||||
const importFile = ref<File|null>(null), importPreview = ref<any>(null), restoreFile = ref<File|null>(null)
|
||||
const week = computed(() => habitWeek())
|
||||
@@ -49,13 +49,34 @@ async function request(path:string, options:RequestInit={}) {
|
||||
return response.status===204?null:type.includes('json')?response.json():response.blob()
|
||||
}
|
||||
async function safe(work:()=>Promise<void>) { busy.value=true; error.value=''; try{await work()}catch(e){error.value=e instanceof Error?e.message:'请求失败'}finally{busy.value=false} }
|
||||
async function loadHabits(){ await safe(async()=>{const page=mergePage<Habit>(await request('/habits')); habits.value=page.items; await Promise.all(habits.value.map(async h=>{try{const [logs,stats]=await Promise.all([request(`/habits/${h.id}/logs?from=${dateKey(week.value[0])}&to=${dateKey(week.value[6])}`),request(`/habits/${h.id}/stats`)]);h.logs=mergePage<any>(logs).items;h.stats=stats}catch{/* optional enrichment */}}))}) }
|
||||
async function addHabit(){if(!habitName.value.trim())return;await safe(async()=>{await request('/habits',{method:'POST',body:JSON.stringify({name:habitName.value.trim(),type:habitType.value,target:habitTarget.value})});habitName.value='';await loadHabits();emit('notice','习惯已创建')})}
|
||||
function logFor(h:Habit,day:string){return h.logs?.find(l=>l.day===day)}
|
||||
async function checkIn(h:Habit,day:string,value?:number){await safe(async()=>{await request(`/habits/${h.id}/logs`,{method:'POST',body:JSON.stringify({day,value:h.kind==='numeric'?(value??h.target??1):!Boolean(logFor(h,day)?.value)})});await loadHabits();emit('notice','打卡已记录')})}
|
||||
async function loadHabits(){ await safe(async()=>{const grid=await request(`/habits/grid?week=${dateKey(week.value[0])}`); habits.value=(grid as {habits?: Habit[]}).habits ?? [];}) }
|
||||
async function addHabit(){if(!habitName.value.trim())return;await safe(async()=>{await request('/habits',{method:'POST',body:JSON.stringify({name:habitName.value.trim(),kind:habitType.value,target:habitTarget.value,schedule_type:'daily'})});habitName.value='';await loadHabits();emit('notice','习惯已创建')})}
|
||||
function logFor(h:Habit,day:string){return (h.cells??[]).find(c=>c.day===day)}
|
||||
async function checkIn(h:Habit,day:string,value?:number){await safe(async()=>{const current=logFor(h,day);const next=h.kind==='numeric'?(value??h.target??1):(current?.value?0:1);await request(`/habits/${h.id}/logs/${day}`,{method:'PUT',body:JSON.stringify({value:next})});await loadHabits();emit('notice','打卡已记录')})}
|
||||
async function deleteHabit(h:Habit){if(!confirm(`删除习惯“${h.name}”?`))return;await safe(async()=>{await request(`/habits/${h.id}`,{method:'DELETE'});await loadHabits()})}
|
||||
async function moveTask(arg:EventDropArg){const task=props.tasks.find(t=>t.id===arg.event.id);if(!task)return;const previous=task.due_at;try{await request(`/tasks/${task.id}`,{method:'PATCH',body:JSON.stringify({due_at:moveDueDate(previous,arg.event.startStr.slice(0,10)),version:task.version})});emit('changed');const undo=confirm('日期已更新。要撤销吗?');if(undo){const fresh=props.tasks.find(t=>t.id===task.id) || task;await request(`/tasks/${task.id}`,{method:'PATCH',body:JSON.stringify({due_at:previous,version:fresh.version})});emit('changed')}}catch(e){arg.revert();error.value=e instanceof Error?e.message:'移动失败'}}
|
||||
const calendarOptions=computed<CalendarOptions>(()=>({plugins:[dayGridPlugin,interactionPlugin],initialView:'dayGridMonth',locale:'zh-cn',firstDay:1,height:'auto',editable:true,dayMaxEvents:4,headerToolbar:{left:'prev,next today',center:'title',right:''},events:props.tasks.filter(t=>t.due_at).map(t=>({id:t.id,title:t.title,start:t.due_at!})),eventDrop:moveTask}))
|
||||
const currentCalendarRange = ref<{ startStr:string; endStr:string }|null>(null)
|
||||
async function loadCalendar(range?: { startStr?: string; endStr?: string }) {
|
||||
if (range?.startStr && range?.endStr) currentCalendarRange.value = { startStr: range.startStr, endStr: range.endStr }
|
||||
const now = new Date()
|
||||
const activeRange = currentCalendarRange.value
|
||||
const start = activeRange?.startStr.slice(0, 10) ?? dateKey(new Date(now.getFullYear(), now.getMonth(), 1))
|
||||
const endExclusiveKey = activeRange?.endStr.slice(0, 10) ?? dateKey(new Date(now.getFullYear(), now.getMonth() + 1, 1))
|
||||
const endExclusive = new Date(`${endExclusiveKey}T00:00:00`)
|
||||
endExclusive.setDate(endExclusive.getDate() - 1)
|
||||
const end = dateKey(endExclusive)
|
||||
await safe(async()=>{
|
||||
const rows = await request(`/calendar?start=${start}&end=${end}`) as Array<{id?:string;task_id:string;recurrence_id?:string|null;title:string;due_at?:string;occurrence_at:string;version?:number}>
|
||||
calendarTasks.value = rows.map(row=>({
|
||||
id: row.recurrence_id ? `${row.task_id}:${row.occurrence_at}` : (row.id ?? row.task_id),
|
||||
title: row.title,
|
||||
due_at: row.due_at ?? row.occurrence_at,
|
||||
version: row.version ?? 0,
|
||||
recurrence_id: row.recurrence_id,
|
||||
}))
|
||||
})
|
||||
}
|
||||
async function moveTask(arg:EventDropArg){const task=calendarTasks.value.find(t=>t.id===arg.event.id);if(!task||task.version===0)return;const previous=task.due_at;try{await request(`/tasks/${task.id}`,{method:'PATCH',body:JSON.stringify({due_at:moveDueDate(previous,arg.event.startStr.slice(0,10)),version:task.version})});await loadCalendar();emit('notice','日期已更新')}catch(e){arg.revert();error.value=e instanceof Error?e.message:'移动失败'}}
|
||||
const calendarOptions=computed<CalendarOptions>(()=>({plugins:[dayGridPlugin,interactionPlugin],initialView:'dayGridMonth',locale:'zh-cn',firstDay:1,height:'auto',editable:true,dayMaxEvents:4,headerToolbar:{left:'prev,next today',center:'title',right:''},events:calendarTasks.value.filter(t=>t.due_at).map(t=>({id:t.id,title:t.title,start:t.due_at!,editable:t.version!==0})),datesSet:loadCalendar,eventDrop:moveTask}))
|
||||
async function loadSettings(){await safe(async()=>{const [s,a]=await Promise.all([request('/sessions').catch(()=>[]),request('/audit-logs?limit=20').catch(()=>[])]);sessions.value=mergePage<Session>(s).items;audit.value=mergePage<any>(a).items})}
|
||||
async function revoke(id:string){await safe(async()=>{await request(`/sessions/${id}`,{method:'DELETE'});await loadSettings();emit('notice','会话已撤销')})}
|
||||
function downloadBlob(blob:Blob,name:string){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
||||
@@ -69,7 +90,7 @@ onMounted(()=>props.view==='habits'?loadHabits():props.view==='settings'?loadSet
|
||||
<section class="mvp-view" :class="{loading:busy}">
|
||||
<p v-if="error" class="inline-error">{{error}}</p>
|
||||
<template v-if="view==='calendar'">
|
||||
<header class="view-intro"><div><small>拖动任务即可改期</small><h2>月历</h2></div><span>{{tasks.filter(t=>t.due_at).length}} 个已排期任务</span></header>
|
||||
<header class="view-intro"><div><small>拖动任务即可改期</small><h2>月历</h2></div><span>{{calendarTasks.length}} 个已排期任务</span></header>
|
||||
<div class="calendar-card"><FullCalendar :options="calendarOptions" /></div>
|
||||
</template>
|
||||
<template v-else-if="view==='habits'">
|
||||
|
||||
Reference in New Issue
Block a user