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:
Zhi
2026-02-27 09:47:19 +00:00
parent 32557f1de2
commit 853594f447
20 changed files with 831 additions and 0 deletions

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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>
)
}