This commit is contained in:
梁泽军 2025-06-17 17:40:11 +08:00
parent f1cf65e7a7
commit 902d8f7284
19 changed files with 1253 additions and 230 deletions

10
client-temp/auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
}

View File

@ -12,7 +12,9 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.0.0",
"@types/react-router-dom": "^5.3.3",
"antd": "^5.26.1",
"axios": "^1.10.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
@ -32,6 +34,8 @@
"tailwindcss": "^3.4.1",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"unplugin-auto-import": "^19.3.0",
"unplugin-auto-import-antd": "0.0.2",
"vite": "^6.3.5"
}
}

181
client-temp/src/App.App.tsx Normal file
View File

@ -0,0 +1,181 @@
import { useState, useEffect } from 'react';
import type { Todo, Priority } from './types/todo';
import { getTodos, addTodo, toggleTodo, deleteTodo, updateTodo } from './api/todo';
import TodoModalForm from './components/TodoModalForm';
import TodoHeader from './components/TodoHeader';
import TodoListContent from './components/TodoListContent';
import { Layout, notification } from 'antd';
const { Content } = Layout;
function App() {
const [todos, setTodos] = useState<Todo[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [currentTodo, setCurrentTodo] = useState<Todo | null>(null);
const [modalTitle, setModalTitle] = useState('');
const [isEditing, setIsEditing] = useState(false); // New state to indicate if we are editing or adding
const [activeTab, setActiveTab] = useState('pending'); // New state for active tab
const [searchTerm, setSearchTerm] = useState(''); // New state for search term
useEffect(() => {
loadTodos();
}, []);
const loadTodos = async () => {
try {
const data = await getTodos();
setTodos(data);
} catch (error) {
console.error('Failed to load todos:', error);
notification.error({
message: '加载待办事项失败',
description: '无法从服务器获取待办事项列表。',
});
}
};
const closeModal = () => {
setIsModalOpen(false);
setCurrentTodo(null);
setIsEditing(false);
};
const openAddModal = () => {
setCurrentTodo(null);
setModalTitle('新增待办事项');
setIsEditing(false);
setIsModalOpen(true);
};
const handleEditClick = (todo: Todo) => {
setCurrentTodo(todo);
setModalTitle('编辑待办事项');
setIsEditing(true);
setIsModalOpen(true);
};
const handleModalSubmit = async (values: {
title: string;
description: string;
priority: Priority;
completed?: boolean;
}) => {
try {
if (currentTodo) {
// Update existing todo
const updatedTodo = await updateTodo(
currentTodo._id,
values.title,
values.description,
values.priority,
values.completed || false // Ensure completed is boolean
);
setTodos(todos.map(todo => (todo._id === updatedTodo._id ? updatedTodo : todo)));
notification.success({
message: '待办事项更新成功',
});
} else {
// Add new todo
const newTodo = await addTodo(values.title, values.description, values.priority);
setTodos([...todos, newTodo]);
notification.success({
message: '待办事项添加成功',
});
}
closeModal();
} catch (error) {
console.error(currentTodo ? 'Failed to update todo:' : 'Failed to add todo:', error);
notification.error({
message: currentTodo ? '更新待办事项失败' : '添加待办事项失败',
description: '请检查您的输入和网络连接。',
});
}
};
const handleToggle = async (_id: string) => {
try {
const updatedTodo = await toggleTodo(_id);
setTodos(todos.map(todo => todo._id === _id ? updatedTodo : todo));
notification.success({
message: '待办事项状态切换成功',
});
} catch (error) {
console.error('Failed to toggle todo:', error);
notification.error({
message: '切换待办事项状态失败',
description: '请稍后再试。',
});
}
};
const handleDelete = async (_id: string) => {
try {
await deleteTodo(_id);
setTodos(todos.filter(todo => todo._id !== _id));
notification.success({
message: '待办事项删除成功',
});
} catch (error) {
console.error('Failed to delete todo:', error);
notification.error({
message: '删除待办事项失败',
description: '请稍后再试。',
});
}
};
const handleTabChange = (key: string) => {
setActiveTab(key);
};
const filteredTodos = todos.filter(todo => {
const matchesSearch = searchTerm === '' ||
todo.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
(todo.description && todo.description.toLowerCase().includes(searchTerm.toLowerCase()));
if (activeTab === 'pending') {
return !todo.completed && matchesSearch;
} else {
return todo.completed && matchesSearch;
}
});
return (
<Layout style={{ minHeight: '100vh' }}>
<TodoHeader
openAddModal={openAddModal}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
/>
<Content style={{ padding: '24px', margin: '0 auto', width: '90%' }}>
<TodoListContent
todos={todos}
handleEditClick={handleEditClick}
handleToggle={handleToggle}
handleDelete={handleDelete}
activeTab={activeTab}
handleTabChange={handleTabChange}
filteredTodos={filteredTodos}
/>
{isModalOpen && (
<TodoModalForm
isVisible={isModalOpen}
title={modalTitle}
initialValues={{
title: currentTodo?.title || '',
description: currentTodo?.description || '',
priority: currentTodo?.priority || 'medium',
completed: currentTodo?.completed || false,
}}
onCancel={closeModal}
onSubmit={handleModalSubmit}
isEditing={isEditing}
/>
)}
</Content>
</Layout>
);
}
export default App;

View File

@ -1,122 +1,87 @@
import { useState, useEffect } from 'react';
import type { Todo } from './types/todo';
import { getTodos, addTodo, toggleTodo, deleteTodo } from './api/todo';
import { priorityColors } from './types/todo';
import { Layout } from 'antd';
import type { Priority, Todo } from './types/todo';
import TodoModalForm from './components/TodoModalForm';
import TodoHeader from './components/TodoHeader';
import TodoListContent from './components/TodoListContent';
import { getPriorityTagColor, getPriorityIcon, priorityLabels } from './utils/priorityUtils';
import { useTodos } from './hooks/useTodos';
import { Typography, Input, Button, Space } from 'antd';
const { Content } = Layout;
const { Text } = Typography;
const { Search } = Input;
function App() {
const [todos, setTodos] = useState<Todo[]>([]);
const [title, setTitle] = useState('');
const [priority, setPriority] = useState<Todo['priority']>('medium');
useEffect(() => {
loadTodos();
}, []);
const loadTodos = async () => {
try {
const data = await getTodos();
setTodos(data);
} catch (error) {
console.error('Failed to load todos:', error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
try {
const newTodo = await addTodo(title, priority);
setTodos([...todos, newTodo]);
setTitle('');
} catch (error) {
console.error('Failed to add todo:', error);
}
};
const handleToggle = async (id: number) => {
try {
const updatedTodo = await toggleTodo(id);
setTodos(todos.map(todo => todo.id === id ? updatedTodo : todo));
} catch (error) {
console.error('Failed to toggle todo:', error);
}
};
const handleDelete = async (id: number) => {
try {
await deleteTodo(id);
setTodos(todos.filter(todo => todo.id !== id));
} catch (error) {
console.error('Failed to delete todo:', error);
}
};
const {
todos,
isModalOpen,
currentTodo,
modalTitle,
isEditing,
activeTab,
searchTerm,
setSearchTerm,
openAddModal,
closeModal,
handleEditClick,
handleModalSubmit,
handleToggle,
handleDelete,
handleTabChange,
filteredTodos,
} = useTodos();
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Todo List</h1>
<form onSubmit={handleSubmit} className="mb-8">
<div className="flex gap-4">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Add a new todo..."
className="flex-1 px-4 py-2 border rounded"
<Layout style={{ minHeight: '100vh' }}>
<TodoHeader />
<Content style={{ padding: '24px', margin: '0 auto',width: '90%', }}>
{/* Title */}
<div style={{ width: '100%', margin: '0 auto' }}>
{/* Search and button */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 0px 24px 0px' }}>
<Search
placeholder="搜索待办事项..."
allowClear
onSearch={setSearchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ width: 300 }}
/>
<select
value={priority}
onChange={(e) => setPriority(e.target.value as Todo['priority'])}
className="px-4 py-2 border rounded"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<button
type="submit"
className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Add
</button>
<Button type="primary" onClick={openAddModal}>
</Button>
</div>
</div>
</form>
<ul className="space-y-4">
{todos.map((todo) => (
<li
key={todo.id}
className="flex items-center justify-between p-4 border rounded"
>
<div className="flex items-center gap-4">
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
className="w-5 h-5"
<TodoListContent
todos={todos}
handleEditClick={handleEditClick}
handleToggle={handleToggle}
handleDelete={handleDelete}
getPriorityTagColor={getPriorityTagColor}
getPriorityIcon={getPriorityIcon}
priorityLabels={priorityLabels}
activeTab={activeTab}
handleTabChange={handleTabChange}
filteredTodos={filteredTodos}
/>
<span
className={`text-lg ${todo.completed ? 'line-through text-gray-500' : ''}`}
>
{todo.title}
</span>
<span
className={`px-2 py-1 rounded text-sm bg-${priorityColors[todo.priority]}-100 text-${priorityColors[todo.priority]}-800`}
>
{todo.priority}
</span>
</div>
<button
onClick={() => handleDelete(todo.id)}
className="px-3 py-1 text-red-500 hover:text-red-600"
>
Delete
</button>
</li>
))}
</ul>
</div>
{isModalOpen && (
<TodoModalForm
isVisible={isModalOpen}
title={modalTitle}
initialValues={{
title: currentTodo?.title || '',
description: currentTodo?.description || '',
priority: currentTodo?.priority || 'medium',
completed: currentTodo?.completed || false,
}}
onCancel={closeModal}
onSubmit={handleModalSubmit}
isEditing={isEditing}
/>
)}
</Content>
</Layout>
);
}

View File

@ -1,13 +1,25 @@
import axios from 'axios';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
baseURL: '/api',
});
api.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
api.interceptors.response.use(
response => response,
error => {
if (error.response && error.response.status === 401) {
localStorage.removeItem('token'); // 清除无效 token
window.location.href = '/login';
}
return Promise.reject(error);

View File

@ -2,20 +2,25 @@ import type { Todo } from '../types/todo';
import { api } from './axios';
export const getTodos = async (): Promise<Todo[]> => {
const response = await api.get('/api/todos');
const response = await api.get('/todos');
return response.data;
};
export const addTodo = async (title: string, priority: Todo['priority']): Promise<Todo> => {
const response = await api.post('/api/todos', { title, priority });
export const addTodo = async (title: string, description: string, priority: Todo['priority']): Promise<Todo> => {
const response = await api.post('/todos', { title, description, priority });
return response.data;
};
export const toggleTodo = async (id: number): Promise<Todo> => {
const response = await api.patch(`/api/todos/${id}/toggle`);
export const updateTodo = async (_id: string, title: string, description: string, priority: Todo['priority'], completed: boolean): Promise<Todo> => {
const response = await api.put(`/todos/${_id}`, { title, description, priority, completed });
return response.data;
};
export const deleteTodo = async (id: number): Promise<void> => {
await api.delete(`/api/todos/${id}`);
export const toggleTodo = async (_id: string): Promise<Todo> => {
const response = await api.patch(`/todos/${_id}/toggle`);
return response.data;
};
export const deleteTodo = async (_id: string): Promise<void> => {
await api.delete(`/todos/${_id}`);
};

View File

@ -0,0 +1,47 @@
import React from 'react';
const AntDesignIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg {...props} width="200px" height="200px" viewBox="0 0 200 200" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink">
<title>Group 28 Copy 5</title>
<desc>Created with Sketch.</desc>
<defs>
<linearGradient x1="62.1023273%" y1="0%" x2="108.19718%" y2="37.8635764%" id="linearGradient-1">
<stop stopColor="#4285EB" offset="0%"></stop>
<stop stopColor="#2EC7FF" offset="100%"></stop>
</linearGradient>
<linearGradient x1="69.644116%" y1="0%" x2="54.0428975%" y2="108.456714%" id="linearGradient-2">
<stop stopColor="#29CDFF" offset="0%"></stop>
<stop stopColor="#148EFF" offset="37.8600687%"></stop>
<stop stopColor="#0A60FF" offset="100%"></stop>
</linearGradient>
<linearGradient x1="69.6908165%" y1="-12.9743587%" x2="16.7228981%" y2="117.391248%" id="linearGradient-3">
<stop stopColor="#FA816E" offset="0%"></stop>
<stop stopColor="#F74A5C" offset="41.472606%"></stop>
<stop stopColor="#F51D2C" offset="100%"></stop>
</linearGradient>
<linearGradient x1="68.1279872%" y1="-35.6905737%" x2="30.4400914%" y2="114.942679%" id="linearGradient-4">
<stop stopColor="#FA8E7D" offset="0%"></stop>
<stop stopColor="#F74A5C" offset="51.2635191%"></stop>
<stop stopColor="#F51D2C" offset="100%"></stop>
</linearGradient>
</defs>
<g id="Page-1" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<g id="logo" transform="translate(-20.000000, -20.000000)">
<g id="Group-28-Copy-5" transform="translate(20.000000, 20.000000)">
<g id="Group-27-Copy-3">
<g id="Group-25" fillRule="nonzero">
<g id="2">
<path d="M91.5880863,4.17652823 L4.17996544,91.5127728 C-0.519240605,96.2081146 -0.519240605,103.791885 4.17996544,108.487227 L91.5880863,195.823472 C96.2872923,200.518814 103.877304,200.518814 108.57651,195.823472 L145.225487,159.204632 C149.433969,154.999611 149.433969,148.181924 145.225487,143.976903 C141.017005,139.771881 134.193707,139.771881 129.985225,143.976903 L102.20193,171.737352 C101.032305,172.906015 99.2571609,172.906015 98.0875359,171.737352 L28.285908,101.993122 C27.1162831,100.824459 27.1162831,99.050775 28.285908,97.8821118 L98.0875359,28.1378823 C99.2571609,26.9692191 101.032305,26.9692191 102.20193,28.1378823 L129.985225,55.8983314 C134.193707,60.1033528 141.017005,60.1033528 145.225487,55.8983314 C149.433969,51.69331 149.433969,44.8756232 145.225487,40.6706018 L108.58055,4.05574592 C103.862049,-0.537986846 96.2692618,-0.500797906 91.5880863,4.17652823 Z" fill="url(#linearGradient-1)"></path>
<path d="M91.5880863,4.17652823 L4.17996544,91.5127728 C-0.519240605,96.2081146 -0.519240605,103.791885 4.17996544,108.487227 L91.5880863,195.823472 C96.2872923,200.518814 103.877304,200.518814 108.57651,195.823472 L145.225487,159.204632 C149.433969,154.999611 149.433969,148.181924 145.225487,143.976903 C141.017005,139.771881 134.193707,139.771881 129.985225,143.976903 L102.20193,171.737352 C101.032305,172.906015 99.2571609,172.906015 98.0875359,171.737352 L28.285908,101.993122 C27.1162831,100.824459 27.1162831,99.050775 28.285908,97.8821118 L98.0875359,28.1378823 C100.999864,25.6271836 105.751642,20.541824 112.729652,19.3524487 C117.915585,18.4685261 123.585219,20.4140239 129.738554,25.1889424 C125.624663,21.0784292 118.571995,14.0340304 108.58055,4.05574592 C103.862049,-0.537986846 96.2692618,-0.500797906 91.5880863,4.17652823 Z" fill="url(#linearGradient-2)"></path>
</g>
<path d="M153.685633,135.854579 C157.894115,140.0596 164.717412,140.0596 168.925894,135.854579 L195.959977,108.842726 C200.659183,104.147384 200.659183,96.5636133 195.960527,91.8688194 L168.690777,64.7181159 C164.472332,60.5180858 157.646868,60.5241425 153.435895,64.7316526 C149.227413,68.936674 149.227413,75.7543607 153.435895,79.9593821 L171.854035,98.3623765 C173.02366,99.5310396 173.02366,101.304724 171.854035,102.473387 L153.685633,120.626849 C149.47715,124.83187 149.47715,131.649557 153.685633,135.854579 Z" fill="url(#linearGradient-3)"></path>
</g>
<ellipse id="Combined-Shape" fill="url(#linearGradient-4)" cx="100.519339" cy="100.436681" rx="23.6001926" ry="23.580786"></ellipse>
</g>
</g>
</g>
</g>
</svg>
);
export { AntDesignIcon };

View File

@ -0,0 +1,72 @@
import React, { useState, useEffect } from 'react';
import { Layout, Typography, Button, Modal, Space, Avatar } from 'antd';
import { ExclamationCircleOutlined, LogoutOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
const { Header } = Layout;
const { Text } = Typography;
interface TodoHeaderProps {
}
const TodoHeader: React.FC<TodoHeaderProps> = () => {
const navigate = useNavigate();
const [userName, setUserName] = useState<string>('');
const [avatarBgColor, setAvatarBgColor] = useState<string>('');
const generateRandomColor = () => {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
};
useEffect(() => {
// In a real application, you would fetch the user's name from an authenticated API
// For now, let's assume the username is stored in localStorage after login
const storedUserName = localStorage.getItem('username'); // Assuming username is stored
setUserName(storedUserName || '用户');
setAvatarBgColor(generateRandomColor());
}, []);
const showConfirmLogout = () => {
Modal.confirm({
title: '确认退出',
icon: <ExclamationCircleOutlined />,
content: '您确定要退出登录吗?',
okText: '确认',
cancelText: '取消',
onOk() {
localStorage.removeItem('token');
localStorage.removeItem('username'); // Also clear username on logout
navigate('/login');
},
onCancel() {
console.log('取消退出');
},
});
};
return (
<Header style={{ background: '#fff', padding: 0, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)' }}>
<div style={{ width: '90%', margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ padding: '0' }}>
<Text strong style={{ fontSize: '24px' }}>Todo List</Text>
</div>
<Space>
<Avatar style={{ backgroundColor: avatarBgColor, verticalAlign: 'middle' }} size="large">
{userName.charAt(0).toUpperCase()}
</Avatar>
<Text>{userName}</Text>
<Button type="text" danger icon={<LogoutOutlined />} onClick={showConfirmLogout}>
</Button>
</Space>
</div>
</Header>
);
};
export default TodoHeader;

View File

@ -0,0 +1,151 @@
import React from 'react';
import { List, Button, Typography, Tag, Space, Popconfirm, Checkbox, Tabs } from 'antd';
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { Todo, Priority } from '../types/todo';
const { Text } = Typography;
interface TodoListContentProps {
todos: Todo[];
handleEditClick: (todo: Todo) => void;
handleToggle: (id: string) => void;
handleDelete: (id: string) => void;
getPriorityTagColor: (priority: Priority) => string;
getPriorityIcon: (priority: Priority) => string;
priorityLabels: { [key in Priority]: string };
activeTab: string;
handleTabChange: (key: string) => void;
filteredTodos: Todo[];
}
const TodoListContent: React.FC<TodoListContentProps> = ({
todos,
handleEditClick,
handleToggle,
handleDelete,
getPriorityTagColor,
getPriorityIcon,
priorityLabels,
activeTab,
handleTabChange,
filteredTodos,
}) => {
const items = [
{
key: 'pending',
label: '待办',
children: (
<List
locale={{ emptyText: '暂无待办事项' }}
bordered
dataSource={filteredTodos.filter((todo) => !todo.completed)}
renderItem={(todo) => (
<List.Item
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
actions={[
<Button type="link" icon={<EditOutlined />} onClick={() => handleEditClick(todo)}>
</Button>,
<Popconfirm
title="确认删除该待办事项吗?"
onConfirm={() => handleDelete(todo._id)}
okText="是"
cancelText="否"
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>,
]}
>
<div style={{ flex: 1 }}>
<List.Item.Meta
avatar={
<Checkbox
checked={todo.completed}
onChange={() => handleToggle(todo._id)}
/>
}
title={
<Space>
<Text delete={todo.completed} style={{ fontSize: '16px', fontWeight: 'bold' }}>
{todo.title}
</Text>
<Tag color={getPriorityTagColor(todo.priority)}>
{getPriorityIcon(todo.priority)} {priorityLabels[todo.priority]}
</Tag>
</Space>
}
description={
todo.description && <Text type="secondary">{todo.description}</Text>
}
/>
</div>
</List.Item>
)}
/>
),
},
{
key: 'completed',
label: '已完成',
children: (
<List
locale={{ emptyText: '暂无待办事项' }}
bordered
dataSource={filteredTodos.filter((todo) => todo.completed)}
renderItem={(todo) => (
<List.Item
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
actions={[
<Button type="link" icon={<EditOutlined />} onClick={() => handleEditClick(todo)}>
</Button>,
<Popconfirm
title="确认删除该待办事项吗?"
onConfirm={() => handleDelete(todo._id)}
okText="是"
cancelText="否"
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>,
]}
>
<div style={{ flex: 1 }}>
<List.Item.Meta
avatar={
<Checkbox
checked={todo.completed}
onChange={() => handleToggle(todo._id)}
/>
}
title={
<Space>
<Text delete={todo.completed} style={{ fontSize: '16px', fontWeight: 'bold' }}>
{todo.title}
</Text>
<Tag color={getPriorityTagColor(todo.priority)}>
{getPriorityIcon(todo.priority)} {priorityLabels[todo.priority]}
</Tag>
</Space>
}
description={
todo.description && <Text type="secondary">{todo.description}</Text>
}
/>
</div>
</List.Item>
)}
/>
),
},
];
return (
<Tabs defaultActiveKey="pending" activeKey={activeTab} onChange={handleTabChange} items={items} />
);
};
export default TodoListContent;

View File

@ -0,0 +1,103 @@
import React, { useEffect } from 'react';
import { Modal, Form, Input, Select, Button, Checkbox } from 'antd';
import type { Priority } from '../types/todo';
import { priorityLabels, getPriorityIcon } from '../utils/priorityUtils';
const { Option } = Select;
interface TodoModalFormProps {
isVisible: boolean;
title: string;
initialValues: {
title: string;
description: string;
priority: Priority;
completed?: boolean;
};
onCancel: () => void;
onSubmit: (values: {
title: string;
description: string;
priority: Priority;
completed?: boolean;
}) => void;
isEditing: boolean;
}
const TodoModalForm: React.FC<TodoModalFormProps> = ({
isVisible,
title,
initialValues,
onCancel,
onSubmit,
isEditing,
}) => {
const [form] = Form.useForm();
useEffect(() => {
form.setFieldsValue(initialValues);
}, [initialValues, form]);
const handleFinish = (values: any) => {
onSubmit(values);
};
return (
<Modal
title={title}
open={isVisible}
onCancel={onCancel}
footer={null}
destroyOnHidden
>
<Form
form={form}
layout="vertical"
onFinish={handleFinish}
>
<Form.Item
name="title"
label="标题"
rules={[{ required: true, message: '请输入待办事项标题!' }]}
>
<Input placeholder="待办事项标题..." />
</Form.Item>
<Form.Item
name="description"
label="描述"
>
<Input.TextArea rows={4} placeholder="可选描述..." />
</Form.Item>
<Form.Item
name="priority"
label="优先级"
rules={[{ required: true, message: '请选择优先级!' }]}
>
<Select placeholder="选择优先级">
<Option value="low">{getPriorityIcon('low')} {priorityLabels.low}</Option>
<Option value="medium">{getPriorityIcon('medium')} {priorityLabels.medium}</Option>
<Option value="high">{getPriorityIcon('high')} {priorityLabels.high}</Option>
</Select>
</Form.Item>
{isEditing && (
<Form.Item
name="completed"
valuePropName="checked"
>
<Checkbox></Checkbox>
</Form.Item>
)}
<Form.Item>
<div style={{ float: 'right' }}>
<Button onClick={onCancel} style={{ marginRight: 8 }}></Button>
<Button type="primary" htmlType="submit">
{isEditing ? '保存' : '添加'}
</Button>
</div>
</Form.Item>
</Form>
</Modal>
);
};
export default TodoModalForm;

View File

@ -0,0 +1,175 @@
import { useState, useEffect } from 'react';
import { notification } from 'antd';
import type { Todo, Priority } from '../types/todo';
import { getTodos, addTodo, toggleTodo, deleteTodo, updateTodo } from '../api/todo';
interface UseTodosResult {
todos: Todo[];
isModalOpen: boolean;
currentTodo: Todo | null;
modalTitle: string;
isEditing: boolean;
activeTab: string;
searchTerm: string;
setSearchTerm: (term: string) => void;
openAddModal: () => void;
closeModal: () => void;
handleEditClick: (todo: Todo) => void;
handleModalSubmit: (values: { title: string; description: string; priority: Priority; completed?: boolean; }) => Promise<void>;
handleToggle: (_id: string) => Promise<void>;
handleDelete: (_id: string) => Promise<void>;
handleTabChange: (key: string) => void;
filteredTodos: Todo[];
loadTodos: () => Promise<void>;
}
export const useTodos = (): UseTodosResult => {
const [todos, setTodos] = useState<Todo[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [currentTodo, setCurrentTodo] = useState<Todo | null>(null);
const [modalTitle, setModalTitle] = useState('');
const [isEditing, setIsEditing] = useState(false);
const [activeTab, setActiveTab] = useState('pending');
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
loadTodos();
}, []);
const loadTodos = async () => {
try {
const data = await getTodos();
setTodos(data);
} catch (error) {
console.error('Failed to load todos:', error);
notification.error({
message: '加载待办事项失败',
description: '无法从服务器获取待办事项列表。',
});
}
};
const closeModal = () => {
setIsModalOpen(false);
setCurrentTodo(null);
setIsEditing(false);
};
const openAddModal = () => {
setCurrentTodo(null);
setModalTitle('新增待办事项');
setIsEditing(false);
setIsModalOpen(true);
};
const handleEditClick = (todo: Todo) => {
setCurrentTodo(todo);
setModalTitle('编辑待办事项');
setIsEditing(true);
setIsModalOpen(true);
};
const handleModalSubmit = async (values: {
title: string;
description: string;
priority: Priority;
completed?: boolean;
}) => {
try {
if (currentTodo) {
const updatedTodo = await updateTodo(
currentTodo._id,
values.title,
values.description,
values.priority,
values.completed || false
);
setTodos(todos.map(todo => (todo._id === updatedTodo._id ? updatedTodo : todo)));
notification.success({
message: '待办事项更新成功',
});
} else {
const newTodo = await addTodo(values.title, values.description, values.priority);
setTodos([...todos, newTodo]);
notification.success({
message: '待办事项添加成功',
});
}
closeModal();
} catch (error) {
console.error(currentTodo ? 'Failed to update todo:' : 'Failed to add todo:', error);
notification.error({
message: currentTodo ? '更新待办事项失败' : '添加待办事项失败',
description: '请检查您的输入和网络连接。',
});
}
};
const handleToggle = async (_id: string) => {
try {
const updatedTodo = await toggleTodo(_id);
setTodos(todos.map(todo => todo._id === _id ? updatedTodo : todo));
notification.success({
message: '待办事项状态切换成功',
});
} catch (error) {
console.error('Failed to toggle todo:', error);
notification.error({
message: '切换待办事项状态失败',
description: '请稍后再试。',
});
}
};
const handleDelete = async (_id: string) => {
try {
await deleteTodo(_id);
setTodos(todos.filter(todo => todo._id !== _id));
notification.success({
message: '待办事项删除成功',
});
} catch (error) {
console.error('Failed to delete todo:', error);
notification.error({
message: '删除待办事项失败',
description: '请稍后再试。',
});
}
};
const handleTabChange = (key: string) => {
setActiveTab(key);
};
const filteredTodos = todos.filter(todo => {
const matchesSearch = searchTerm === '' ||
todo.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
(todo.description && todo.description.toLowerCase().includes(searchTerm.toLowerCase()));
if (activeTab === 'pending') {
return !todo.completed && matchesSearch;
} else {
return todo.completed && matchesSearch;
}
});
return {
todos,
isModalOpen,
currentTodo,
modalTitle,
isEditing,
activeTab,
searchTerm,
setSearchTerm,
openAddModal,
closeModal,
handleEditClick,
handleModalSubmit,
handleToggle,
handleDelete,
handleTabChange,
filteredTodos,
loadTodos,
};
};

View File

@ -1 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-50;
}
}

View File

@ -1,10 +1,22 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import App from './App.tsx';
import Login from './pages/Login';
import Register from './pages/Register';
import ForgotPassword from './pages/ForgotPassword';
import 'antd/dist/reset.css';
import '@ant-design/v5-patch-for-react-19';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/*" element={<App />} />
</Routes>
</BrowserRouter>
</React.StrictMode>
);

View File

@ -0,0 +1,116 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Form, Input, Button, Card, Space, Typography, Alert } from 'antd';
import { MailOutlined, LockOutlined, CheckSquareOutlined } from '@ant-design/icons';
import { api } from '../api/axios';
const { Title, Text, Link } = Typography;
function ForgotPassword() {
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const onFinish = async (values: any) => {
setLoading(true);
setMessage(null);
setError(null);
try {
// 模拟API调用实际情况会调用后端接口发送重置邮件
console.log('Reset password request for:', values.email);
await new Promise(resolve => setTimeout(resolve, 1000)); // 模拟网络延迟
setMessage('如果您的账户存在,密码重置链接已发送到您的邮箱。');
// 实际情况下,这里可能会调用后端接口,例如:
// await api.post('/auth/forgot-password', { email: values.email });
} catch (err: any) {
const errorMessage = err.response?.data?.message || '请求失败,请稍后再试。';
setError(errorMessage);
} finally {
setLoading(false);
}
};
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100" style={{ background: '#f0f2f5',height: '100vh',display: 'flex',alignItems: 'center',justifyContent: 'center' }}>
<Card
className="w-full max-w-sm"
variant="borderless"
style={{
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.1)',
padding: '40px 24px 24px',
textAlign: 'center',
width: '500px',
margin: '0 auto 200px',
}}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: 24 }}>
<CheckSquareOutlined style={{ fontSize: '48px', color: '#1890ff' }} />
<Title level={2} style={{ margin: '8px 0 0' }}></Title>
<Text type="secondary"></Text>
</div>
{message && (
<Alert
message="发送成功"
description={message}
type="success"
showIcon
closable
onClose={() => setMessage(null)}
style={{ marginBottom: 24 }}
/>
)}
{error && (
<Alert
message="请求失败"
description={error}
type="error"
showIcon
closable
onClose={() => setError(null)}
style={{ marginBottom: 24 }}
/>
)}
<Form
name="forgot_password"
onFinish={onFinish}
autoComplete="off"
layout="vertical"
style={{ width: '100%' }}
>
<Form.Item
name="email"
rules={[
{ required: true, message: '请输入您的邮箱或用户名!' },
// { type: 'email', message: '请输入有效的邮箱地址!' }, // 如果只允许邮箱,可以启用此规则
]}
>
<Input
prefix={<MailOutlined className="site-form-item-icon" />}
placeholder="邮箱或用户名"
size="large"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" className="w-full" size="large" style={{width: '100%',}} loading={loading}>
</Button>
</Form.Item>
</Form>
<Link href="/login" style={{ textAlign: 'center', display: 'block' }}>
</Link>
</Space>
</Card>
</div>
);
}
export default ForgotPassword;

View File

@ -1,64 +1,117 @@
import React, { useState, useEffect } from 'react';
import { login } from '../api/auth';
import { useNavigate, Link } from 'react-router-dom';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Form, Input, Button, Card, Space, Typography, Alert, Checkbox, Divider } from 'antd';
import { UserOutlined, LockOutlined, CheckSquareOutlined } from '@ant-design/icons';
import { api } from '../api/axios';
const Login: React.FC = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { Title, Text, Link } = Typography;
function Login() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const [isLogin, setIsLogin] = useState(!!localStorage.getItem('token'));
useEffect(() => {
const onStorage = () => setIsLogin(!!localStorage.getItem('token'));
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const onFinish = async (values: any) => {
setLoading(true);
setError(null);
try {
const res = await login(username, password);
localStorage.setItem('token', res.token);
localStorage.setItem('username', res.username);
window.dispatchEvent(new Event('tokenChange'));
const response = await api.post('/auth/login', {
username: values.username,
password: values.password,
});
localStorage.setItem('token', response.data.token);
localStorage.setItem('username', values.username);
navigate('/');
} catch (err: any) {
setError(err.response?.data?.message || '登录失败');
const errorMessage = err.response?.data?.message || '登录失败,请检查用户名或密码。';
setError(errorMessage);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-100 flex flex-col items-center justify-center py-4 sm:py-10 px-1 sm:px-2">
<div className="w-full max-w-xs sm:max-w-md bg-white rounded-lg sm:rounded-xl shadow-lg p-4 sm:p-8">
<h2 className="text-2xl sm:text-3xl font-bold text-center mb-4 sm:mb-6 text-blue-600 tracking-tight"></h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:gap-4 mb-2">
<input
className="border rounded px-2 sm:px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400 text-sm sm:text-base"
type="text"
placeholder="用户名"
value={username}
onChange={e => setUsername(e.target.value)}
required
<div className="flex items-center justify-center min-h-screen bg-gray-100" style={{ background: '#f0f2f5',height: '100vh',display: 'flex',alignItems: 'center',justifyContent: 'center' }}>
<Card
className="w-full max-w-sm"
variant="borderless"
style={{
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.1)',
padding: '40px 24px 24px',
textAlign: 'center',
width: '500px',
margin: '0 auto 200px',
}}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: 24 }}>
<CheckSquareOutlined style={{ fontSize: '48px', color: '#1890ff' }} />
<Title level={2} style={{ margin: '8px 0 0' }}>Todo List</Title>
<Text type="secondary"></Text>
</div>
{error && (
<Alert
message="账户或密码错误"
description={error}
type="error"
showIcon
closable
onClose={() => setError(null)}
style={{ marginBottom: 24 }}
/>
<input
className="border rounded px-2 sm:px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400 text-sm sm:text-base"
type="password"
)}
<Form
name="login"
initialValues={{ remember: true }}
onFinish={onFinish}
autoComplete="off"
layout="vertical"
style={{ width: '100%' }}
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名!' }]}
>
<Input
prefix={<UserOutlined className="site-form-item-icon" />}
placeholder="账户"
size="large"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码!' }]}
>
<Input.Password
prefix={<LockOutlined className="site-form-item-icon" />}
placeholder="密码"
value={password}
onChange={e => setPassword(e.target.value)}
required
size="large"
/>
{error && <div className="text-red-500 text-xs sm:text-sm text-center">{error}</div>}
<button className="bg-blue-500 text-white py-2 rounded hover:bg-blue-600 transition text-sm sm:text-base" type="submit"></button>
</form>
<div className="text-xs sm:text-sm text-center mt-3 sm:mt-4">
<Link to="/register" className="text-blue-500 hover:underline"></Link>
</div>
</Form.Item>
<Form.Item>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Checkbox></Checkbox>
<Space>
<Link href="/forgot-password"></Link>
<Link href="/register"></Link>
</Space>
</div>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" className="w-full" size="large" loading={loading} style={{width: '100%',}}>
</Button>
</Form.Item>
</Form>
</Space>
</Card>
</div>
);
};
}
export default Login;

View File

@ -1,58 +1,131 @@
import React, { useState } from 'react';
import { register } from '../api/auth';
import { useNavigate, Link } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { Form, Input, Button, Card, Space, Typography, Alert } from 'antd';
import { UserOutlined, LockOutlined, CheckSquareOutlined } from '@ant-design/icons';
import { api } from '../api/axios';
const Register: React.FC = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const { Title, Text, Link } = Typography;
function Register() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
const onFinish = async (values: any) => {
setLoading(true);
setError(null);
try {
await register(username, password);
setSuccess('注册成功,请登录');
setTimeout(() => navigate('/login'), 1000);
await api.post('/auth/register', {
username: values.username,
password: values.password,
});
navigate('/login');
} catch (err: any) {
setError(err.response?.data?.message || '注册失败');
const errorMessage = err.response?.data?.message || '注册失败,用户名可能已被占用或服务器错误。';
setError(errorMessage);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-100 flex flex-col items-center justify-center py-4 sm:py-10 px-1 sm:px-2">
<div className="w-full max-w-xs sm:max-w-md bg-white rounded-lg sm:rounded-xl shadow-lg p-4 sm:p-8">
<h2 className="text-2xl sm:text-3xl font-bold text-center mb-4 sm:mb-6 text-blue-600 tracking-tight"></h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:gap-4 mb-2">
<input
className="border rounded px-2 sm:px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400 text-sm sm:text-base"
type="text"
<div className="flex items-center justify-center min-h-screen bg-gray-100" style={{ background: '#f0f2f5',height: '100vh',display: 'flex',alignItems: 'center',justifyContent: 'center' }}>
<Card
className="w-full max-w-sm"
variant="borderless"
style={{
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.1)',
padding: '40px 24px 24px',
textAlign: 'center',
width: '500px',
margin: '0 auto 200px',
}}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: 24 }}>
<CheckSquareOutlined style={{ fontSize: '48px', color: '#1890ff' }} />
<Title level={2} style={{ margin: '8px 0 0' }}>Todo List</Title>
<Text type="secondary"></Text>
</div>
{error && (
<Alert
message="注册失败"
description={error}
type="error"
showIcon
closable
onClose={() => setError(null)}
style={{ marginBottom: 24 }}
/>
)}
<Form
name="register"
initialValues={{ remember: true }}
onFinish={onFinish}
autoComplete="off"
layout="vertical"
style={{ width: '100%' }}
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名!' }]}
>
<Input
prefix={<UserOutlined className="site-form-item-icon" />}
placeholder="用户名"
value={username}
onChange={e => setUsername(e.target.value)}
required
size="large"
/>
<input
className="border rounded px-2 sm:px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400 text-sm sm:text-base"
type="password"
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码!' }]}
>
<Input.Password
prefix={<LockOutlined className="site-form-item-icon" />}
placeholder="密码"
value={password}
onChange={e => setPassword(e.target.value)}
required
size="large"
/>
{error && <div className="text-red-500 text-xs sm:text-sm text-center">{error}</div>}
{success && <div className="text-green-500 text-xs sm:text-sm text-center">{success}</div>}
<button className="bg-blue-500 text-white py-2 rounded hover:bg-blue-600 transition text-sm sm:text-base" type="submit"></button>
</form>
<div className="text-xs sm:text-sm text-center mt-3 sm:mt-4">
<Link to="/login" className="text-blue-500 hover:underline"></Link>
</div>
</Form.Item>
<Form.Item
name="confirm"
dependencies={['password']}
hasFeedback
rules={[
{ required: true, message: '请确认您的密码!' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致!'));
},
}),
]}
>
<Input.Password
prefix={<LockOutlined className="site-form-item-icon" />}
placeholder="确认密码"
size="large"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" className="w-full" size="large" style={{width: '100%',}} loading={loading}>
</Button>
</Form.Item>
<div style={{ textAlign: 'right' }}>
<Link href="/login"></Link>
</div>
</Form>
</Space>
</Card>
</div>
);
};
}
export default Register;

View File

@ -1,15 +1,16 @@
export type Priority = 'low' | 'medium' | 'high';
export interface Todo {
id: number;
_id: string;
title: string;
description: string;
completed: boolean;
priority: Priority;
}
export const priorityColors: Record<Priority, string> = {
low: 'blue',
medium: 'yellow',
low: 'green',
medium: 'orange',
high: 'red',
};

View File

@ -0,0 +1,25 @@
import type { Priority } from '../types/todo';
export const priorityLabels: { [key in Priority]: string } = {
low: '低',
medium: '中',
high: '高',
};
export const getPriorityTagColor = (priority: Priority): string => {
switch (priority) {
case 'low': return 'green';
case 'medium': return 'orange';
case 'high': return 'red';
default: return 'default';
}
};
export const getPriorityIcon = (priority: Priority): string => {
switch (priority) {
case 'low': return '🌱';
case 'medium': return '🌟';
case 'high': return '🔥';
default: return '';
}
};

View File

@ -1,14 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import AutoImport from 'unplugin-auto-import/vite'
import AntdResolve from 'unplugin-auto-import-antd'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
react(),
AutoImport({
resolvers: [AntdResolve()]
})
],
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_API_URL,
target: 'http://localhost:5050',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
@ -19,4 +26,7 @@ export default defineConfig({
sourcemap: process.env.VITE_NODE_ENV === 'development',
minify: process.env.VITE_NODE_ENV === 'production',
},
optimizeDeps: {
exclude: ['@ant-design/v5-patch-for-react-19'],
},
})