fix: habits view api fields + remove multi-select
ci / docker (push) Successful in 7m7s

This commit is contained in:
2026-09-05 18:01:17 +08:00
parent 799b522f9b
commit e8c49b045f
3 changed files with 27 additions and 29 deletions
+24 -5
View File
@@ -10,7 +10,7 @@ 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; type?:string; target?:number; unit?:string; logs?: Array<{date:string;value:number|boolean}>; stats?: Record<string,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 Session = { id:string; created_at?:string; last_seen_at?:string; current?:boolean; user_agent?:string }
const props = defineProps<{ view:View; tasks:Task[] }>()
const emit = defineEmits<{ changed:[]; notice:[message:string] }>()
@@ -19,21 +19,40 @@ const busy = ref(false), error = ref(''), habitName = ref(''), habitType = ref('
const importFile = ref<File|null>(null), importPreview = ref<any>(null), restoreFile = ref<File|null>(null)
const week = computed(() => habitWeek())
function formatErrorMessage(detail: unknown): string {
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
const text = detail
.map((item) => (item && typeof item === 'object' && 'msg' in item && typeof (item as { msg?: unknown }).msg === 'string' ? (item as { msg: string }).msg : String(item)))
.join('')
return text || '请求参数有误'
}
if (detail && typeof detail === 'object') {
const detailObject = detail as Record<string, unknown>
if ('detail' in detailObject) return formatErrorMessage(detailObject.detail)
return JSON.stringify(detail)
}
return '请求失败'
}
async function request(path:string, options:RequestInit={}) {
const headers:Record<string,string> = { ...(options.headers as Record<string,string> || {}) }
if (options.body && !(options.body instanceof FormData)) headers['Content-Type']='application/json'
const csrf = csrfHeader(options.method)
if (csrf['x-csrf-token']) headers['x-csrf-token'] = csrf['x-csrf-token']
const response = await fetch('/api/v1'+path,{ credentials:'include',...options,headers })
if (!response.ok) throw new Error((await response.json().catch(()=>({}))).detail || `请求失败 (${response.status})`)
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(formatErrorMessage((body as { detail?: unknown }).detail))
}
const type=response.headers.get('content-type')||''
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.date===day)}
async function checkIn(h:Habit,day:string,value?:number){await safe(async()=>{await request(`/habits/${h.id}/logs`,{method:'POST',body:JSON.stringify({date:day,value:h.type==='numeric'?(value??h.target??1):!Boolean(logFor(h,day)?.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 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}))
@@ -56,7 +75,7 @@ onMounted(()=>props.view==='habits'?loadHabits():props.view==='settings'?loadSet
<template v-else-if="view==='habits'">
<header class="view-intro"><div><small>今天做一点明天更轻松</small><h2>习惯</h2></div><button class="soft-button" @click="loadHabits"><RefreshCw/>刷新</button></header>
<form class="habit-create" @submit.prevent="addHabit"><input v-model="habitName" placeholder="新习惯名称"><select v-model="habitType"><option value="boolean">完成 / 未完成</option><option value="numeric">数值</option></select><input v-if="habitType==='numeric'" v-model.number="habitTarget" type="number" min="0" step="any" aria-label="目标值"><button><Plus/>添加</button></form>
<div class="habit-list"><article v-for="h in habits" :key="h.id" class="habit-card"><div class="habit-title"><div><h3>{{h.name}}</h3><small v-if="h.stats">连续 {{h.stats.current_streak??0}} 天 · 完成率 {{Math.round((h.stats.completion_rate??0)*100)}}%</small></div><button class="icon ghost" aria-label="删除习惯" @click="deleteHabit(h)"><Trash2/></button></div><div class="week-grid"><div v-for="d in week" :key="dateKey(d)"><small>{{['一','二','三','四','五','六','日'][(d.getDay()+6)%7]}}<br>{{d.getDate()}}</small><button v-if="h.type!=='numeric'" class="habit-check" :class="{done:logFor(h,dateKey(d))?.value}" @click="checkIn(h,dateKey(d))">{{logFor(h,dateKey(d))?.value?'✓':'·'}}</button><input v-else type="number" :value="logFor(h,dateKey(d))?.value??''" :placeholder="String(h.target??1)" @change="checkIn(h,dateKey(d),Number(($event.target as HTMLInputElement).value))"></div></div></article><div v-if="!habits.length&&!busy" class="empty-panel">还没有习惯从一件容易坚持的小事开始</div></div>
<div class="habit-list"><article v-for="h in habits" :key="h.id" class="habit-card"><div class="habit-title"><div><h3>{{h.name}}</h3><small v-if="h.stats">连续 {{h.stats.current_streak??0}} 天 · 完成率 {{Math.round((h.stats.completion_rate??0)*100)}}%</small></div><button class="icon ghost" aria-label="删除习惯" @click="deleteHabit(h)"><Trash2/></button></div><div class="week-grid"><div v-for="d in week" :key="dateKey(d)"><small>{{['一','二','三','四','五','六','日'][(d.getDay()+6)%7]}}<br>{{d.getDate()}}</small><button v-if="h.kind!=='numeric'" class="habit-check" :class="{done:logFor(h,dateKey(d))?.value}" @click="checkIn(h,dateKey(d))">{{logFor(h,dateKey(d))?.value?'✓':'·'}}</button><input v-else type="number" :value="logFor(h,dateKey(d))?.value??''" :placeholder="String(h.target??1)" @change="checkIn(h,dateKey(d),Number(($event.target as HTMLInputElement).value))"></div></div></article><div v-if="!habits.length&&!busy" class="empty-panel">还没有习惯从一件容易坚持的小事开始</div></div>
</template>
<template v-else>
<header class="view-intro"><div><small>备份迁移与安全</small><h2>设置与数据</h2></div></header>