diff --git a/Dockerfile b/Dockerfile index 753a9b2..8407916 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,17 @@ +# Build stage FROM node:20-alpine AS build WORKDIR /app -COPY package.json ./ +COPY package.json package-lock.json* ./ RUN npm install COPY . . +ARG VITE_API_BASE=/api +ENV VITE_API_BASE=$VITE_API_BASE 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 +# Production stage — lightweight static server, no nginx +FROM node:20-alpine +RUN npm install -g serve@14 +WORKDIR /app +COPY --from=build /app/dist ./dist EXPOSE 3000 -CMD ["nginx", "-g", "daemon off;"] +CMD ["serve", "-s", "dist", "-l", "3000"] diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index 129d125..0000000 --- a/nginx.conf +++ /dev/null @@ -1,15 +0,0 @@ -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/src/App.tsx b/src/App.tsx index 45cde9e..473aaca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,11 @@ import DashboardPage from '@/pages/DashboardPage' import IssuesPage from '@/pages/IssuesPage' import IssueDetailPage from '@/pages/IssueDetailPage' import CreateIssuePage from '@/pages/CreateIssuePage' +import ProjectsPage from '@/pages/ProjectsPage' +import ProjectDetailPage from '@/pages/ProjectDetailPage' +import MilestonesPage from '@/pages/MilestonesPage' +import MilestoneDetailPage from '@/pages/MilestoneDetailPage' +import NotificationsPage from '@/pages/NotificationsPage' export default function App() { const { user, loading, login, logout } = useAuth() @@ -30,6 +35,11 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 3096de9..749d8bf 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,4 +1,6 @@ +import { useState, useEffect } from 'react' import { Link, useLocation } from 'react-router-dom' +import api from '@/services/api' import type { User } from '@/types' interface Props { @@ -8,10 +10,26 @@ interface Props { export default function Sidebar({ user, onLogout }: Props) { const { pathname } = useLocation() + const [unreadCount, setUnreadCount] = useState(0) + + useEffect(() => { + api.get<{ count: number }>('/notifications/count') + .then(({ data }) => setUnreadCount(data.count)) + .catch(() => {}) + const timer = setInterval(() => { + api.get<{ count: number }>('/notifications/count') + .then(({ data }) => setUnreadCount(data.count)) + .catch(() => {}) + }, 30000) + return () => clearInterval(timer) + }, []) + const links = [ { to: '/', icon: '📊', label: '仪表盘' }, { to: '/issues', icon: '📋', label: 'Issues' }, { to: '/projects', icon: '📁', label: '项目' }, + { to: '/milestones', icon: '🏁', label: '里程碑' }, + { to: '/notifications', icon: '🔔', label: `通知${unreadCount > 0 ? ` (${unreadCount})` : ''}` }, ] return ( @@ -21,7 +39,7 @@ export default function Sidebar({ user, onLogout }: Props) {
    {links.map((l) => ( -
  • +
  • {l.icon} {l.label}
  • ))} diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index adbd62a..4954072 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -36,7 +36,9 @@ export function useAuth() { const form = new URLSearchParams() form.append('username', username) form.append('password', password) - const { data } = await api.post('/auth/login', form) + const { data } = await api.post('/auth/token', form, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) localStorage.setItem('token', data.access_token) setState((s) => ({ ...s, token: data.access_token })) await fetchUser() diff --git a/src/index.css b/src/index.css index fae3c5f..bf60b36 100644 --- a/src/index.css +++ b/src/index.css @@ -118,3 +118,45 @@ dd { font-size: .9rem; } .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; } + +/* Project grid */ +.project-grid, .milestone-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; margin-top: 16px; } +.project-card, .milestone-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 20px; cursor: pointer; transition: .15s; } +.project-card:hover, .milestone-card:hover { border-color: var(--accent); background: var(--bg-hover); } +.project-card h3, .milestone-card h3 { margin-bottom: 8px; } +.project-desc { color: var(--text-dim); font-size: .9rem; margin-bottom: 12px; } +.project-meta { font-size: .8rem; color: var(--text-dim); display: flex; gap: 12px; } + +/* Milestone card header */ +.milestone-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } +.milestone-item { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border); cursor: pointer; } +.milestone-item:hover { background: var(--bg-hover); } +.milestone-title { font-weight: 500; } + +/* Progress bar */ +.progress-bar-container { width: 100%; height: 20px; background: var(--bg); border-radius: 10px; overflow: hidden; border: 1px solid var(--border); } +.progress-bar { height: 100%; background: var(--success); color: #fff; font-size: .75rem; display: flex; align-items: center; justify-content: center; min-width: 30px; transition: width .3s; border-radius: 10px; } + +/* Notification list */ +.notification-list { margin-top: 16px; } +.notification-item { display: flex; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; transition: .15s; } +.notification-item:hover { background: var(--bg-hover); } +.notification-item.unread { background: var(--bg-card); } +.notification-dot { width: 12px; color: var(--accent); font-size: .6rem; padding-top: 4px; } +.notification-body p { margin-bottom: 4px; } + +/* Inline form */ +.inline-form { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; } +.inline-form input, .inline-form select { padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); } + +/* Filter checkbox */ +.filter-check { display: flex; align-items: center; gap: 6px; color: var(--text-dim); font-size: .9rem; cursor: pointer; } + +/* Member list */ +.member-list { display: flex; gap: 8px; flex-wrap: wrap; } + +/* Empty state */ +.empty { color: var(--text-dim); font-style: italic; padding: 16px 0; } + +/* Text dim helper */ +.text-dim { color: var(--text-dim); font-size: .85rem; } diff --git a/src/pages/MilestoneDetailPage.tsx b/src/pages/MilestoneDetailPage.tsx new file mode 100644 index 0000000..97ac2b4 --- /dev/null +++ b/src/pages/MilestoneDetailPage.tsx @@ -0,0 +1,76 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import api from '@/services/api' +import type { Milestone, MilestoneProgress, Issue } from '@/types' +import dayjs from 'dayjs' + +export default function MilestoneDetailPage() { + const { id } = useParams() + const navigate = useNavigate() + const [milestone, setMilestone] = useState(null) + const [progress, setProgress] = useState(null) + const [issues, setIssues] = useState([]) + + useEffect(() => { + api.get(`/milestones/${id}`).then(({ data }) => setMilestone(data)) + api.get(`/milestones/${id}/progress`).then(({ data }) => setProgress(data)).catch(() => {}) + api.get(`/milestones/${id}/issues`).then(({ data }) => setIssues(data)).catch(() => {}) + }, [id]) + + if (!milestone) return
    加载中...
    + + return ( +
    + + +
    +

    🏁 {milestone.title}

    +
    + {milestone.status} + {milestone.due_date && 截止 {dayjs(milestone.due_date).format('YYYY-MM-DD')}} +
    +
    + + {milestone.description && ( +
    +

    描述

    +

    {milestone.description}

    +
    + )} + + {progress && ( +
    +

    进度

    +
    +
    + {progress.progress_percent.toFixed(0)}% +
    +
    +

    + {progress.completed_issues} / {progress.total_issues} issues 已完成 +

    +
    + )} + +
    +

    Issues ({issues.length})

    + + + + + + {issues.map((i) => ( + navigate(`/issues/${i.id}`)}> + + + + + + ))} + {issues.length === 0 && } + +
    #标题状态优先级
    {i.id}{i.title}{i.status}{i.priority}
    暂无关联 issue
    +
    +
    + ) +} diff --git a/src/pages/MilestonesPage.tsx b/src/pages/MilestonesPage.tsx new file mode 100644 index 0000000..541fe4a --- /dev/null +++ b/src/pages/MilestonesPage.tsx @@ -0,0 +1,87 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import api from '@/services/api' +import type { Milestone, Project } from '@/types' +import dayjs from 'dayjs' + +export default function MilestonesPage() { + const [milestones, setMilestones] = useState([]) + const [projects, setProjects] = useState([]) + const [projectFilter, setProjectFilter] = useState('') + const [showCreate, setShowCreate] = useState(false) + const [form, setForm] = useState({ title: '', description: '', project_id: 0, due_date: '' }) + const navigate = useNavigate() + + const fetchMilestones = () => { + const params = projectFilter ? `?project_id=${projectFilter}` : '' + api.get(`/milestones${params}`).then(({ data }) => setMilestones(data)) + } + + useEffect(() => { + api.get('/projects').then(({ data }) => { + setProjects(data) + if (data.length) setForm((f) => ({ ...f, project_id: data[0].id })) + }) + }, []) + + useEffect(() => { fetchMilestones() }, [projectFilter]) + + const createMilestone = async (e: React.FormEvent) => { + e.preventDefault() + const payload: Record = { ...form } + if (!form.due_date) delete payload.due_date + await api.post('/milestones', payload) + setShowCreate(false) + fetchMilestones() + } + + return ( +
    +
    +

    🏁 里程碑 ({milestones.length})

    + +
    + +
    + +
    + + {showCreate && ( +
    + setForm({ ...form, title: e.target.value })} /> + setForm({ ...form, description: e.target.value })} /> + + setForm({ ...form, due_date: e.target.value })} /> + +
    + )} + +
    + {milestones.map((ms) => ( +
    navigate(`/milestones/${ms.id}`)}> +
    + {ms.status} +

    {ms.title}

    +
    +

    {ms.description || '暂无描述'}

    +
    + {ms.due_date && 截止 {dayjs(ms.due_date).format('YYYY-MM-DD')}} + 创建于 {dayjs(ms.created_at).format('YYYY-MM-DD')} +
    +
    + ))} + {milestones.length === 0 &&

    暂无里程碑

    } +
    +
    + ) +} diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx new file mode 100644 index 0000000..e8ac2b0 --- /dev/null +++ b/src/pages/NotificationsPage.tsx @@ -0,0 +1,69 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import api from '@/services/api' +import type { Notification } from '@/types' +import dayjs from 'dayjs' + +export default function NotificationsPage() { + const [notifications, setNotifications] = useState([]) + const [unreadOnly, setUnreadOnly] = useState(false) + const [unreadCount, setUnreadCount] = useState(0) + const navigate = useNavigate() + + const fetchNotifications = () => { + const params = new URLSearchParams() + if (unreadOnly) params.set('unread_only', 'true') + api.get(`/notifications?${params}`).then(({ data }) => setNotifications(data)) + api.get<{ count: number }>('/notifications/count').then(({ data }) => setUnreadCount(data.count)).catch(() => {}) + } + + useEffect(() => { fetchNotifications() }, [unreadOnly]) + + const markRead = async (id: number) => { + await api.post(`/notifications/${id}/read`) + fetchNotifications() + } + + const markAllRead = async () => { + await api.post('/notifications/read-all') + fetchNotifications() + } + + return ( +
    +
    +

    🔔 通知 {unreadCount > 0 && {unreadCount}}

    + {unreadCount > 0 && ( + + )} +
    + +
    + +
    + +
    + {notifications.map((n) => ( +
    { + if (!n.is_read) markRead(n.id) + if (n.issue_id) navigate(`/issues/${n.issue_id}`) + }} + > +
    {n.is_read ? '' : '●'}
    +
    +

    {n.message}

    + {dayjs(n.created_at).format('MM-DD HH:mm')} +
    +
    + ))} + {notifications.length === 0 &&

    暂无通知

    } +
    +
    + ) +} diff --git a/src/pages/ProjectDetailPage.tsx b/src/pages/ProjectDetailPage.tsx new file mode 100644 index 0000000..6daa6d8 --- /dev/null +++ b/src/pages/ProjectDetailPage.tsx @@ -0,0 +1,105 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import api from '@/services/api' +import type { Project, ProjectMember, Issue, Milestone, PaginatedResponse } from '@/types' +import dayjs from 'dayjs' + +export default function ProjectDetailPage() { + const { id } = useParams() + const navigate = useNavigate() + const [project, setProject] = useState(null) + const [members, setMembers] = useState([]) + const [issues, setIssues] = useState([]) + const [milestones, setMilestones] = useState([]) + const [editing, setEditing] = useState(false) + const [editForm, setEditForm] = useState({ name: '', description: '' }) + + useEffect(() => { + api.get(`/projects/${id}`).then(({ data }) => { + setProject(data) + setEditForm({ name: data.name, description: data.description || '' }) + }) + api.get(`/projects/${id}/members`).then(({ data }) => setMembers(data)) + api.get>(`/issues?project_id=${id}&page_size=10`).then(({ data }) => setIssues(data.items)) + api.get(`/milestones?project_id=${id}`).then(({ data }) => setMilestones(data)) + }, [id]) + + const updateProject = async (e: React.FormEvent) => { + e.preventDefault() + const { data } = await api.patch(`/projects/${id}`, editForm) + setProject(data) + setEditing(false) + } + + if (!project) return
    加载中...
    + + return ( +
    + + +
    + {editing ? ( +
    + setEditForm({ ...editForm, name: e.target.value })} required /> + setEditForm({ ...editForm, description: e.target.value })} placeholder="描述" /> + + +
    + ) : ( + <> +

    📁 {project.name}

    +

    {project.description || '暂无描述'}

    + + + )} +
    + +
    +

    成员 ({members.length})

    + {members.length > 0 ? ( +
    + {members.map((m) => ( + {`用户 #${m.user_id} (${m.role})`} + ))} +
    + ) : ( +

    暂无成员

    + )} +
    + +
    +

    里程碑 ({milestones.length})

    + {milestones.map((ms) => ( +
    navigate(`/milestones/${ms.id}`)}> + {ms.status} + {ms.title} + {ms.due_date && · 截止 {dayjs(ms.due_date).format('YYYY-MM-DD')}} +
    + ))} + {milestones.length === 0 &&

    暂无里程碑

    } +
    + +
    +
    +

    最近 Issues

    + +
    + + + + + + {issues.map((i) => ( + navigate(`/issues/${i.id}`)}> + + + + + + ))} + +
    #标题状态优先级
    {i.id}{i.title}{i.status}{i.priority}
    +
    +
    + ) +} diff --git a/src/pages/ProjectsPage.tsx b/src/pages/ProjectsPage.tsx new file mode 100644 index 0000000..2a3a1c7 --- /dev/null +++ b/src/pages/ProjectsPage.tsx @@ -0,0 +1,64 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import api from '@/services/api' +import type { Project } from '@/types' +import dayjs from 'dayjs' + +export default function ProjectsPage() { + const [projects, setProjects] = useState([]) + const [showCreate, setShowCreate] = useState(false) + const [form, setForm] = useState({ name: '', description: '', owner_id: 1 }) + const navigate = useNavigate() + + const fetchProjects = () => { + api.get('/projects').then(({ data }) => setProjects(data)) + } + + useEffect(() => { fetchProjects() }, []) + + const createProject = async (e: React.FormEvent) => { + e.preventDefault() + await api.post('/projects', form) + setForm({ name: '', description: '', owner_id: 1 }) + setShowCreate(false) + fetchProjects() + } + + return ( +
    +
    +

    📁 项目 ({projects.length})

    + +
    + + {showCreate && ( +
    + setForm({ ...form, name: e.target.value })} + /> + setForm({ ...form, description: e.target.value })} + /> + +
    + )} + +
    + {projects.map((p) => ( +
    navigate(`/projects/${p.id}`)}> +

    {p.name}

    +

    {p.description || '暂无描述'}

    +
    + 创建于 {dayjs(p.created_at).format('YYYY-MM-DD')} +
    +
    + ))} + {projects.length === 0 &&

    暂无项目,点击上方创建

    } +
    +
    + ) +} diff --git a/src/services/api.ts b/src/services/api.ts index 5d38049..4814c5b 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,7 +1,7 @@ import axios from 'axios' const api = axios.create({ - baseURL: '/api', + baseURL: import.meta.env.VITE_API_BASE || '/api', }) api.interceptors.request.use((config) => { @@ -17,7 +17,9 @@ api.interceptors.response.use( (err) => { if (err.response?.status === 401) { localStorage.removeItem('token') - window.location.href = '/login' + if (window.location.pathname !== '/login') { + window.location.href = '/login' + } } return Promise.reject(err) } diff --git a/src/types/index.ts b/src/types/index.ts index 9f40e4e..5cfb782 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,7 +2,9 @@ export interface User { id: number username: string email: string + full_name: string | null is_admin: boolean + is_active: boolean created_at: string } @@ -14,11 +16,18 @@ export interface Project { created_at: string } +export interface ProjectMember { + id: number + user_id: number + project_id: number + role: string +} + export interface Issue { id: number title: string description: string | null - issue_type: 'task' | 'bug' | 'feature' | 'resolution' + issue_type: 'task' | 'story' | 'test' | 'resolution' status: 'open' | 'in_progress' | 'resolved' | 'closed' | 'blocked' priority: 'low' | 'medium' | 'high' | 'critical' project_id: number @@ -27,6 +36,9 @@ export interface Issue { tags: string | null due_date: string | null milestone_id: number | null + resolution_summary: string | null + positions: string | null + pending_matters: string | null created_at: string updated_at: string | null } @@ -40,6 +52,33 @@ export interface Comment { updated_at: string | null } +export interface Milestone { + id: number + title: string + description: string | null + status: string + project_id: number + due_date: string | null + created_at: string + updated_at: string | null +} + +export interface MilestoneProgress { + milestone_id: number + total_issues: number + completed_issues: number + progress_percent: number +} + +export interface Notification { + id: number + user_id: number + message: string + is_read: boolean + issue_id: number | null + created_at: string +} + export interface PaginatedResponse { items: T[] total: number