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:
39
src/App.tsx
Normal file
39
src/App.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import Sidebar from '@/components/Sidebar'
|
||||
import LoginPage from '@/pages/LoginPage'
|
||||
import DashboardPage from '@/pages/DashboardPage'
|
||||
import IssuesPage from '@/pages/IssuesPage'
|
||||
import IssueDetailPage from '@/pages/IssueDetailPage'
|
||||
import CreateIssuePage from '@/pages/CreateIssuePage'
|
||||
|
||||
export default function App() {
|
||||
const { user, loading, login, logout } = useAuth()
|
||||
|
||||
if (loading) return <div className="loading">加载中...</div>
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<LoginPage onLogin={login} />
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="app-layout">
|
||||
<Sidebar user={user} onLogout={logout} />
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/issues" element={<IssuesPage />} />
|
||||
<Route path="/issues/new" element={<CreateIssuePage />} />
|
||||
<Route path="/issues/:id" element={<IssueDetailPage />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
37
src/components/Sidebar.tsx
Normal file
37
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import type { User } from '@/types'
|
||||
|
||||
interface Props {
|
||||
user: User | null
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export default function Sidebar({ user, onLogout }: Props) {
|
||||
const { pathname } = useLocation()
|
||||
const links = [
|
||||
{ to: '/', icon: '📊', label: '仪表盘' },
|
||||
{ to: '/issues', icon: '📋', label: 'Issues' },
|
||||
{ to: '/projects', icon: '📁', label: '项目' },
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h1>⚓ HarborForge</h1>
|
||||
</div>
|
||||
<ul className="nav-links">
|
||||
{links.map((l) => (
|
||||
<li key={l.to} className={pathname === l.to ? 'active' : ''}>
|
||||
<Link to={l.to}>{l.icon} {l.label}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{user && (
|
||||
<div className="sidebar-footer">
|
||||
<span>👤 {user.username}</span>
|
||||
<button onClick={onLogout}>退出</button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
51
src/hooks/useAuth.ts
Normal file
51
src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import api from '@/services/api'
|
||||
import type { User } from '@/types'
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
token: string | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
token: localStorage.getItem('token'),
|
||||
loading: true,
|
||||
})
|
||||
|
||||
const fetchUser = useCallback(async () => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
setState({ user: null, token: null, loading: false })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { data } = await api.get<User>('/auth/me')
|
||||
setState({ user: data, token, loading: false })
|
||||
} catch {
|
||||
localStorage.removeItem('token')
|
||||
setState({ user: null, token: null, loading: false })
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchUser() }, [fetchUser])
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const form = new URLSearchParams()
|
||||
form.append('username', username)
|
||||
form.append('password', password)
|
||||
const { data } = await api.post('/auth/login', form)
|
||||
localStorage.setItem('token', data.access_token)
|
||||
setState((s) => ({ ...s, token: data.access_token }))
|
||||
await fetchUser()
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token')
|
||||
setState({ user: null, token: null, loading: false })
|
||||
}
|
||||
|
||||
return { ...state, login, logout }
|
||||
}
|
||||
120
src/index.css
Normal file
120
src/index.css
Normal file
@@ -0,0 +1,120 @@
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--bg-card: #1a1d27;
|
||||
--bg-hover: #22252f;
|
||||
--border: #2a2d37;
|
||||
--text: #e1e4ea;
|
||||
--text-dim: #8b8fa3;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--sidebar-w: 220px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* Layout */
|
||||
.app-layout { display: flex; min-height: 100vh; }
|
||||
.main-content { flex: 1; padding: 24px 32px; margin-left: var(--sidebar-w); }
|
||||
.loading { display: flex; align-items: center; justify-content: center; height: 100vh; font-size: 1.2rem; color: var(--text-dim); }
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar { position: fixed; top: 0; left: 0; width: var(--sidebar-w); height: 100vh; background: var(--bg-card); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 16px 0; }
|
||||
.sidebar-header { padding: 8px 20px 24px; }
|
||||
.sidebar-header h1 { font-size: 1.2rem; }
|
||||
.nav-links { list-style: none; flex: 1; }
|
||||
.nav-links li a { display: block; padding: 10px 20px; color: var(--text-dim); transition: .15s; }
|
||||
.nav-links li a:hover, .nav-links li.active a { background: var(--bg-hover); color: var(--text); text-decoration: none; }
|
||||
.sidebar-footer { padding: 12px 20px; border-top: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; font-size: .85rem; color: var(--text-dim); }
|
||||
.sidebar-footer button { background: none; border: 1px solid var(--border); color: var(--text-dim); padding: 4px 10px; border-radius: 4px; cursor: pointer; }
|
||||
|
||||
/* Login */
|
||||
.login-page { display: flex; align-items: center; justify-content: center; height: 100vh; }
|
||||
.login-card { background: var(--bg-card); padding: 40px; border-radius: 12px; border: 1px solid var(--border); width: 360px; text-align: center; }
|
||||
.login-card h1 { margin-bottom: 8px; }
|
||||
.login-card .subtitle { color: var(--text-dim); margin-bottom: 24px; }
|
||||
.login-card form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.login-card input { padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); font-size: .95rem; }
|
||||
.login-card button { padding: 10px; background: var(--accent); color: #fff; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; }
|
||||
.login-card button:hover { background: var(--accent-hover); }
|
||||
.error { color: var(--danger); font-size: .85rem; }
|
||||
|
||||
/* Stats */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; margin: 16px 0 24px; }
|
||||
.stat-card { background: var(--bg-card); border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: 8px; padding: 16px; text-align: center; }
|
||||
.stat-card.total { border-left-color: var(--success); }
|
||||
.stat-number { display: block; font-size: 1.8rem; font-weight: 700; }
|
||||
.stat-label { display: block; font-size: .8rem; color: var(--text-dim); margin-top: 4px; text-transform: capitalize; }
|
||||
|
||||
/* Bar chart */
|
||||
.bar-chart { margin: 12px 0; }
|
||||
.bar-row { display: flex; align-items: center; margin: 6px 0; }
|
||||
.bar-label { width: 70px; font-size: .85rem; color: var(--text-dim); text-transform: capitalize; }
|
||||
.bar { padding: 4px 10px; border-radius: 4px; color: #fff; font-size: .8rem; min-width: 30px; text-align: right; transition: .3s; }
|
||||
|
||||
/* Table */
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||
thead th { text-align: left; padding: 10px 12px; border-bottom: 2px solid var(--border); color: var(--text-dim); font-size: .8rem; text-transform: uppercase; }
|
||||
tbody td { padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
tr.clickable { cursor: pointer; }
|
||||
tr.clickable:hover { background: var(--bg-hover); }
|
||||
.issue-title { font-weight: 500; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Badges */
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; text-transform: capitalize; color: #fff; background: var(--text-dim); }
|
||||
.status-open { background: #3b82f6; }
|
||||
.status-in_progress { background: #f59e0b; }
|
||||
.status-resolved { background: #10b981; }
|
||||
.status-closed { background: #6b7280; }
|
||||
.status-blocked { background: #ef4444; }
|
||||
.priority-low { background: #6b7280; }
|
||||
.priority-medium { background: #3b82f6; }
|
||||
.priority-high { background: #f59e0b; }
|
||||
.priority-critical { background: #ef4444; }
|
||||
|
||||
/* Page header */
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.btn-primary { background: var(--accent); color: #fff; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: .9rem; }
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-back { background: none; border: 1px solid var(--border); color: var(--text-dim); padding: 6px 12px; border-radius: 6px; cursor: pointer; margin-bottom: 16px; }
|
||||
|
||||
/* Filters */
|
||||
.filters { margin-bottom: 12px; display: flex; gap: 8px; }
|
||||
.filters select { padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-card); color: var(--text); }
|
||||
|
||||
/* Pagination */
|
||||
.pagination { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: 16px; }
|
||||
.pagination button { padding: 6px 14px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text); border-radius: 6px; cursor: pointer; }
|
||||
.pagination button:disabled { opacity: .4; cursor: default; }
|
||||
|
||||
/* Issue detail */
|
||||
.issue-header { margin-bottom: 20px; }
|
||||
.issue-header h2 { margin-bottom: 8px; }
|
||||
.issue-meta { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.tags { color: var(--accent); font-size: .85rem; }
|
||||
.section { margin: 20px 0; }
|
||||
.section h3 { margin-bottom: 8px; color: var(--text-dim); font-size: .9rem; text-transform: uppercase; }
|
||||
dl { display: grid; grid-template-columns: 120px 1fr; gap: 6px; }
|
||||
dt { color: var(--text-dim); font-size: .85rem; }
|
||||
dd { font-size: .9rem; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.btn-transition { padding: 6px 14px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text); border-radius: 6px; cursor: pointer; text-transform: capitalize; }
|
||||
.btn-transition:hover { background: var(--bg-hover); }
|
||||
|
||||
/* Comments */
|
||||
.comment { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 8px; }
|
||||
.comment-meta { font-size: .8rem; color: var(--text-dim); margin-bottom: 4px; }
|
||||
.comment-form { margin-top: 12px; }
|
||||
.comment-form textarea { width: 100%; min-height: 80px; padding: 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); resize: vertical; margin-bottom: 8px; }
|
||||
.comment-form button { padding: 8px 16px; background: var(--accent); color: #fff; border: none; border-radius: 6px; cursor: pointer; }
|
||||
|
||||
/* Create Issue form */
|
||||
.create-issue form { max-width: 600px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.create-issue label { display: flex; flex-direction: column; gap: 4px; font-size: .85rem; color: var(--text-dim); }
|
||||
.create-issue input, .create-issue textarea, .create-issue select { padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); font-size: .95rem; }
|
||||
.create-issue textarea { min-height: 100px; resize: vertical; }
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
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>
|
||||
)
|
||||
}
|
||||
26
src/services/api.ts
Normal file
26
src/services/api.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
62
src/types/index.ts
Normal file
62
src/types/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
is_admin: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
owner_id: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
id: number
|
||||
title: string
|
||||
description: string | null
|
||||
issue_type: 'task' | 'bug' | 'feature' | 'resolution'
|
||||
status: 'open' | 'in_progress' | 'resolved' | 'closed' | 'blocked'
|
||||
priority: 'low' | 'medium' | 'high' | 'critical'
|
||||
project_id: number
|
||||
reporter_id: number
|
||||
assignee_id: number | null
|
||||
tags: string | null
|
||||
due_date: string | null
|
||||
milestone_id: number | null
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: number
|
||||
content: string
|
||||
issue_id: number
|
||||
author_id: number
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
total_issues: number
|
||||
by_status: Record<string, number>
|
||||
by_priority: Record<string, number>
|
||||
by_type: Record<string, number>
|
||||
recent_issues: Issue[]
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
}
|
||||
Reference in New Issue
Block a user