S SmartDocs
Serie: React.js javascript 315 líneas · Actualizado 2026-04-02

Project_ReduxTodo.jsx

React.js/lessons/19_redux_toolkit/projects/Project_ReduxTodo.jsx

/**
 * Project: Redux Todo App — Redux Toolkit 練習
 *
 * 學習重點:
 * - configureStore 建立 Store
 * - createSlice 定義 reducer 和 actions
 * - useSelector 讀取 state
 * - useDispatch 發送 action
 * - Immer 的直接修改語法
 *
 * 安裝依賴:npm install @reduxjs/toolkit react-redux
 * 使用方式:將此檔案內容複製到你的 App.jsx 中
 */

import { useState } from 'react';
import { configureStore, createSlice } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';

// ============================================================
// Todos Slice
// ============================================================
const todosSlice = createSlice({
  name: 'todos',
  initialState: {
    items: [
      { id: 1, text: '學習 Redux Toolkit', done: true, priority: 'high' },
      { id: 2, text: '建立 Todo App', done: false, priority: 'high' },
      { id: 3, text: '理解 createSlice', done: false, priority: 'medium' },
      { id: 4, text: '練習 useSelector', done: false, priority: 'low' },
    ],
    filter: 'all',
  },
  reducers: {
    addTodo: (state, action) => {
      state.items.push({
        id: Date.now(),
        text: action.payload.text,
        done: false,
        priority: action.payload.priority || 'medium',
      });
    },
    toggleTodo: (state, action) => {
      const todo = state.items.find((t) => t.id === action.payload);
      if (todo) todo.done = !todo.done;
    },
    deleteTodo: (state, action) => {
      state.items = state.items.filter((t) => t.id !== action.payload);
    },
    editTodo: (state, action) => {
      const { id, text } = action.payload;
      const todo = state.items.find((t) => t.id === id);
      if (todo) todo.text = text;
    },
    setFilter: (state, action) => {
      state.filter = action.payload;
    },
    clearCompleted: (state) => {
      state.items = state.items.filter((t) => !t.done);
    },
    toggleAll: (state) => {
      const allDone = state.items.every((t) => t.done);
      state.items.forEach((t) => { t.done = !allDone; });
    },
  },
});

const { addTodo, toggleTodo, deleteTodo, editTodo, setFilter, clearCompleted, toggleAll } = todosSlice.actions;

// ============================================================
// Store
// ============================================================
const store = configureStore({
  reducer: { todos: todosSlice.reducer },
});

// ============================================================
// Components
// ============================================================
function TodoInput() {
  const [text, setText] = useState('');
  const [priority, setPriority] = useState('medium');
  const dispatch = useDispatch();

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!text.trim()) return;
    dispatch(addTodo({ text: text.trim(), priority }));
    setText('');
  };

  return (
    <form onSubmit={handleSubmit} style={{
      display: 'flex', gap: '8px', marginBottom: '20px',
      padding: '12px', backgroundColor: '#f8fafc', borderRadius: '12px',
    }}>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        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', borderRadius: '8px', border: '2px solid #e2e8f0' }}>
        <option value="high">高</option>
        <option value="medium">中</option>
        <option value="low">低</option>
      </select>
      <button type="submit" disabled={!text.trim()} style={{
        padding: '10px 24px', borderRadius: '8px', border: 'none',
        backgroundColor: text.trim() ? '#4f46e5' : '#cbd5e1',
        color: 'white', cursor: text.trim() ? 'pointer' : 'not-allowed',
        fontWeight: 'bold',
      }}>
        新增
      </button>
    </form>
  );
}

function TodoFilters() {
  const dispatch = useDispatch();
  const filter = useSelector((state) => state.todos.filter);
  const items = useSelector((state) => state.todos.items);
  const activeCount = items.filter((t) => !t.done).length;
  const completedCount = items.filter((t) => t.done).length;

  return (
    <div style={{
      display: 'flex', justifyContent: 'space-between', alignItems: 'center',
      marginBottom: '16px', flexWrap: 'wrap', gap: '8px',
    }}>
      <div style={{ display: 'flex', gap: '4px' }}>
        {[
          { key: 'all', label: `全部 (${items.length})` },
          { key: 'active', label: `未完成 (${activeCount})` },
          { key: 'completed', label: `已完成 (${completedCount})` },
        ].map((f) => (
          <button
            key={f.key}
            onClick={() => dispatch(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.8rem',
              fontWeight: filter === f.key ? '600' : '400',
            }}
          >
            {f.label}
          </button>
        ))}
      </div>
      <div style={{ display: 'flex', gap: '8px' }}>
        {items.length > 0 && (
          <button onClick={() => dispatch(toggleAll())} style={{
            padding: '6px 12px', borderRadius: '8px', border: '1px solid #e2e8f0',
            backgroundColor: 'white', color: '#64748b', cursor: 'pointer', fontSize: '0.8rem',
          }}>
            {items.every((t) => t.done) ? '取消全選' : '全選'}
          </button>
        )}
        {completedCount > 0 && (
          <button onClick={() => dispatch(clearCompleted())} style={{
            padding: '6px 12px', borderRadius: '8px', border: '1px solid #fca5a5',
            backgroundColor: 'white', color: '#ef4444', cursor: 'pointer', fontSize: '0.8rem',
          }}>
            清除已完成
          </button>
        )}
      </div>
    </div>
  );
}

