diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a21f178
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+dist
+.git
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..753a9b2
--- /dev/null
+++ b/Dockerfile
@@ -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;"]
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..c140927
--- /dev/null
+++ b/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ HarborForge
+
+
+
+
+
+
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..129d125
--- /dev/null
+++ b/nginx.conf
@@ -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;
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..73093a9
--- /dev/null
+++ b/package.json
@@ -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"
+ }
+}
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..45cde9e
--- /dev/null
+++ b/src/App.tsx
@@ -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 加载中...
+
+ if (!user) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+ )
+}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
new file mode 100644
index 0000000..3096de9
--- /dev/null
+++ b/src/components/Sidebar.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts
new file mode 100644
index 0000000..adbd62a
--- /dev/null
+++ b/src/hooks/useAuth.ts
@@ -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({
+ 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('/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 }
+}
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..fae3c5f
--- /dev/null
+++ b/src/index.css
@@ -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; }
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..964aeb4
--- /dev/null
+++ b/src/main.tsx
@@ -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(
+
+
+ ,
+)
diff --git a/src/pages/CreateIssuePage.tsx b/src/pages/CreateIssuePage.tsx
new file mode 100644
index 0000000..86a175d
--- /dev/null
+++ b/src/pages/CreateIssuePage.tsx
@@ -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([])
+ const [form, setForm] = useState({
+ title: '', description: '', project_id: 0, issue_type: 'task',
+ priority: 'medium', tags: '', reporter_id: 1,
+ })
+
+ useEffect(() => {
+ api.get('/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 (
+
+
新建 Issue
+
+
+ )
+}
diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx
new file mode 100644
index 0000000..072ae19
--- /dev/null
+++ b/src/pages/DashboardPage.tsx
@@ -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(null)
+
+ useEffect(() => {
+ api.get('/dashboard/stats').then(({ data }) => setStats(data))
+ }, [])
+
+ if (!stats) return 加载中...
+
+ const statusColors: Record = {
+ open: '#3b82f6', in_progress: '#f59e0b', resolved: '#10b981',
+ closed: '#6b7280', blocked: '#ef4444',
+ }
+ const priorityColors: Record = {
+ low: '#6b7280', medium: '#3b82f6', high: '#f59e0b', critical: '#ef4444',
+ }
+
+ return (
+
+
📊 仪表盘
+
+
+
+ {stats.total_issues}
+ 总 Issues
+
+ {Object.entries(stats.by_status).map(([k, v]) => (
+
+ {v}
+ {k}
+
+ ))}
+
+
+
+
按优先级
+
+ {Object.entries(stats.by_priority).map(([k, v]) => (
+
+ ))}
+
+
+
+
+
最近 Issues
+
+
+ | ID | 标题 | 状态 | 优先级 | 类型 |
+
+
+ {stats.recent_issues.map((i) => (
+
+ | #{i.id} |
+ {i.title} |
+ {i.status} |
+ {i.priority} |
+ {i.issue_type} |
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/pages/IssueDetailPage.tsx b/src/pages/IssueDetailPage.tsx
new file mode 100644
index 0000000..5d1263b
--- /dev/null
+++ b/src/pages/IssueDetailPage.tsx
@@ -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(null)
+ const [comments, setComments] = useState([])
+ const [newComment, setNewComment] = useState('')
+
+ useEffect(() => {
+ api.get(`/issues/${id}`).then(({ data }) => setIssue(data))
+ api.get(`/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(`/issues/${id}/comments`)
+ setComments(data)
+ }
+
+ const transition = async (newStatus: string) => {
+ await api.post(`/issues/${id}/transition?new_status=${newStatus}`)
+ const { data } = await api.get(`/issues/${id}`)
+ setIssue(data)
+ }
+
+ if (!issue) return 加载中...
+
+ const statusActions: Record = {
+ open: ['in_progress', 'blocked'],
+ in_progress: ['resolved', 'blocked'],
+ blocked: ['open', 'in_progress'],
+ resolved: ['closed', 'open'],
+ closed: ['open'],
+ }
+
+ return (
+
+
+
+
+
#{issue.id} {issue.title}
+
+ {issue.status}
+ {issue.priority}
+ {issue.issue_type}
+ {issue.tags && {issue.tags}}
+
+
+
+
+
+
描述
+
{issue.description || '暂无描述'}
+
+
+
+
详情
+
+ - 创建时间
- {dayjs(issue.created_at).format('YYYY-MM-DD HH:mm')}
+ {issue.due_date && <>- 截止日期
- {dayjs(issue.due_date).format('YYYY-MM-DD')}
>}
+ {issue.updated_at && <>- 更新时间
- {dayjs(issue.updated_at).format('YYYY-MM-DD HH:mm')}
>}
+
+
+
+
+
状态变更
+
+ {(statusActions[issue.status] || []).map((s) => (
+
+ ))}
+
+
+
+
+
评论 ({comments.length})
+ {comments.map((c) => (
+
+
用户 #{c.author_id} · {dayjs(c.created_at).format('MM-DD HH:mm')}
+
{c.content}
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/src/pages/IssuesPage.tsx b/src/pages/IssuesPage.tsx
new file mode 100644
index 0000000..2760c97
--- /dev/null
+++ b/src/pages/IssuesPage.tsx
@@ -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([])
+ 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>(`/issues?${params}`).then(({ data }) => {
+ setIssues(data.items)
+ setTotal(data.total)
+ setTotalPages(data.total_pages)
+ })
+ }
+
+ useEffect(() => { fetchIssues() }, [page, statusFilter, priorityFilter])
+
+ const statusColors: Record = {
+ open: '#3b82f6', in_progress: '#f59e0b', resolved: '#10b981',
+ closed: '#6b7280', blocked: '#ef4444',
+ }
+
+ return (
+
+
+
📋 Issues ({total})
+
+
+
+
+
+
+
+
+
+ | # | 标题 | 状态 | 优先级 | 类型 | 标签 | 创建时间 |
+
+
+ {issues.map((i) => (
+ navigate(`/issues/${i.id}`)} className="clickable">
+ | {i.id} |
+ {i.title} |
+ {i.status} |
+ {i.priority} |
+ {i.issue_type} |
+ {i.tags || '-'} |
+ {new Date(i.created_at).toLocaleDateString()} |
+
+ ))}
+
+
+
+ {totalPages > 1 && (
+
+
+ {page} / {totalPages}
+
+
+ )}
+
+ )
+}
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..0b90318
--- /dev/null
+++ b/src/pages/LoginPage.tsx
@@ -0,0 +1,54 @@
+import React, { useState } from 'react'
+
+interface Props {
+ onLogin: (username: string, password: string) => Promise
+}
+
+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 (
+
+
+
⚓ HarborForge
+
Agent/人类协同任务管理平台
+
+
+
+ )
+}
diff --git a/src/services/api.ts b/src/services/api.ts
new file mode 100644
index 0000000..5d38049
--- /dev/null
+++ b/src/services/api.ts
@@ -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
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..9f40e4e
--- /dev/null
+++ b/src/types/index.ts
@@ -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 {
+ items: T[]
+ total: number
+ page: number
+ page_size: number
+ total_pages: number
+}
+
+export interface DashboardStats {
+ total_issues: number
+ by_status: Record
+ by_priority: Record
+ by_type: Record
+ recent_issues: Issue[]
+}
+
+export interface LoginResponse {
+ access_token: string
+ token_type: string
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..18cfe85
--- /dev/null
+++ b/tsconfig.json
@@ -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" }]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..42872c5
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..9ebaa08
--- /dev/null
+++ b/vite.config.ts
@@ -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/, ''),
+ },
+ },
+ },
+})