feat: implement task management module

Backend (FastAPI):
- Database migration for spaces, projects, task_statuses, tasks tables
- SQLAlchemy models with relationships
- Pydantic schemas for CRUD operations
- Spaces API: CRUD with soft delete
- Projects API: CRUD with auto-created default statuses
- Tasks API: CRUD, status change, assign, subtask support
- Permission middleware with Security Level filtering
- Subtask depth limit (max 2 levels)

Frontend (React + Vite):
- Layout component with navigation
- Spaces list page
- Projects list page
- Tasks list page with status management

Fixes:
- auth_client.py: use 'username' field for external API
- config.py: extend JWT expiry to 7 days
- auth/router.py: sync Redis session with JWT expiry

Tests: 36 passed (unit + integration)
E2E: All APIs verified with real authentication

OpenSpec: add-task-management archived

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2025-12-29 00:31:34 +08:00
parent 1fda7da2c2
commit daca7798e3
41 changed files with 3616 additions and 13 deletions

View File

@@ -0,0 +1,398 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '../services/api'
interface Task {
id: string
title: string
description: string | null
priority: string
status_id: string | null
status_name: string | null
status_color: string | null
assignee_id: string | null
assignee_name: string | null
due_date: string | null
subtask_count: number
}
interface TaskStatus {
id: string
name: string
color: string
is_done: boolean
}
interface Project {
id: string
title: string
space_id: string
}
export default function Tasks() {
const { projectId } = useParams()
const navigate = useNavigate()
const [project, setProject] = useState<Project | null>(null)
const [tasks, setTasks] = useState<Task[]>([])
const [statuses, setStatuses] = useState<TaskStatus[]>([])
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [newTask, setNewTask] = useState({
title: '',
description: '',
priority: 'medium',
})
const [creating, setCreating] = useState(false)
useEffect(() => {
loadData()
}, [projectId])
const loadData = async () => {
try {
const [projectRes, tasksRes, statusesRes] = await Promise.all([
api.get(`/api/projects/${projectId}`),
api.get(`/api/projects/${projectId}/tasks`),
api.get(`/api/projects/${projectId}/statuses`),
])
setProject(projectRes.data)
setTasks(tasksRes.data.tasks)
setStatuses(statusesRes.data)
} catch (err) {
console.error('Failed to load data:', err)
} finally {
setLoading(false)
}
}
const handleCreateTask = async () => {
if (!newTask.title.trim()) return
setCreating(true)
try {
await api.post(`/api/projects/${projectId}/tasks`, newTask)
setShowCreateModal(false)
setNewTask({ title: '', description: '', priority: 'medium' })
loadData()
} catch (err) {
console.error('Failed to create task:', err)
} finally {
setCreating(false)
}
}
const handleStatusChange = async (taskId: string, statusId: string) => {
try {
await api.patch(`/api/tasks/${taskId}/status`, { status_id: statusId })
loadData()
} catch (err) {
console.error('Failed to update status:', err)
}
}
const getPriorityStyle = (priority: string): React.CSSProperties => {
const colors: { [key: string]: string } = {
low: '#808080',
medium: '#0066cc',
high: '#ff9800',
urgent: '#f44336',
}
return {
width: '4px',
backgroundColor: colors[priority] || colors.medium,
borderRadius: '2px',
}
}
if (loading) {
return <div style={styles.loading}>Loading...</div>
}
return (
<div style={styles.container}>
<div style={styles.breadcrumb}>
<span onClick={() => navigate('/spaces')} style={styles.breadcrumbLink}>
Spaces
</span>
<span style={styles.breadcrumbSeparator}>/</span>
<span
onClick={() => navigate(`/spaces/${project?.space_id}`)}
style={styles.breadcrumbLink}
>
Projects
</span>
<span style={styles.breadcrumbSeparator}>/</span>
<span>{project?.title}</span>
</div>
<div style={styles.header}>
<h1 style={styles.title}>Tasks</h1>
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
+ New Task
</button>
</div>
<div style={styles.taskList}>
{tasks.map((task) => (
<div key={task.id} style={styles.taskRow}>
<div style={getPriorityStyle(task.priority)} />
<div style={styles.taskContent}>
<div style={styles.taskTitle}>{task.title}</div>
<div style={styles.taskMeta}>
{task.assignee_name && (
<span style={styles.assignee}>{task.assignee_name}</span>
)}
{task.due_date && (
<span style={styles.dueDate}>
Due: {new Date(task.due_date).toLocaleDateString()}
</span>
)}
{task.subtask_count > 0 && (
<span style={styles.subtaskCount}>
{task.subtask_count} subtasks
</span>
)}
</div>
</div>
<select
value={task.status_id || ''}
onChange={(e) => handleStatusChange(task.id, e.target.value)}
style={{
...styles.statusSelect,
backgroundColor: task.status_color || '#f5f5f5',
}}
>
{statuses.map((status) => (
<option key={status.id} value={status.id}>
{status.name}
</option>
))}
</select>
</div>
))}
{tasks.length === 0 && (
<div style={styles.empty}>
<p>No tasks yet. Create your first task!</p>
</div>
)}
</div>
{showCreateModal && (
<div style={styles.modalOverlay}>
<div style={styles.modal}>
<h2 style={styles.modalTitle}>Create New Task</h2>
<input
type="text"
placeholder="Task title"
value={newTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
style={styles.input}
/>
<textarea
placeholder="Description (optional)"
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
style={styles.textarea}
/>
<label style={styles.label}>Priority</label>
<select
value={newTask.priority}
onChange={(e) => setNewTask({ ...newTask, priority: e.target.value })}
style={styles.select}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
<div style={styles.modalActions}>
<button onClick={() => setShowCreateModal(false)} style={styles.cancelButton}>
Cancel
</button>
<button
onClick={handleCreateTask}
disabled={creating || !newTask.title.trim()}
style={styles.submitButton}
>
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
const styles: { [key: string]: React.CSSProperties } = {
container: {
padding: '24px',
maxWidth: '1200px',
margin: '0 auto',
},
breadcrumb: {
marginBottom: '16px',
fontSize: '14px',
color: '#666',
},
breadcrumbLink: {
color: '#0066cc',
cursor: 'pointer',
},
breadcrumbSeparator: {
margin: '0 8px',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px',
},
title: {
fontSize: '24px',
fontWeight: 600,
margin: 0,
},
createButton: {
padding: '10px 20px',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
},
taskList: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
overflow: 'hidden',
},
taskRow: {
display: 'flex',
alignItems: 'center',
padding: '16px',
borderBottom: '1px solid #eee',
gap: '12px',
},
taskContent: {
flex: 1,
},
taskTitle: {
fontSize: '14px',
fontWeight: 500,
marginBottom: '4px',
},
taskMeta: {
display: 'flex',
gap: '12px',
fontSize: '12px',
color: '#666',
},
assignee: {
backgroundColor: '#f0f0f0',
padding: '2px 6px',
borderRadius: '4px',
},
dueDate: {},
subtaskCount: {
color: '#999',
},
statusSelect: {
padding: '6px 12px',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
cursor: 'pointer',
color: 'white',
},
empty: {
textAlign: 'center',
padding: '48px',
color: '#666',
},
loading: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '200px',
},
modalOverlay: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
modal: {
backgroundColor: 'white',
padding: '24px',
borderRadius: '8px',
width: '400px',
maxWidth: '90%',
},
modalTitle: {
marginBottom: '16px',
},
input: {
width: '100%',
padding: '10px',
marginBottom: '12px',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '14px',
boxSizing: 'border-box',
},
textarea: {
width: '100%',
padding: '10px',
marginBottom: '12px',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '14px',
minHeight: '80px',
resize: 'vertical',
boxSizing: 'border-box',
},
label: {
display: 'block',
marginBottom: '4px',
fontSize: '14px',
fontWeight: 500,
},
select: {
width: '100%',
padding: '10px',
marginBottom: '16px',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '14px',
boxSizing: 'border-box',
},
modalActions: {
display: 'flex',
justifyContent: 'flex-end',
gap: '12px',
},
cancelButton: {
padding: '10px 20px',
backgroundColor: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
},
submitButton: {
padding: '10px 20px',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
},
}