完善
This commit is contained in:
parent
f1cf65e7a7
commit
902d8f7284
10
client-temp/auto-imports.d.ts
vendored
Normal file
10
client-temp/auto-imports.d.ts
vendored
Normal 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 {
|
||||
|
||||
}
|
||||
@ -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
181
client-temp/src/App.App.tsx
Normal 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;
|
||||
@ -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"
|
||||
/>
|
||||
<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>
|
||||
<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 }}
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
<TodoListContent
|
||||
todos={todos}
|
||||
handleEditClick={handleEditClick}
|
||||
handleToggle={handleToggle}
|
||||
handleDelete={handleDelete}
|
||||
getPriorityTagColor={getPriorityTagColor}
|
||||
getPriorityIcon={getPriorityIcon}
|
||||
priorityLabels={priorityLabels}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}`);
|
||||
};
|
||||
47
client-temp/src/components/AntDesignIcon.tsx
Normal file
47
client-temp/src/components/AntDesignIcon.tsx
Normal 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 };
|
||||
72
client-temp/src/components/TodoHeader.tsx
Normal file
72
client-temp/src/components/TodoHeader.tsx
Normal 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;
|
||||
151
client-temp/src/components/TodoListContent.tsx
Normal file
151
client-temp/src/components/TodoListContent.tsx
Normal 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;
|
||||
103
client-temp/src/components/TodoModalForm.tsx
Normal file
103
client-temp/src/components/TodoModalForm.tsx
Normal 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;
|
||||
175
client-temp/src/hooks/useTodos.ts
Normal file
175
client-temp/src/hooks/useTodos.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@ -1 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
116
client-temp/src/pages/ForgotPassword.tsx
Normal file
116
client-temp/src/pages/ForgotPassword.tsx
Normal 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;
|
||||
@ -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
|
||||
/>
|
||||
<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"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{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>
|
||||
</div>
|
||||
<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="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="密码"
|
||||
size="large"
|
||||
/>
|
||||
</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;
|
||||
@ -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"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<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"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{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>
|
||||
</div>
|
||||
<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="用户名"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码!' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined className="site-form-item-icon" />}
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
/>
|
||||
</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;
|
||||
@ -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',
|
||||
};
|
||||
|
||||
|
||||
25
client-temp/src/utils/priorityUtils.ts
Normal file
25
client-temp/src/utils/priorityUtils.ts
Normal 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 '';
|
||||
}
|
||||
};
|
||||
@ -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'],
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user