function TodoItem({ todo }) {
  const [isEditing, setIsEditing] = useState(false);
  const [editText, setEditText] = useState(todo.text);
  const dispatch = useDispatch();

  const priorityStyles = {
    high: { bg: '#fef2f2', color: '#ef4444', label: '高' },
    medium: { bg: '#fffbeb', color: '#f59e0b', label: '中' },
    low: { bg: '#f0fdf4', color: '#22c55e', label: '低' },
  };
  const ps = priorityStyles[todo.priority];

  const handleSave = () => {
    if (editText.trim()) {
      dispatch(editTodo({ id: todo.id, text: editText.trim() }));
    }
    setIsEditing(false);
  };

  return (
    <div 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,
    }}>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => dispatch(toggleTodo(todo.id))}
        style={{ width: '18px', height: '18px', accentColor: '#4f46e5', cursor: 'pointer' }}
      />
      <span style={{
        padding: '2px 8px', borderRadius: '6px', backgroundColor: ps.bg,
        color: ps.color, fontSize: '0.7rem', fontWeight: 'bold', flexShrink: 0,
      }}>{ps.label}</span>
      {isEditing ? (
        <input
          value={editText}
          onChange={(e) => setEditText(e.target.value)}
          onBlur={handleSave}
          onKeyDown={(e) => e.key === 'Enter' && handleSave()}
          autoFocus
          style={{ flex: 1, padding: '4px 8px', border: '2px solid #818cf8', borderRadius: '6px', outline: 'none' }}
        />
      ) : (
        <span
          onDoubleClick={() => setIsEditing(true)}
          style={{
            flex: 1, textDecoration: todo.done ? 'line-through' : 'none',
            color: todo.done ? '#94a3b8' : '#1e293b', cursor: 'text',
          }}
          title="雙擊編輯"
        >
          {todo.text}
        </span>
      )}
      <button
        onClick={() => dispatch(deleteTodo(todo.id))}
        style={{ background: 'none', border: 'none', color: '#e2e8f0', cursor: 'pointer', fontSize: '1.2rem' }}
        onMouseEnter={(e) => (e.target.style.color = '#ef4444')}
        onMouseLeave={(e) => (e.target.style.color = '#e2e8f0')}
      >
        ×
      </button>
    </div>
  );
}

function TodoList() {
  const items = useSelector((state) => state.todos.items);
  const filter = useSelector((state) => state.todos.filter);

  const filtered = items.filter((t) => {
    if (filter === 'active') return !t.done;
    if (filter === 'completed') return t.done;
    return true;
  });

  if (filtered.length === 0) {
    return (
      <div style={{ textAlign: 'center', padding: '40px', color: '#cbd5e1' }}>
        <p style={{ fontSize: '2rem', margin: '0 0 8px' }}>📋</p>
        <p style={{ margin: 0 }}>沒有待辦事項</p>
      </div>
    );
  }

  return (
    <div>
      {filtered.map((todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </div>
  );
}

function StoreVisualizer() {
  const state = useSelector((s) => s.todos);
  return (
    <div style={{
      marginTop: '24px', padding: '16px', borderRadius: '12px',
      backgroundColor: '#0f172a', color: '#94a3b8', fontFamily: 'monospace',
      fontSize: '0.75rem', lineHeight: 1.6, overflow: 'auto',
    }}>
      <p style={{ color: '#818cf8', margin: '0 0 4px' }}>// Redux Store State</p>
      <pre style={{ margin: 0 }}>{JSON.stringify(state, null, 2)}</pre>
    </div>
  );
}

function TodoApp() {
  return (
    <div style={{
      maxWidth: '600px', margin: '32px auto', padding: '0 16px',
      fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif",
    }}>
      <h1 style={{ color: '#0f172a', marginBottom: '4px' }}>Redux Todo App</h1>
      <p style={{ color: '#94a3b8', marginTop: 0, marginBottom: '24px' }}>使用 Redux Toolkit 管理狀態</p>
      <TodoInput />
      <TodoFilters />
      <TodoList />
      <StoreVisualizer />
    </div>
  );
}

// ============================================================
// App(用 Provider 包裹)
// ============================================================
function App() {
  return (
    <Provider store={store}>
      <TodoApp />
    </Provider>
  );
}

export default App;

Artículos relacionados