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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.git

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HarborForge</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

15
nginx.conf Normal file
View File

@@ -0,0 +1,15 @@
server {
listen 3000;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
try_files $uri $uri/ /index.html;
}
}

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "harborforge-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.22.0",
"axios": "^1.6.7",
"dayjs": "^1.11.10"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.4.0",
"vite": "^5.1.0"
}
}

39
src/App.tsx Normal file
View 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>
)
}

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

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

26
src/services/api.ts Normal file
View 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
View 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
}

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

21
vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ''),
},
},
},
})