feat: initial frontend - React + TypeScript + Vite
- Login page with JWT auth - Dashboard with stats and charts - Issues list with pagination, filtering - Issue detail with comments, status transitions - Create issue form - Dark theme UI - Docker (nginx) with API proxy to backend - Sidebar navigation
This commit is contained in:
60
src/pages/CreateIssuePage.tsx
Normal file
60
src/pages/CreateIssuePage.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '@/services/api'
|
||||
import type { Project } from '@/types'
|
||||
|
||||
export default function CreateIssuePage() {
|
||||
const navigate = useNavigate()
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [form, setForm] = useState({
|
||||
title: '', description: '', project_id: 0, issue_type: 'task',
|
||||
priority: 'medium', tags: '', reporter_id: 1,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Project[]>('/projects').then(({ data }) => {
|
||||
setProjects(data)
|
||||
if (data.length) setForm((f) => ({ ...f, project_id: data[0].id }))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const payload = { ...form, tags: form.tags || null }
|
||||
await api.post('/issues', payload)
|
||||
navigate('/issues')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="create-issue">
|
||||
<h2>新建 Issue</h2>
|
||||
<form onSubmit={submit}>
|
||||
<label>标题 <input required value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} /></label>
|
||||
<label>描述 <textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></label>
|
||||
<label>项目
|
||||
<select value={form.project_id} onChange={(e) => setForm({ ...form, project_id: Number(e.target.value) })}>
|
||||
{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>类型
|
||||
<select value={form.issue_type} onChange={(e) => setForm({ ...form, issue_type: e.target.value })}>
|
||||
<option value="task">Task</option>
|
||||
<option value="bug">Bug</option>
|
||||
<option value="feature">Feature</option>
|
||||
<option value="resolution">Resolution</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>优先级
|
||||
<select value={form.priority} onChange={(e) => setForm({ ...form, priority: e.target.value })}>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>标签 <input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} placeholder="逗号分隔" /></label>
|
||||
<button type="submit" className="btn-primary">创建</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
src/pages/DashboardPage.tsx
Normal file
75
src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import api from '@/services/api'
|
||||
import type { DashboardStats } from '@/types'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DashboardStats>('/dashboard/stats').then(({ data }) => setStats(data))
|
||||
}, [])
|
||||
|
||||
if (!stats) return <div className="loading">加载中...</div>
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: '#3b82f6', in_progress: '#f59e0b', resolved: '#10b981',
|
||||
closed: '#6b7280', blocked: '#ef4444',
|
||||
}
|
||||
const priorityColors: Record<string, string> = {
|
||||
low: '#6b7280', medium: '#3b82f6', high: '#f59e0b', critical: '#ef4444',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<h2>📊 仪表盘</h2>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card total">
|
||||
<span className="stat-number">{stats.total_issues}</span>
|
||||
<span className="stat-label">总 Issues</span>
|
||||
</div>
|
||||
{Object.entries(stats.by_status).map(([k, v]) => (
|
||||
<div className="stat-card" key={k} style={{ borderLeftColor: statusColors[k] || '#ccc' }}>
|
||||
<span className="stat-number">{v}</span>
|
||||
<span className="stat-label">{k}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3>按优先级</h3>
|
||||
<div className="bar-chart">
|
||||
{Object.entries(stats.by_priority).map(([k, v]) => (
|
||||
<div className="bar-row" key={k}>
|
||||
<span className="bar-label">{k}</span>
|
||||
<div className="bar" style={{
|
||||
width: `${Math.max((v / stats.total_issues) * 100, 5)}%`,
|
||||
backgroundColor: priorityColors[k] || '#ccc',
|
||||
}}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3>最近 Issues</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>标题</th><th>状态</th><th>优先级</th><th>类型</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.recent_issues.map((i) => (
|
||||
<tr key={i.id}>
|
||||
<td>#{i.id}</td>
|
||||
<td><a href={`/issues/${i.id}`}>{i.title}</a></td>
|
||||
<td><span className={`badge status-${i.status}`}>{i.status}</span></td>
|
||||
<td><span className={`badge priority-${i.priority}`}>{i.priority}</span></td>
|
||||
<td>{i.issue_type}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
src/pages/IssueDetailPage.tsx
Normal file
97
src/pages/IssueDetailPage.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '@/services/api'
|
||||
import type { Issue, Comment } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export default function IssueDetailPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [issue, setIssue] = useState<Issue | null>(null)
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [newComment, setNewComment] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Issue>(`/issues/${id}`).then(({ data }) => setIssue(data))
|
||||
api.get<Comment[]>(`/issues/${id}/comments`).then(({ data }) => setComments(data))
|
||||
}, [id])
|
||||
|
||||
const addComment = async () => {
|
||||
if (!newComment.trim() || !issue) return
|
||||
await api.post('/comments', { content: newComment, issue_id: issue.id, author_id: 1 })
|
||||
setNewComment('')
|
||||
const { data } = await api.get<Comment[]>(`/issues/${id}/comments`)
|
||||
setComments(data)
|
||||
}
|
||||
|
||||
const transition = async (newStatus: string) => {
|
||||
await api.post(`/issues/${id}/transition?new_status=${newStatus}`)
|
||||
const { data } = await api.get<Issue>(`/issues/${id}`)
|
||||
setIssue(data)
|
||||
}
|
||||
|
||||
if (!issue) return <div className="loading">加载中...</div>
|
||||
|
||||
const statusActions: Record<string, string[]> = {
|
||||
open: ['in_progress', 'blocked'],
|
||||
in_progress: ['resolved', 'blocked'],
|
||||
blocked: ['open', 'in_progress'],
|
||||
resolved: ['closed', 'open'],
|
||||
closed: ['open'],
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="issue-detail">
|
||||
<button className="btn-back" onClick={() => navigate('/issues')}>← 返回</button>
|
||||
|
||||
<div className="issue-header">
|
||||
<h2>#{issue.id} {issue.title}</h2>
|
||||
<div className="issue-meta">
|
||||
<span className={`badge status-${issue.status}`}>{issue.status}</span>
|
||||
<span className={`badge priority-${issue.priority}`}>{issue.priority}</span>
|
||||
<span className="badge">{issue.issue_type}</span>
|
||||
{issue.tags && <span className="tags">{issue.tags}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="issue-body">
|
||||
<div className="section">
|
||||
<h3>描述</h3>
|
||||
<p>{issue.description || '暂无描述'}</p>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3>详情</h3>
|
||||
<dl>
|
||||
<dt>创建时间</dt><dd>{dayjs(issue.created_at).format('YYYY-MM-DD HH:mm')}</dd>
|
||||
{issue.due_date && <><dt>截止日期</dt><dd>{dayjs(issue.due_date).format('YYYY-MM-DD')}</dd></>}
|
||||
{issue.updated_at && <><dt>更新时间</dt><dd>{dayjs(issue.updated_at).format('YYYY-MM-DD HH:mm')}</dd></>}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3>状态变更</h3>
|
||||
<div className="actions">
|
||||
{(statusActions[issue.status] || []).map((s) => (
|
||||
<button key={s} className="btn-transition" onClick={() => transition(s)}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3>评论 ({comments.length})</h3>
|
||||
{comments.map((c) => (
|
||||
<div className="comment" key={c.id}>
|
||||
<div className="comment-meta">用户 #{c.author_id} · {dayjs(c.created_at).format('MM-DD HH:mm')}</div>
|
||||
<p>{c.content}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="comment-form">
|
||||
<textarea value={newComment} onChange={(e) => setNewComment(e.target.value)} placeholder="添加评论..." />
|
||||
<button onClick={addComment} disabled={!newComment.trim()}>提交评论</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
src/pages/IssuesPage.tsx
Normal file
78
src/pages/IssuesPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '@/services/api'
|
||||
import type { Issue, PaginatedResponse } from '@/types'
|
||||
|
||||
export default function IssuesPage() {
|
||||
const [issues, setIssues] = useState<Issue[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [priorityFilter, setPriorityFilter] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const fetchIssues = () => {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: '20' })
|
||||
if (statusFilter) params.set('issue_status', statusFilter)
|
||||
api.get<PaginatedResponse<Issue>>(`/issues?${params}`).then(({ data }) => {
|
||||
setIssues(data.items)
|
||||
setTotal(data.total)
|
||||
setTotalPages(data.total_pages)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => { fetchIssues() }, [page, statusFilter, priorityFilter])
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: '#3b82f6', in_progress: '#f59e0b', resolved: '#10b981',
|
||||
closed: '#6b7280', blocked: '#ef4444',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="issues-page">
|
||||
<div className="page-header">
|
||||
<h2>📋 Issues ({total})</h2>
|
||||
<button className="btn-primary" onClick={() => navigate('/issues/new')}>+ 新建 Issue</button>
|
||||
</div>
|
||||
|
||||
<div className="filters">
|
||||
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<table className="issues-table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>标题</th><th>状态</th><th>优先级</th><th>类型</th><th>标签</th><th>创建时间</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{issues.map((i) => (
|
||||
<tr key={i.id} onClick={() => navigate(`/issues/${i.id}`)} className="clickable">
|
||||
<td>{i.id}</td>
|
||||
<td className="issue-title">{i.title}</td>
|
||||
<td><span className="badge" style={{ backgroundColor: statusColors[i.status] || '#ccc' }}>{i.status}</span></td>
|
||||
<td><span className={`badge priority-${i.priority}`}>{i.priority}</span></td>
|
||||
<td>{i.issue_type}</td>
|
||||
<td>{i.tags || '-'}</td>
|
||||
<td>{new Date(i.created_at).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>上一页</button>
|
||||
<span>{page} / {totalPages}</span>
|
||||
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
src/pages/LoginPage.tsx
Normal file
54
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
onLogin: (username: string, password: string) => Promise<void>
|
||||
}
|
||||
|
||||
export default function LoginPage({ onLogin }: Props) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await onLogin(username, password)
|
||||
} catch {
|
||||
setError('登录失败,请检查用户名和密码')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1>⚓ HarborForge</h1>
|
||||
<p className="subtitle">Agent/人类协同任务管理平台</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user