S SmartDocs
Série: React.js javascript 376 lignes · Mis à jour 2026-04-02

Project_TodoApp.jsx

React.js/lessons/05_state_useState/projects/Project_TodoApp.jsx

/**
 * Project: 待辦事項 App — useState 練習
 *
 * 學習重點:
 * - useState 管理多種狀態(字串、陣列、物件)
 * - 不可變性更新(新增、刪除、修改陣列中的物件)
 * - 函式形式的 setState
 * - 派生狀態(過濾、統計)
 *
 * 使用方式:將此檔案內容複製到你的 App.jsx 中
 */

import { useState } from 'react';

function TodoApp() {
  const [todos, setTodos] = useState([
    { id: 1, text: '學習 React 基礎', done: true, priority: 'high', createdAt: '2024-01-10' },
    { id: 2, text: '練習 useState Hook', done: false, priority: 'high', createdAt: '2024-01-11' },
    { id: 3, text: '閱讀 React 官方文件', done: false, priority: 'medium', createdAt: '2024-01-12' },
    { id: 4, text: '建立第一個專案', done: false, priority: 'low', createdAt: '2024-01-13' },
  ]);
  const [inputValue, setInputValue] = useState('');
  const [priority, setPriority] = useState('medium');
  const [filter, setFilter] = useState('all');
  const [editingId, setEditingId] = useState(null);
  const [editText, setEditText] = useState('');

  // 新增
  const addTodo = () => {
    if (!inputValue.trim()) return;
    setTodos((prev) => [
      ...prev,
      {
        id: Date.now(),
        text: inputValue.trim(),
        done: false,
        priority,
        createdAt: new Date().toISOString().split('T')[0],
      },
    ]);
    setInputValue('');
  };

  // 切換完成
  const toggleTodo = (id) => {
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );
  };

  // 刪除
  const deleteTodo = (id) => {
    setTodos((prev) => prev.filter((t) => t.id !== id));
  };

  // 開始編輯
  const startEditing = (todo) => {
    setEditingId(todo.id);
    setEditText(todo.text);
  };

  // 儲存編輯
  const saveEdit = (id) => {
    if (!editText.trim()) return;
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, text: editText.trim() } : t))
    );
    setEditingId(null);
    setEditText('');
  };

  // 清除已完成
  const clearCompleted = () => {
    setTodos((prev) => prev.filter((t) => !t.done));
  };

  // 全部標為完成
  const toggleAll = () => {
    const allDone = todos.every((t) => t.done);
    setTodos((prev) => prev.map((t) => ({ ...t, done: !allDone })));
  };

  // 過濾
  const filteredTodos = todos.filter((t) => {
    if (filter === 'active') return !t.done;
    if (filter === 'completed') return t.done;
    return true;
  });

  // 統計
  const stats = {
    total: todos.length,
    active: todos.filter((t) => !t.done).length,
    completed: todos.filter((t) => t.done).length,
  };

  const priorityConfig = {
    high: { label: '高', color: '#ef4444', bg: '#fef2f2' },
    medium: { label: '中', color: '#f59e0b', bg: '#fffbeb' },
    low: { label: '低', color: '#22c55e', bg: '#f0fdf4' },
  };

  return (
    <div style={{
      maxWidth: '580px',
      margin: '32px auto',
      fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif",
      padding: '0 16px',
    }}>
      {/* 標題 */}
      <div style={{ textAlign: 'center', marginBottom: '32px' }}>
        <h1 style={{ margin: '0 0 4px', color: '#0f172a', fontSize: '2rem' }}>
          Todo List
        </h1>
        <p style={{ margin: 0, color: '#94a3b8', fontSize: '0.9rem' }}>
          {stats.active} 項待完成 · {stats.completed} 項已完成
        </p>
      </div>

      {/* 輸入區 */}
      <div style={{
        display: 'flex',
        gap: '8px',
        marginBottom: '20px',
        backgroundColor: '#f8fafc',
        padding: '12px',
        borderRadius: '12px',
      }}>
        <input
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && addTodo()}
          placeholder="新增待辦事項..."
          style={{
            flex: 1,
            padding: '10px 14px',
            border: '2px solid #e2e8f0',
            borderRadius: '8px',
            fontSize: '0.95rem',
            outline: 'none',
          }}
        />
        <select
          value={priority}
          onChange={(e) => setPriority(e.target.value)}
          style={{
            padding: '8px',
            border: '2px solid #e2e8f0',
            borderRadius: '8px',
            backgroundColor: 'white',
            cursor: 'pointer',
          }}
        >
          <option value="high">高</option>
          <option value="medium">中</option>
          <option value="low">低</option>
        </select>
        <button
          onClick={addTodo}
          disabled={!inputValue.trim()}
          style={{
            padding: '10px 20px',
            backgroundColor: inputValue.trim() ? '#4f46e5' : '#cbd5e1',
            color: 'white',
            border: 'none',
            borderRadius: '8px',
            cursor: inputValue.trim() ? 'pointer' : 'not-allowed',
            fontWeight: 'bold',
            fontSize: '0.95rem',
          }}
        >
          新增
        </button>
      </div>

      {/* 篩選列 */}
      <div style={{
        display: 'flex',
        justifyContent: 'space-between',
        alignItems: 'center',
        marginBottom: '16px',
      }}>
        <div style={{ display: 'flex', gap: '4px' }}>
          {[
            { key: 'all', label: '全部' },
            { key: 'active', label: '未完成' },
            { key: 'completed', label: '已完成' },
          ].map((f) => (
            <button
              key={f.key}
              onClick={() => setFilter(f.key)}
              style={{
                padding: '6px 14px',
                borderRadius: '8px',
                border: 'none',
                backgroundColor: filter === f.key ? '#4f46e5' : 'transparent',
                color: filter === f.key ? 'white' : '#64748b',
                cursor: 'pointer',
                fontSize: '0.85rem',
                fontWeight: filter === f.key ? '600' : '400',
              }}
            >
              {f.label}
            </button>
          ))}
        </div>
        <div style={{ display: 'flex', gap: '8px' }}>
          {stats.total > 0 && (
            <button
              onClick={toggleAll}
              style={{
                padding: '6px 12px',
                border: '1px solid #e2e8f0',
                borderRadius: '8px',
                backgroundColor: 'white',
                color: '#64748b',
                cursor: 'pointer',
                fontSize: '0.8rem',
              }}
            >
              {todos.every((t) => t.done) ? '取消全選' : '全選'}
            </button>
          )}
          {stats.completed > 0 && (
            <button
              onClick={clearCompleted}
              style={{
                padding: '6px 12px',
                border: '1px solid #fca5a5',
                borderRadius: '8px',
                backgroundColor: 'white',
                color: '#ef4444',
                cursor: 'pointer',
                fontSize: '0.8rem',
              }}
            >
              清除已完成
            </button>
          )}
        </div>
      </div>

      {/* 待辦列表 */}
      <div>
        {filteredTodos.length === 0 ? (
          <div style={{ textAlign: 'center', padding: '48px 0', color: '#cbd5e1' }}>
            <p style={{ fontSize: '2.5rem', margin: '0 0 8px' }}>📋</p>
            <p style={{ margin: 0 }}>
              {filter === 'all' ? '還沒有待辦事項' : '沒有符合篩選的項目'}
            </p>
          </div>
        ) : (
          filteredTodos.map((todo) => (
            <div
              key={todo.id}
              style={{
                display: 'flex',
                alignItems: 'center',
                gap: '12px',
                padding: '12px 16px',
                backgroundColor: 'white',
                borderRadius: '10px',
                marginBottom: '8px',
                border: '1px solid #f1f5f9',
                opacity: todo.done ? 0.6 : 1,
                transition: 'all 0.2s',
              }}
            >
              {/* Checkbox */}
              <input
                type="checkbox"
                checked={todo.done}
                onChange={() => toggleTodo(todo.id)}
                style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: '#4f46e5' }}
              />

              {/* 優先級標籤 */}
              <span
                style={{
                  padding: '2px 8px',
                  borderRadius: '6px',
                  backgroundColor: priorityConfig[todo.priority].bg,
                  color: priorityConfig[todo.priority].color,
                  fontSize: '0.7rem',
                  fontWeight: 'bold',
                  flexShrink: 0,
                }}
              >
                {priorityConfig[todo.priority].label}
              </span>

              {/* 文字 / 編輯 */}
              {editingId === todo.id ? (
                <input
                  value={editText}
                  onChange={(e) => setEditText(e.target.value)}
                  onKeyDown={(e) => e.key === 'Enter' && saveEdit(todo.id)}
                  onBlur={() => saveEdit(todo.id)}
                  autoFocus
                  style={{
                    flex: 1,
                    padding: '4px 8px',
                    border: '2px solid #818cf8',
                    borderRadius: '6px',
                    fontSize: '0.9rem',
                    outline: 'none',
                  }}
                />
              ) : (
                <span
                  onDoubleClick={() => startEditing(todo)}
                  style={{
                    flex: 1,
                    textDecoration: todo.done ? 'line-through' : 'none',
                    color: todo.done ? '#94a3b8' : '#1e293b',
                    cursor: 'text',
                    fontSize: '0.95rem',
                  }}
                  title="雙擊編輯"
                >
                  {todo.text}
                </span>
              )}

              {/* 日期 */}
              <span style={{ color: '#cbd5e1', fontSize: '0.75rem', flexShrink: 0 }}>
                {todo.createdAt}
              </span>

              {/* 刪除 */}
              <button
                onClick={() => deleteTodo(todo.id)}
                style={{
                  background: 'none',
                  border: 'none',
                  color: '#e2e8f0',
                  cursor: 'pointer',
                  fontSize: '1.1rem',
                  padding: '0 4px',
                  flexShrink: 0,
                }}
                onMouseEnter={(e) => (e.target.style.color = '#ef4444')}
                onMouseLeave={(e) => (e.target.style.color = '#e2e8f0')}
              >
                ×
              </button>
            </div>
          ))
        )}
      </div>

      {/* 進度條 */}
      {stats.total > 0 && (
        <div style={{ marginTop: '24px' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8rem', color: '#94a3b8', marginBottom: '6px' }}>
            <span>完成進度</span>
            <span>{Math.round((stats.completed / stats.total) * 100)}%</span>
          </div>
          <div style={{ height: '6px', backgroundColor: '#f1f5f9', borderRadius: '3px', overflow: 'hidden' }}>
            <div
              style={{
                height: '100%',
                width: `${(stats.completed / stats.total) * 100}%`,
                backgroundColor: stats.completed === stats.total ? '#22c55e' : '#4f46e5',
                borderRadius: '3px',
                transition: 'width 0.3s ease',
              }}
            />
          </div>
        </div>
      )}
    </div>
  );
}

export default TodoApp;

Articles liés