feat: complete project management features
Deploy / Deploy Staging (push) Successful in 17s
Deploy / Deploy Production (push) Has been skipped
Tests / test (push) Failing after 2m16s
Tests / lint (push) Failing after 2m16s

- Milestone management page with create/list functionality
- Task edit form component (UI ready, backend PATCH needed)
- Task detail page with status/progress update controls
- Status dropdown for quick status change
- Progress slider for interactive progress update
- Edit button to toggle edit form
- Updated homepage with milestone navigation
- All core UI features complete
This commit is contained in:
Xiaoxia AI
2026-06-16 13:06:00 +08:00
parent ca169de6f9
commit c187e3eee8
4 changed files with 469 additions and 12 deletions
+160
View File
@@ -0,0 +1,160 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
interface EditTaskFormProps {
taskId: string;
initialData: {
name: string;
description: string;
priority: string;
assignee_user_id: string;
};
onSuccess?: () => void;
onCancel?: () => void;
}
export default function EditTaskForm({ taskId, initialData, onSuccess, onCancel }: EditTaskFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState(initialData);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
// 注意:这里需要后端补充 PATCH /tasks/{id} 接口
alert('任务编辑功能需要后端补充 PATCH /tasks/{id} 接口');
if (onSuccess) onSuccess();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} style={{
background: 'var(--bg-white)',
padding: '24px',
borderRadius: '8px',
border: '1px solid var(--border)',
}}>
<h3 style={{ marginBottom: '20px', fontSize: '18px', fontWeight: 'bold' }}></h3>
{error && (
<div style={{
padding: '12px',
background: '#FFECE8',
border: '1px solid var(--error)',
borderRadius: '4px',
color: 'var(--error)',
marginBottom: '20px',
}}>
{error}
</div>
)}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
<span style={{ color: 'var(--error)' }}>*</span>
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
fontSize: '14px',
}}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
fontSize: '14px',
resize: 'vertical',
}}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
</label>
<select
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
fontSize: '14px',
}}
>
<option value="low"></option>
<option value="medium"></option>
<option value="high"></option>
<option value="urgent"></option>
</select>
</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
<button
type="submit"
disabled={loading}
style={{
flex: 1,
padding: '10px',
borderRadius: '4px',
border: 'none',
background: loading ? '#ccc' : 'var(--primary)',
color: 'white',
fontWeight: 'bold',
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
{loading ? '保存中...' : '保存修改'}
</button>
{onCancel && (
<button
type="button"
onClick={onCancel}
style={{
flex: 1,
padding: '10px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'white',
cursor: 'pointer',
}}
>
</button>
)}
</div>
</form>
);
}
+234
View File
@@ -0,0 +1,234 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
interface Milestone {
id: string;
name: string;
description: string;
target_date: string | null;
completed: boolean;
completed_at: string | null;
created_at: string;
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
export default function MilestonesPage() {
const [milestones, setMilestones] = useState<Milestone[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [formData, setFormData] = useState({ name: '', description: '' });
const projectId = 'demo_project_1';
const workspaceId = 'demo_workspace_1';
useEffect(() => {
fetchMilestones();
}, []);
const fetchMilestones = async () => {
setLoading(true);
try {
const res = await fetch(`${API_BASE}/api/v1/project-management/milestones?project_id=${projectId}`);
if (!res.ok) throw new Error('获取里程碑列表失败');
const data = await res.json();
setMilestones(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleCreateMilestone = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await fetch(`${API_BASE}/api/v1/project-management/milestones`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project_id: projectId,
workspace_id: workspaceId,
...formData,
}),
});
if (!res.ok) throw new Error('创建失败');
setFormData({ name: '', description: '' });
setShowCreateForm(false);
fetchMilestones();
} catch (err: any) {
alert(err.message);
}
};
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<p>...</p>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
{/* Header */}
<header style={{
height: '60px',
background: 'var(--bg-white)',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
padding: '0 20px',
justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
<Link href="/" style={{ fontSize: '18px', fontWeight: 'bold', color: 'var(--primary)', textDecoration: 'none' }}>
📁
</Link>
<span style={{ color: 'var(--text-secondary)' }}></span>
</div>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
style={{
padding: '6px 12px',
borderRadius: '4px',
border: 'none',
background: 'var(--primary)',
color: 'white',
cursor: 'pointer',
fontWeight: 'bold',
}}
>
{showCreateForm ? '取消' : '+ 新增里程碑'}
</button>
</header>
{/* Main */}
<main style={{ flex: 1, padding: '20px', overflow: 'auto' }}>
{error && (
<div style={{
padding: '12px',
background: '#FFECE8',
border: '1px solid var(--error)',
borderRadius: '4px',
color: 'var(--error)',
marginBottom: '20px',
}}>
{error}
</div>
)}
{showCreateForm && (
<div style={{ background: 'var(--bg-white)', padding: '24px', borderRadius: '8px', marginBottom: '20px' }}>
<h3 style={{ marginBottom: '16px', fontSize: '18px', fontWeight: 'bold' }}></h3>
<form onSubmit={handleCreateMilestone}>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}> *</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
}}
placeholder="例如:V1.0 发布"
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}></label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: '4px',
resize: 'vertical',
}}
placeholder="详细说明里程碑内容"
/>
</div>
<button
type="submit"
style={{
padding: '8px 16px',
borderRadius: '4px',
border: 'none',
background: 'var(--primary)',
color: 'white',
fontWeight: 'bold',
cursor: 'pointer',
}}
>
</button>
</form>
</div>
)}
{milestones.length === 0 ? (
<div style={{
background: 'var(--bg-white)',
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
color: 'var(--text-secondary)',
}}>
<p></p>
<p style={{ fontSize: '14px', marginTop: '8px' }}>"+ 新增里程碑"</p>
</div>
) : (
<div style={{ display: 'grid', gap: '16px' }}>
{milestones.map((milestone) => (
<div
key={milestone.id}
style={{
background: 'var(--bg-white)',
padding: '20px',
borderRadius: '8px',
border: `2px solid ${milestone.completed ? 'var(--success)' : 'var(--border)'}`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<span style={{ fontSize: '24px' }}>{milestone.completed ? '🎉' : '🎯'}</span>
<h3 style={{ fontSize: '18px', fontWeight: 'bold', flex: 1 }}>{milestone.name}</h3>
<span style={{
padding: '4px 12px',
borderRadius: '4px',
fontSize: '12px',
background: milestone.completed ? 'var(--success)' : '#E5E6EB',
color: milestone.completed ? 'white' : 'var(--text-secondary)',
}}>
{milestone.completed ? '已完成' : '进行中'}
</span>
</div>
{milestone.description && (
<p style={{ color: 'var(--text-secondary)', lineHeight: '1.6', marginBottom: '12px' }}>
{milestone.description}
</p>
)}
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
{new Date(milestone.created_at).toLocaleDateString('zh-CN')}
{milestone.completed_at && (
<span style={{ marginLeft: '12px' }}>
{new Date(milestone.completed_at).toLocaleDateString('zh-CN')}
</span>
)}
</div>
</div>
))}
</div>
)}
</main>
</div>
);
}
+20 -6
View File
@@ -8,18 +8,18 @@ export default function HomePage() {
justifyContent: 'center',
gap: '20px'
}}>
<h1 style={{ fontSize: '32px', fontWeight: 'bold', color: '#165DFF' }}>
<h1 style={{ fontSize: '32px', fontWeight: 'bold', color: 'var(--primary)' }}>
📁 SaaS
</h1>
<p style={{ color: '#6E7681' }}>
...
<p style={{ color: 'var(--text-secondary)' }}>
</p>
<div style={{ display: 'flex', gap: '12px' }}>
<a
href="/projects"
style={{
padding: '10px 20px',
background: '#165DFF',
background: 'var(--primary)',
color: 'white',
borderRadius: '6px',
textDecoration: 'none',
@@ -28,14 +28,28 @@ export default function HomePage() {
>
</a>
<a
href="/milestones"
style={{
padding: '10px 20px',
background: 'white',
color: 'var(--primary)',
border: '1px solid var(--primary)',
borderRadius: '6px',
textDecoration: 'none',
fontWeight: 'bold'
}}
>
</a>
<a
href="/api/docs"
target="_blank"
style={{
padding: '10px 20px',
background: 'white',
color: '#165DFF',
border: '1px solid #165DFF',
color: 'var(--primary)',
border: '1px solid var(--primary)',
borderRadius: '6px',
textDecoration: 'none'
}}
+55 -6
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import CreateIssueForm from '../../components/CreateIssueForm';
import EditTaskForm from '../../components/EditTaskForm';
interface Task {
id: string;
@@ -45,6 +46,7 @@ export default function TaskDetailPage() {
const [error, setError] = useState('');
const [updating, setUpdating] = useState(false);
const [showIssueForm, setShowIssueForm] = useState(false);
const [showEditForm, setShowEditForm] = useState(false);
useEffect(() => {
fetchTaskDetail();
@@ -161,21 +163,68 @@ export default function TaskDetailPage() {
<div style={{ display: 'grid', gap: '20px' }}>
{/* 任务基本信息 */}
<div style={{ background: 'var(--bg-white)', padding: '24px', borderRadius: '8px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>{task.name}</h1>
<p style={{ color: 'var(--text-secondary)', lineHeight: '1.6' }}>{task.description || '暂无描述'}</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 'bold' }}>{task.name}</h1>
<button
onClick={() => setShowEditForm(!showEditForm)}
style={{
padding: '6px 12px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'white',
cursor: 'pointer',
}}
>
{showEditForm ? '取消编辑' : '编辑任务'}
</button>
</div>
<div style={{ display: 'flex', gap: '20px', marginTop: '20px', paddingTop: '20px', borderTop: '1px solid var(--border)' }}>
<p style={{ color: 'var(--text-secondary)', lineHeight: '1.6', marginBottom: '20px' }}>
{task.description || '暂无描述'}
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '20px', paddingTop: '20px', borderTop: '1px solid var(--border)' }}>
<div>
<span style={{ color: 'var(--text-secondary)', fontSize: '14px' }}></span>
<p style={{ fontWeight: 'bold', marginTop: '4px' }}>{task.status}</p>
<select
value={task.status}
onChange={(e) => updateStatus(e.target.value)}
disabled={updating}
style={{
display: 'block',
marginTop: '8px',
padding: '6px 10px',
border: '1px solid var(--border)',
borderRadius: '4px',
fontWeight: 'bold',
cursor: updating ? 'not-allowed' : 'pointer',
}}
>
<option value="pending"></option>
<option value="in_progress"></option>
<option value="completed"></option>
<option value="blocked"></option>
<option value="cancelled"></option>
</select>
</div>
<div>
<span style={{ color: 'var(--text-secondary)', fontSize: '14px' }}></span>
<p style={{ fontWeight: 'bold', marginTop: '4px' }}>{task.priority}</p>
<p style={{ fontWeight: 'bold', marginTop: '8px' }}>{task.priority}</p>
</div>
<div>
<span style={{ color: 'var(--text-secondary)', fontSize: '14px' }}></span>
<p style={{ fontWeight: 'bold', marginTop: '4px' }}>{task.progress}%</p>
<div style={{ marginTop: '8px', display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
type="range"
min="0"
max="100"
value={task.progress}
onChange={(e) => updateProgress(parseFloat(e.target.value))}
disabled={updating}
style={{ flex: 1, cursor: updating ? 'not-allowed' : 'pointer' }}
/>
<span style={{ fontWeight: 'bold', minWidth: '45px' }}>{task.progress}%</span>
</div>
</div>
</div>
</div>