2026-03-08 01:34:54 +08:00
|
|
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
|
|
|
|
import { Plus, Trash2, ChevronLeft, ChevronRight, ListTodo } from 'lucide-react'
|
|
|
|
|
import { getTodoData, addTodoItem, toggleTodoItem, updateTodoItem, deleteTodoItem } from './api'
|
|
|
|
|
import { WEEK_DAYS } from '@shared/constants'
|
|
|
|
|
import { getTodayDate, getTomorrowDate } from '@shared/utils/date'
|
|
|
|
|
import type { DayTodo } from './types'
|
|
|
|
|
|
|
|
|
|
const formatDateDisplay = (dateStr: string): string => {
|
|
|
|
|
const [year, month, day] = dateStr.split('-')
|
|
|
|
|
const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day))
|
|
|
|
|
const weekDay = WEEK_DAYS[date.getDay()]
|
|
|
|
|
return `${parseInt(month)}月${parseInt(day)}日 ${weekDay}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isToday = (dateStr: string): boolean => {
|
|
|
|
|
return dateStr === getTodayDate()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isTomorrow = (dateStr: string): boolean => {
|
|
|
|
|
return dateStr === getTomorrowDate()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isEditable = (dateStr: string): boolean => {
|
|
|
|
|
const today = getTodayDate()
|
|
|
|
|
return dateStr >= today
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const TodoPage: React.FC = () => {
|
|
|
|
|
const [dayTodos, setDayTodos] = useState<DayTodo[]>([])
|
|
|
|
|
const [currentYear, setCurrentYear] = useState(new Date().getFullYear())
|
|
|
|
|
const [currentMonth, setCurrentMonth] = useState(new Date().getMonth() + 1)
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
const [editingItem, setEditingItem] = useState<{ date: string; index: number } | null>(null)
|
|
|
|
|
const [editContent, setEditContent] = useState('')
|
|
|
|
|
const [addingDate, setAddingDate] = useState<string | null>(null)
|
|
|
|
|
const [newTodoContent, setNewTodoContent] = useState('')
|
|
|
|
|
const [isInitialLoad, setIsInitialLoad] = useState(true)
|
2026-03-09 15:14:23 +08:00
|
|
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
|
const editInputRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
|
|
|
|
|
|
const adjustTextAreaHeight = (el: HTMLTextAreaElement) => {
|
|
|
|
|
const baseHeight = 28 // 1.75rem = 28px
|
|
|
|
|
el.style.height = baseHeight + 'px'
|
|
|
|
|
el.style.height = Math.max(el.scrollHeight, baseHeight) + 'px'
|
|
|
|
|
}
|
2026-03-08 01:34:54 +08:00
|
|
|
|
|
|
|
|
const loadTodoData = useCallback(async (year: number, month: number, isInitial: boolean = false) => {
|
|
|
|
|
if (isInitial) {
|
|
|
|
|
setLoading(true)
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const data = await getTodoData(year, month)
|
|
|
|
|
setDayTodos(data.dayTodos)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to load TODO:', error)
|
|
|
|
|
setDayTodos([])
|
|
|
|
|
} finally {
|
|
|
|
|
if (isInitial) {
|
|
|
|
|
setLoading(false)
|
|
|
|
|
setIsInitialLoad(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadTodoData(currentYear, currentMonth, isInitialLoad)
|
|
|
|
|
}, [currentYear, currentMonth, loadTodoData, isInitialLoad])
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (addingDate && inputRef.current) {
|
|
|
|
|
inputRef.current.focus()
|
2026-03-09 15:14:23 +08:00
|
|
|
adjustTextAreaHeight(inputRef.current)
|
2026-03-08 01:34:54 +08:00
|
|
|
}
|
|
|
|
|
}, [addingDate])
|
|
|
|
|
|
2026-03-09 15:14:23 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (editingItem && editInputRef.current) {
|
|
|
|
|
adjustTextAreaHeight(editInputRef.current)
|
|
|
|
|
}
|
|
|
|
|
}, [editingItem])
|
|
|
|
|
|
2026-03-08 01:34:54 +08:00
|
|
|
const handlePrevMonth = () => {
|
|
|
|
|
if (currentMonth === 1) {
|
|
|
|
|
setCurrentMonth(12)
|
|
|
|
|
setCurrentYear(currentYear - 1)
|
|
|
|
|
} else {
|
|
|
|
|
setCurrentMonth(currentMonth - 1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleNextMonth = () => {
|
|
|
|
|
if (currentMonth === 12) {
|
|
|
|
|
setCurrentMonth(1)
|
|
|
|
|
setCurrentYear(currentYear + 1)
|
|
|
|
|
} else {
|
|
|
|
|
setCurrentMonth(currentMonth + 1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleStartAdd = (date: string) => {
|
|
|
|
|
setAddingDate(date)
|
|
|
|
|
setNewTodoContent('')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleCancelAdd = () => {
|
|
|
|
|
setAddingDate(null)
|
|
|
|
|
setNewTodoContent('')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleAddTodo = async (date: string, continueAdding: boolean = false) => {
|
|
|
|
|
const content = newTodoContent.trim()
|
|
|
|
|
if (!content) {
|
|
|
|
|
setAddingDate(null)
|
|
|
|
|
setNewTodoContent('')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const data = await addTodoItem(currentYear, currentMonth, date, content)
|
|
|
|
|
setDayTodos(data.dayTodos)
|
|
|
|
|
if (continueAdding) {
|
|
|
|
|
setNewTodoContent('')
|
|
|
|
|
} else {
|
|
|
|
|
setAddingDate(null)
|
|
|
|
|
setNewTodoContent('')
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to add TODO:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleToggleTodo = async (date: string, itemIndex: number, completed: boolean) => {
|
|
|
|
|
try {
|
|
|
|
|
const data = await toggleTodoItem(currentYear, currentMonth, date, itemIndex, completed)
|
|
|
|
|
setDayTodos(data.dayTodos)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to toggle TODO:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleStartEdit = (date: string, index: number, content: string) => {
|
|
|
|
|
setEditingItem({ date, index })
|
|
|
|
|
setEditContent(content)
|
|
|
|
|
setAddingDate(null)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleSaveEdit = async () => {
|
|
|
|
|
if (!editingItem) return
|
|
|
|
|
const { date, index } = editingItem
|
|
|
|
|
const newContent = editContent.trim()
|
2026-03-09 15:29:08 +08:00
|
|
|
if (!newContent) {
|
|
|
|
|
try {
|
|
|
|
|
const data = await deleteTodoItem(currentYear, currentMonth, date, index)
|
|
|
|
|
setDayTodos(data.dayTodos)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to delete TODO:', error)
|
|
|
|
|
} finally {
|
|
|
|
|
setEditingItem(null)
|
|
|
|
|
setEditContent('')
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-08 01:34:54 +08:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const data = await updateTodoItem(currentYear, currentMonth, date, index, newContent)
|
|
|
|
|
setDayTodos(data.dayTodos)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to update TODO:', error)
|
|
|
|
|
} finally {
|
|
|
|
|
setEditingItem(null)
|
|
|
|
|
setEditContent('')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleCancelEdit = () => {
|
|
|
|
|
setEditingItem(null)
|
|
|
|
|
setEditContent('')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDeleteTodo = async (date: string, itemIndex: number) => {
|
|
|
|
|
try {
|
|
|
|
|
const data = await deleteTodoItem(currentYear, currentMonth, date, itemIndex)
|
|
|
|
|
setDayTodos(data.dayTodos)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to delete TODO:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent, date: string) => {
|
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
handleAddTodo(date, true)
|
|
|
|
|
} else if (e.key === 'Escape') {
|
|
|
|
|
handleCancelAdd()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleEditKeyDown = (e: React.KeyboardEvent) => {
|
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
handleSaveEdit()
|
|
|
|
|
} else if (e.key === 'Escape') {
|
|
|
|
|
handleCancelEdit()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const todayDate = getTodayDate()
|
|
|
|
|
const tomorrowDate = getTomorrowDate()
|
|
|
|
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
const isCurrentMonth = currentYear === now.getFullYear() && currentMonth === now.getMonth() + 1
|
|
|
|
|
|
|
|
|
|
const allDates = [...new Set([
|
|
|
|
|
...dayTodos.map(d => d.date),
|
|
|
|
|
...(isCurrentMonth ? [todayDate, tomorrowDate] : [])
|
|
|
|
|
])].sort()
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-10 10:49:24 +08:00
|
|
|
<div className="max-w-4xl mx-auto w-full px-14 py-12 pb-40">
|
2026-03-08 01:34:54 +08:00
|
|
|
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-8 flex items-center gap-2">
|
|
|
|
|
<ListTodo className="w-8 h-8" />
|
|
|
|
|
TODO
|
|
|
|
|
</h1>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-center mb-6">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={handlePrevMonth}
|
|
|
|
|
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft size={18} />
|
|
|
|
|
</button>
|
|
|
|
|
<span className="text-base font-medium text-gray-700 dark:text-gray-300 min-w-[80px] text-center">
|
|
|
|
|
{currentYear}年{currentMonth}月
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleNextMonth}
|
|
|
|
|
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<ChevronRight size={18} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{loading && isInitialLoad ? (
|
|
|
|
|
<div className="text-center text-gray-500 dark:text-gray-400 py-12">
|
|
|
|
|
加载中...
|
|
|
|
|
</div>
|
|
|
|
|
) : allDates.length === 0 ? (
|
|
|
|
|
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
|
|
|
|
<ListTodo size={48} className="mx-auto mb-4 opacity-30" />
|
|
|
|
|
<p>暂无计划</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-4 pb-8">
|
|
|
|
|
{allDates.map((date) => {
|
|
|
|
|
const dayTodo = dayTodos.find(d => d.date === date)
|
|
|
|
|
const isTodayDate = isToday(date)
|
|
|
|
|
const isTomorrowDate = isTomorrow(date)
|
|
|
|
|
const canEdit = isEditable(date)
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-10 10:49:24 +08:00
|
|
|
<div key={date} className="pt-4 space-y-2">
|
2026-03-08 01:34:54 +08:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<h3 className={`text-lg font-semibold ${isTodayDate
|
|
|
|
|
? 'text-gray-900 dark:text-gray-100'
|
|
|
|
|
: isTomorrowDate
|
|
|
|
|
? 'text-gray-700 dark:text-gray-300'
|
|
|
|
|
: 'text-gray-500 dark:text-gray-500'
|
|
|
|
|
}`}>
|
|
|
|
|
{formatDateDisplay(date)}
|
|
|
|
|
</h3>
|
|
|
|
|
{isTodayDate && (
|
|
|
|
|
<span className="px-2 py-0.5 text-xs bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-800 rounded-full">
|
|
|
|
|
今天
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{isTomorrowDate && (
|
|
|
|
|
<span className="px-2 py-0.5 text-xs bg-gray-400 dark:bg-gray-600 text-white rounded-full">
|
|
|
|
|
明天
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{canEdit && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => handleStartAdd(date)}
|
|
|
|
|
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
|
|
|
title="添加任务"
|
|
|
|
|
>
|
|
|
|
|
<Plus size={16} />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-09 15:14:23 +08:00
|
|
|
<div className="pl-2 space-y-2">
|
2026-03-08 01:34:54 +08:00
|
|
|
{dayTodo?.items.map((item, index) => (
|
|
|
|
|
<div
|
|
|
|
|
key={item.id}
|
2026-03-09 15:14:23 +08:00
|
|
|
className="flex items-center gap-3 group"
|
2026-03-08 01:34:54 +08:00
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => canEdit && handleToggleTodo(date, index, !item.completed)}
|
|
|
|
|
disabled={!canEdit}
|
|
|
|
|
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors ${item.completed
|
|
|
|
|
? 'bg-gray-600 dark:bg-gray-400 border-gray-600 dark:border-gray-400 text-white'
|
|
|
|
|
: 'border-gray-400 dark:border-gray-500'
|
|
|
|
|
} ${canEdit ? 'hover:border-gray-600 dark:hover:border-gray-400 cursor-pointer' : 'cursor-default'}`}
|
|
|
|
|
>
|
|
|
|
|
{item.completed && (
|
|
|
|
|
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
|
|
|
|
</svg>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{editingItem?.date === date && editingItem?.index === index ? (
|
2026-03-09 15:14:23 +08:00
|
|
|
<textarea
|
|
|
|
|
ref={editInputRef}
|
2026-03-08 01:34:54 +08:00
|
|
|
value={editContent}
|
2026-03-09 15:14:23 +08:00
|
|
|
onChange={(e) => {
|
|
|
|
|
setEditContent(e.target.value)
|
|
|
|
|
adjustTextAreaHeight(e.target)
|
|
|
|
|
}}
|
2026-03-08 01:34:54 +08:00
|
|
|
onKeyDown={handleEditKeyDown}
|
|
|
|
|
onBlur={handleSaveEdit}
|
|
|
|
|
autoFocus
|
2026-03-09 15:14:23 +08:00
|
|
|
rows={1}
|
|
|
|
|
className="flex-1 px-2 bg-transparent border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-gray-400 text-gray-800 dark:text-gray-200 resize-none overflow-hidden align-top"
|
|
|
|
|
style={{ height: '1.75rem' }}
|
2026-03-08 01:34:54 +08:00
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<span
|
2026-03-09 15:14:23 +08:00
|
|
|
onDoubleClick={() => canEdit && !item.completed && handleStartEdit(date, index, item.content)}
|
2026-03-10 10:49:24 +08:00
|
|
|
className={`flex-1 px-2 ${canEdit ? 'cursor-pointer' : 'cursor-default'}
|
2026-03-08 01:34:54 +08:00
|
|
|
} ${item.completed
|
|
|
|
|
? 'text-gray-400 dark:text-gray-500 line-through'
|
|
|
|
|
: 'text-gray-700 dark:text-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{item.content}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-09 15:14:23 +08:00
|
|
|
{canEdit && !item.completed && (
|
2026-03-08 01:34:54 +08:00
|
|
|
<button
|
|
|
|
|
onClick={() => handleDeleteTodo(date, index)}
|
|
|
|
|
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-all text-red-500"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 size={14} />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{canEdit && addingDate === date && (
|
2026-03-09 15:14:23 +08:00
|
|
|
<div className="flex items-center gap-3">
|
2026-03-08 01:34:54 +08:00
|
|
|
<div className="w-4 h-4 rounded-full border-2 border-gray-400 dark:border-gray-500 shrink-0" />
|
2026-03-09 15:14:23 +08:00
|
|
|
<textarea
|
2026-03-08 01:34:54 +08:00
|
|
|
ref={inputRef}
|
|
|
|
|
value={newTodoContent}
|
2026-03-09 15:14:23 +08:00
|
|
|
onChange={(e) => {
|
|
|
|
|
setNewTodoContent(e.target.value)
|
|
|
|
|
adjustTextAreaHeight(e.target)
|
|
|
|
|
}}
|
2026-03-08 01:34:54 +08:00
|
|
|
onKeyDown={(e) => handleKeyDown(e, date)}
|
|
|
|
|
onBlur={() => {
|
|
|
|
|
handleAddTodo(date, false)
|
|
|
|
|
}}
|
|
|
|
|
placeholder="输入任务内容..."
|
2026-03-09 15:14:23 +08:00
|
|
|
rows={1}
|
|
|
|
|
className="flex-1 px-2 bg-transparent border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-gray-400 text-gray-800 dark:text-gray-200 placeholder-gray-400 resize-none overflow-hidden align-top"
|
|
|
|
|
style={{ height: '1.75rem' }}
|
2026-03-08 01:34:54 +08:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|