feat: initial lifelog project
- 待办事项(支持优先级、截止日期) - 每日记录(上午/下午/晚上分条目) - Go后端 + Gin + GORM + MySQL - React前端 + TailwindCSS
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user