feat: initial lifelog project

- 待办事项(支持优先级、截止日期)
- 每日记录(上午/下午/晚上分条目)
- Go后端 + Gin + GORM + MySQL
- React前端 + TailwindCSS
This commit is contained in:
2026-07-28 16:22:46 +08:00
commit d495a2b798
32 changed files with 4501 additions and 0 deletions
+381
View File
@@ -0,0 +1,381 @@
import { useState, useEffect, useCallback } from 'react'
import { todoApi, logApi, type Todo, type DailyLog } from '../api'
type Filter = 'all' | 'active' | 'done'
// 每日记录的单条内容
interface LogItem {
id: string
text: string
}
const parseLog = (content: string): LogItem[] => {
return content.split('\n').filter(l => l.trim()).map((l, i) => ({
id: `item-${i}-${Date.now()}`,
text: l.trim()
}))
}
const formatLog = (items: LogItem[]): string => {
return items.map(i => i.text).join('\n')
}
export default function HomePage() {
const [todos, setTodos] = useState<Todo[]>([])
const [newTitle, setNewTitle] = useState('')
const [newPriority, setNewPriority] = useState(2)
const [newDueDate, setNewDueDate] = useState('')
const [filter, setFilter] = useState<Filter>('all')
const [loading, setLoading] = useState(true)
const today = new Date()
const todayStr = today.toISOString().split('T')[0]
const [date, setDate] = useState(todayStr)
// 每时段内容转为卡片数组
const [morningItems, setMorningItems] = useState<LogItem[]>([])
const [afternoonItems, setAfternoonItems] = useState<LogItem[]>([])
const [eveningItems, setEveningItems] = useState<LogItem[]>([])
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [logLoading, setLogLoading] = useState(true)
useEffect(() => {
Promise.all([loadTodos(), loadLog(date)]).finally(() => setLoading(false))
}, [])
const loadTodos = async () => {
const data = await todoApi.getAll()
setTodos(data)
}
const loadLog = async (d: string) => {
setLogLoading(true)
const data = await logApi.getByDate(d)
setMorningItems(data?.morning ? parseLog(data.morning) : [])
setAfternoonItems(data?.afternoon ? parseLog(data.afternoon) : [])
setEveningItems(data?.evening ? parseLog(data.evening) : [])
setLogLoading(false)
}
// 防抖自动保存(仅今日)
useEffect(() => {
if (date !== todayStr) return
const timer = setTimeout(() => {
if (morningItems.length || afternoonItems.length || eveningItems.length) {
handleSave(true)
}
}, 1000)
return () => clearTimeout(timer)
}, [morningItems, afternoonItems, eveningItems])
const handleSave = useCallback(async (isAuto = false) => {
setSaving(true)
setSaved(false)
await logApi.save(date, {
morning: formatLog(morningItems),
afternoon: formatLog(afternoonItems),
evening: formatLog(eveningItems),
})
setSaving(false)
if (!isAuto) {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
}, [date, morningItems, afternoonItems, eveningItems])
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault()
if (!newTitle.trim()) return
const todo = await todoApi.create(newTitle, newPriority)
setTodos([todo, ...todos])
setNewTitle('')
setNewDueDate('')
}
const handleToggle = async (id: number) => {
const updated = await todoApi.toggle(id)
setTodos(todos.map(t => t.id === id ? updated : t))
}
const handleDelete = async (id: number) => {
await todoApi.delete(id)
setTodos(todos.filter(t => t.id !== id))
}
const priorityDot = (p: number) => {
const colors = ['bg-red-400', 'bg-yellow-400', 'bg-green-400']
return <span className={`w-2 h-2 rounded-full ${colors[p - 1] || 'bg-slate-300'} flex-shrink-0`} />
}
const isOverdue = (dueDate: string | null) => {
if (!dueDate) return false
return new Date(dueDate + 'T23:59:59') < new Date(todayStr + 'T23:59:59')
}
const isDueToday = (dueDate: string | null) => {
if (!dueDate) return false
return dueDate === todayStr
}
const formatDueDate = (dueDate: string | null) => {
if (!dueDate) return null
const d = new Date(dueDate + 'T00:00:00')
const diff = Math.round((d.getTime() - today.getTime()) / 86400000)
if (diff === 0) return '今天'
if (diff === 1) return '明天'
if (diff === -1) return '昨天'
if (diff > 0) return `${diff}天后`
return `${Math.abs(diff)}天前`
}
const filteredTodos = todos.filter(t => {
if (filter === 'active') return !t.done
if (filter === 'done') return t.done
return true
})
const pendingCount = todos.filter(t => !t.done).length
const doneCount = todos.filter(t => t.done).length
const hasLogContent = morningItems.length || afternoonItems.length || eveningItems.length
const dateLabel = date === todayStr ? '今天' : date
// 每日记录卡片组件
const LogSection = ({ label, icon, items, onChange }: {
label: string
icon: string
items: LogItem[]
onChange: (items: LogItem[]) => void
}) => {
const [newItemText, setNewItemText] = useState('')
const addItem = () => {
if (!newItemText.trim()) return
onChange([...items, { id: `item-${Date.now()}`, text: newItemText.trim() }])
setNewItemText('')
}
const updateItem = (id: string, text: string) => {
onChange(items.map(i => i.id === id ? { ...i, text } : i))
}
const deleteItem = (id: string) => {
onChange(items.filter(i => i.id !== id))
}
return (
<div>
<label className="block text-sm font-medium text-slate-600 mb-2">{icon} {label}</label>
<div className="space-y-1.5">
{items.map(item => (
<div key={item.id} className="group flex items-start gap-2 bg-slate-50 rounded-xl px-3 py-2 hover:bg-slate-100 transition">
<span className="text-slate-300 mt-0.5 text-xs flex-shrink-0"></span>
<textarea
value={item.text}
onChange={e => updateItem(item.id, e.target.value)}
rows={1}
className="flex-1 bg-transparent text-sm text-slate-700 resize-none focus:outline-none leading-relaxed"
placeholder="输入内容..."
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
addItem()
}
}}
/>
<button
onClick={() => deleteItem(item.id)}
className="text-slate-300 hover:text-red-400 transition text-xs opacity-0 group-hover:opacity-100 mt-0.5 flex-shrink-0"
>
</button>
</div>
))}
{/* 添加新条目 */}
<div className="flex items-center gap-2">
<input
type="text"
value={newItemText}
onChange={e => setNewItemText(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
addItem()
}
}}
placeholder="按回车添加新条目..."
className="flex-1 text-sm px-3 py-2 border border-dashed border-slate-200 rounded-xl focus:outline-none focus:border-blue-400 bg-white transition"
/>
</div>
</div>
</div>
)
}
return (
<div className="max-w-5xl mx-auto">
{/* 顶部概览 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-slate-800">📒 LifeLog</h1>
<div className="flex items-center gap-3 text-sm text-slate-500">
<span className="bg-blue-50 text-blue-600 px-2 py-0.5 rounded-full font-medium">
{pendingCount}
</span>
<span className="bg-green-50 text-green-600 px-2 py-0.5 rounded-full font-medium">
{doneCount}
</span>
<span className={`px-2 py-0.5 rounded-full font-medium ${hasLogContent ? 'bg-amber-50 text-amber-600' : 'bg-slate-100 text-slate-400'}`}>
{dateLabel} {hasLogContent ? '已记录' : '未记录'}
</span>
</div>
</div>
<button
onClick={() => { setDate(todayStr); loadLog(todayStr) }}
className="text-sm text-blue-500 hover:text-blue-600 hover:underline"
>
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 左侧:待办事项 */}
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-slate-800">📋 </h2>
<div className="flex gap-1 bg-slate-100 rounded-lg p-0.5">
{([['all', '全部'], ['active', '进行中'], ['done', '已完成']] as [Filter, string][]).map(([key, label]) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-3 py-1 rounded-md text-xs font-medium transition ${
filter === key ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
{label}
</button>
))}
</div>
</div>
<form onSubmit={handleAdd} className="mb-4 space-y-2">
<input
type="text"
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
placeholder="输入新事项,按回车添加..."
className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400 text-sm"
/>
<div className="flex items-center gap-2">
<select
value={newPriority}
onChange={e => setNewPriority(Number(e.target.value))}
className="px-2 py-1.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400 text-sm"
>
<option value={1}>🔴</option>
<option value={2}>🟡</option>
<option value={3}>🟢</option>
</select>
<input
type="date"
value={newDueDate}
onChange={e => setNewDueDate(e.target.value)}
className="flex-1 px-2 py-1.5 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400 text-sm"
/>
<button
type="submit"
className="px-4 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition text-sm font-medium"
>
</button>
</div>
</form>
<div className="space-y-1 max-h-80 overflow-y-auto">
{loading ? (
<div className="text-center text-slate-400 text-sm py-8">...</div>
) : filteredTodos.length === 0 ? (
<div className="text-center py-8">
<div className="text-4xl mb-2"></div>
<p className="text-slate-400 text-sm">
{filter === 'all' ? '暂无待办事项' : filter === 'active' ? '太棒了,所有事都做完了!' : '还没有已完成的事项'}
</p>
</div>
) : (
filteredTodos.map(todo => (
<div
key={todo.id}
className={`flex items-center gap-3 p-3 rounded-xl transition group hover:bg-slate-50 ${todo.done ? 'opacity-60' : ''}`}
>
<input
type="checkbox"
checked={todo.done}
onChange={() => handleToggle(todo.id)}
className="w-4 h-4 rounded accent-blue-500 flex-shrink-0 cursor-pointer"
/>
<div className="flex-1 min-w-0">
<span className={`text-sm block ${todo.done ? 'line-through text-slate-400' : 'text-slate-700'}`}>
{todo.title}
</span>
{todo.due_date && (
<span className={`text-xs mt-0.5 block ${
todo.done ? 'text-slate-400' : isOverdue(todo.due_date) ? 'text-red-500 font-medium' : isDueToday(todo.due_date) ? 'text-amber-500 font-medium' : 'text-slate-400'
}`}>
📅 {formatDueDate(todo.due_date)}
</span>
)}
</div>
{priorityDot(todo.priority)}
<button
onClick={() => handleDelete(todo.id)}
className="text-slate-300 hover:text-red-400 transition text-xs opacity-0 group-hover:opacity-100"
>
</button>
</div>
))
)}
</div>
</div>
{/* 右侧:每日记录 */}
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-slate-800">📓 </h2>
<div className="flex items-center gap-2">
{saved && <span className="text-green-500 text-xs animate-pulse"> </span>}
{saving && <span className="text-slate-400 text-xs">...</span>}
{date !== todayStr && (
<button
onClick={() => handleSave()}
className="px-3 py-1 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition text-xs font-medium"
>
</button>
)}
</div>
</div>
<div className="flex items-center gap-2 mb-4">
<input
type="date"
value={date}
onChange={e => { setDate(e.target.value); loadLog(e.target.value) }}
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400 text-sm"
/>
</div>
{logLoading ? (
<div className="text-center text-slate-400 text-sm py-8">...</div>
) : (
<div className="space-y-4 max-h-[500px] overflow-y-auto">
<LogSection label="上午" icon="🌅" items={morningItems} onChange={setMorningItems} />
<LogSection label="下午" icon="☀️" items={afternoonItems} onChange={setAfternoonItems} />
<LogSection label="晚上" icon="🌙" items={eveningItems} onChange={setEveningItems} />
</div>
)}
</div>
</div>
</div>
)
}