Files
ok_r/app/projects/[id]/client-page.tsx
karlcarlo fc9cfda29e feat: 添加 API 客户端以处理 basePath 并更新所有 fetch 调用
移除 next.config.js 中的 standalone 输出配置,添加 lib/api-client.ts 封装 fetch 请求,
自动为所有 API 调用添加配置的 basePath。更新所有页面和组件中的 fetch 调用以使用新的 fetchApi 函数,
确保在部署到子路径时 API 请求能正确工作。同时调整 tsconfig.json 的格式并修复项目创建时的字段名。
2026-01-28 15:54:19 +08:00

858 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { StatusBadge, StatusDot } from '@/components/status-badge';
import { ProgressRing } from '@/components/progress-ring';
import { useUser } from '@/components/user-context';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Label } from '@/components/ui/label';
import { calculateProgress, getDaysRemaining, formatDateRange, isKRActive } from '@/lib/kr-utils';
import { REASON_CODES, PERIOD_TYPES } from '@/lib/constants';
import type { Project, User, Objective, KR, Task, TaskStep } from '@/types/database';
import { toast } from 'sonner';
import {
ArrowLeft,
Plus,
Target,
Calendar,
Users,
AlertTriangle,
Clock,
Pencil,
Trash2,
Loader2,
CheckSquare,
User as UserIcon,
} from 'lucide-react';
import { TaskEditDialog } from '@/components/task-edit-dialog';
import { fetchApi } from '@/lib/api-client';
interface ProjectWithRelations extends Project {
owner: User | null;
pm: User | null;
members: { user_id: string; users: User }[];
}
interface KRWithRelations extends KR {
owner: User | null;
}
export default function ProjectDetailPage({ projectId }: { projectId: string }) {
// Force re-compile
const { currentUser, users } = useUser();
const router = useRouter();
const [project, setProject] = useState<ProjectWithRelations | null>(null);
const [objectives, setObjectives] = useState<Objective[]>([]);
const [krs, setKRs] = useState<KRWithRelations[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isProjectEditDialogOpen, setIsProjectEditDialogOpen] = useState(false);
const [isObjectiveDialogOpen, setIsObjectiveDialogOpen] = useState(false);
const [editingObjective, setEditingObjective] = useState<Objective | null>(null);
const [deleteObjectiveId, setDeleteObjectiveId] = useState<string | null>(null);
const [isDeleteObjectiveOpen, setIsDeleteObjectiveOpen] = useState(false);
const [isDeletingObjective, setIsDeletingObjective] = useState(false);
const [tasks, setTasks] = useState<(Task & { steps: TaskStep[] })[]>([]);
const [isTaskDialogOpen, setIsTaskDialogOpen] = useState(false);
const [editingTask, setEditingTask] = useState<(Task & { steps: TaskStep[] }) | null>(null);
// Edit form state
const [editForm, setEditForm] = useState({
name: '',
description: '',
project_type: '',
owner_id: '',
pm_id: '',
member_ids: [] as string[],
});
const initEditForm = useCallback(() => {
if (project) {
setEditForm({
name: project.name,
description: project.description || '',
project_type: project.project_type || '通用',
owner_id: project.owner_id || '',
pm_id: project.pm_id || '',
member_ids: project.members?.map(m => m.user_id) || [],
});
}
}, [project]);
useEffect(() => {
if (isProjectEditDialogOpen) {
initEditForm();
}
}, [isProjectEditDialogOpen, initEditForm]);
const fetchData = useCallback(async () => {
try {
const [projectRes, objectivesRes, krsRes, tasksRes] = await Promise.all([
fetchApi(`/api/projects/${projectId}`),
fetchApi(`/api/objectives?project_id=${projectId}`),
fetchApi(`/api/krs?project_id=${projectId}`),
fetchApi(`/api/tasks?project_id=${projectId}`),
]);
if (projectRes.status === 403) {
setLoadError('你没有权限查看此项目');
setProject(null);
return;
}
if (projectRes.status === 404) {
setLoadError('项目不存在');
setProject(null);
return;
}
if (projectRes.ok) {
const projectData = await projectRes.json();
setProject(projectData);
setLoadError(null);
}
if (objectivesRes.ok) {
const objectivesData = await objectivesRes.json();
setObjectives(objectivesData);
}
if (krsRes.ok) {
const krsData = await krsRes.json();
setKRs(krsData);
}
if (tasksRes.ok) {
const tasksData = await tasksRes.json();
setTasks(tasksData);
}
} catch (error) {
console.error('Failed to fetch project data:', error);
setLoadError('加载失败');
} finally {
setIsLoading(false);
}
}, [projectId]);
useEffect(() => {
fetchData();
}, [fetchData]);
const canEdit = currentUser?.role === 'owner' || currentUser?.id === project?.pm_id;
const isMember = project?.members?.some(m => m.user_id === currentUser?.id);
const canAddKR = canEdit || isMember;
const handleDeleteProject = async () => {
setIsDeleting(true);
try {
const res = await fetchApi(`/api/projects/${projectId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('删除失败');
toast.success('项目已删除');
router.push('/projects');
} catch (error) {
toast.error('删除失败');
} finally {
setIsDeleting(false);
}
};
const handleEditProject = async (e: React.FormEvent) => {
e.preventDefault();
setIsEditing(true);
try {
const res = await fetchApi(`/api/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm),
});
if (!res.ok) throw new Error('更新失败');
toast.success('项目已更新');
setIsProjectEditDialogOpen(false);
fetchData();
} catch (error) {
toast.error('更新失败');
} finally {
setIsEditing(false);
}
};
const requestDeleteObjective = (objectiveId: string) => {
setDeleteObjectiveId(objectiveId);
setIsDeleteObjectiveOpen(true);
};
const handleDeleteObjective = async () => {
if (!deleteObjectiveId) return;
setIsDeletingObjective(true);
try {
const res = await fetchApi(`/api/objectives/${deleteObjectiveId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('删除失败');
toast.success('目标已删除');
fetchData();
} catch (error) {
toast.error('删除失败');
} finally {
setIsDeletingObjective(false);
setIsDeleteObjectiveOpen(false);
setDeleteObjectiveId(null);
}
};
const handleEditObjective = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!editingObjective) return;
setIsEditing(true);
const formData = new FormData(e.currentTarget);
try {
const res = await fetchApi(`/api/objectives/${editingObjective.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.get('title'),
description: formData.get('description'),
period_start: formData.get('period_start'),
period_end: formData.get('period_end'),
}),
});
if (!res.ok) throw new Error('更新失败');
toast.success('目标已更新');
setIsObjectiveDialogOpen(false);
setEditingObjective(null);
fetchData();
} catch (error) {
toast.error('更新失败');
} finally {
setIsEditing(false);
}
};
const handleTaskStatusChange = async (taskId: string, newStatus: string) => {
try {
const res = await fetchApi(`/api/tasks/${taskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});
if (!res.ok) throw new Error('更新失败');
setTasks(tasks.map(t => t.id === taskId ? { ...t, status: newStatus as any } : t));
toast.success('计划状态已更新');
} catch (error) {
toast.error('更新失败');
}
};
const activeKRs = krs.filter(isKRActive);
const completedKRs = activeKRs.filter(kr => calculateProgress(kr) >= 1);
if (isLoading) {
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-6 bg-slate-200 rounded w-24" />
<div className="h-8 bg-slate-200 rounded w-64" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-32 bg-slate-200 rounded-lg" />
))}
</div>
<div className="h-64 bg-slate-200 rounded-lg" />
</div>
</div>
</div>
);
}
if (loadError) {
return (
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<Button variant="outline" onClick={() => router.push('/projects')}>
<ArrowLeft className="h-4 w-4 mr-2" />
返回项目列表
</Button>
</div>
<Card>
<CardContent className="p-8 text-center text-muted-foreground">{loadError}</CardContent>
</Card>
</div>
);
}
if (!project) {
return (
<div className="max-w-7xl mx-auto px-4 py-8 text-center">
<p className="text-muted-foreground">项目不存在</p>
<Button asChild className="mt-4" variant="outline">
<Link href="/projects">返回项目列表</Link>
</Button>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="mb-6">
<Link
href="/projects"
className="inline-flex items-center text-sm text-muted-foreground hover:text-slate-800 mb-4"
>
<ArrowLeft className="h-4 w-4 mr-1" />
返回项目列表
</Link>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-slate-800">{project.name}</h1>
<StatusBadge status={project.status} />
</div>
{project.description && (
<p className="text-muted-foreground mt-2">{project.description}</p>
)}
</div>
{canAddKR && (
<div className="flex gap-2">
{canEdit && (
<>
{currentUser?.role === 'owner' && (
<>
<Dialog open={isProjectEditDialogOpen} onOpenChange={setIsProjectEditDialogOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>编辑项目</DialogTitle>
</DialogHeader>
<form onSubmit={handleEditProject} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="name">项目名称</Label>
<Input
id="name"
value={editForm.name}
onChange={e => setEditForm(prev => ({ ...prev, name: e.target.value }))}
required
/>
</div>
<div>
<Label htmlFor="project_type">项目类型</Label>
<Input
id="project_type"
value={editForm.project_type}
onChange={e => setEditForm(prev => ({ ...prev, project_type: e.target.value }))}
placeholder="如:研发、市场、运营"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="owner">负责人</Label>
<Select
value={editForm.owner_id}
onValueChange={val => setEditForm(prev => ({ ...prev, owner_id: val }))}
>
<SelectTrigger>
<SelectValue placeholder="选择负责人" />
</SelectTrigger>
<SelectContent>
{users.map(u => (
<SelectItem key={u.id} value={u.id}>{u.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="pm">项目经理</Label>
<Select
value={editForm.pm_id}
onValueChange={val => setEditForm(prev => ({ ...prev, pm_id: val }))}
>
<SelectTrigger>
<SelectValue placeholder="选择项目经理" />
</SelectTrigger>
<SelectContent>
{users.map(u => (
<SelectItem key={u.id} value={u.id}>{u.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="mb-2 block">项目成员</Label>
<ScrollArea className="h-[120px] w-full border rounded-md p-4">
<div className="grid grid-cols-3 gap-2">
{users.map(user => (
<div key={user.id} className="flex items-center space-x-2">
<Checkbox
id={`member-${user.id}`}
checked={editForm.member_ids.includes(user.id)}
onCheckedChange={(checked) => {
setEditForm(prev => {
if (checked) {
return { ...prev, member_ids: [...prev.member_ids, user.id] };
} else {
return { ...prev, member_ids: prev.member_ids.filter(id => id !== user.id) };
}
});
}}
/>
<label
htmlFor={`member-${user.id}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{user.name}
</label>
</div>
))}
</div>
</ScrollArea>
</div>
<div>
<Label htmlFor="description">项目描述</Label>
<Textarea
id="description"
value={editForm.description}
onChange={e => setEditForm(prev => ({ ...prev, description: e.target.value }))}
/>
</div>
<Button type="submit" disabled={isEditing} className="w-full">
{isEditing && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
保存修改
</Button>
</form>
</DialogContent>
</Dialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-rose-500 hover:text-rose-600">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定要删除项目吗?</AlertDialogTitle>
<AlertDialogDescription>
此操作无法撤销。这将永久删除项目及其所有关联的目标、KR 和打卡记录。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteProject}
className="bg-rose-600 hover:bg-rose-700"
disabled={isDeleting}
>
{isDeleting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
<Button asChild variant="outline">
<Link href={`/projects/${projectId}/objectives/new`}>
<Plus className="h-4 w-4 mr-2" />
新建目标
</Link>
</Button>
</>
)}
<Button asChild className="bg-teal-600 hover:bg-teal-700">
<Link href={`/projects/${projectId}/krs/new`}>
<Plus className="h-4 w-4 mr-2" />
新建 KR
</Link>
</Button>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Dialog open={isObjectiveDialogOpen} onOpenChange={setIsObjectiveDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>编辑目标</DialogTitle>
</DialogHeader>
{editingObjective && (
<form onSubmit={handleEditObjective} className="space-y-4">
<div>
<Label htmlFor="obj-title">目标标题</Label>
<Input id="obj-title" name="title" defaultValue={editingObjective.title} required />
</div>
<div>
<Label htmlFor="obj-desc">描述</Label>
<Textarea id="obj-desc" name="description" defaultValue={editingObjective.description || ''} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="obj-start">开始日期</Label>
<Input type="date" id="obj-start" name="period_start" defaultValue={editingObjective.period_start} required />
</div>
<div>
<Label htmlFor="obj-end">结束日期</Label>
<Input type="date" id="obj-end" name="period_end" defaultValue={editingObjective.period_end} required />
</div>
</div>
<Button type="submit" disabled={isEditing} className="w-full">
{isEditing && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
保存修改
</Button>
</form>
)}
</DialogContent>
</Dialog>
<div className="lg:col-span-2 space-y-6">
<div className="grid grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6 text-center">
<p className="text-3xl font-bold text-slate-800">{activeKRs.length}</p>
<p className="text-sm text-muted-foreground">进行中 KR</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-3xl font-bold text-emerald-600">{completedKRs.length}</p>
<p className="text-sm text-muted-foreground">已达标</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-3xl font-bold text-rose-600">
{activeKRs.filter(kr => kr.status === 'red').length}
</p>
<p className="text-sm text-muted-foreground">风险 KR</p>
</CardContent>
</Card>
</div>
{objectives.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Target className="h-5 w-5 text-teal-600" />
阶段目标 (Objectives)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{objectives.map(obj => {
const objKRs = krs.filter(kr => kr.objective_id === obj.id);
return (
<div key={obj.id} className="p-4 bg-slate-50 rounded-lg">
<div className="flex items-start justify-between mb-2">
<h3 className="font-medium text-slate-800">{obj.title}</h3>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDateRange(obj.period_start, obj.period_end)}
</span>
{currentUser?.role === 'owner' && (
<div className="flex items-center">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => {
setEditingObjective(obj);
setIsObjectiveDialogOpen(true);
}}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-rose-500 hover:text-rose-600"
onClick={() => requestDeleteObjective(obj.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
</div>
{obj.description && (
<p className="text-sm text-muted-foreground mb-3">{obj.description}</p>
)}
<div className="text-xs text-muted-foreground">
关联 {objKRs.length} 个 KR
</div>
</div>
);
})}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-lg">执行目标 (Key Results)</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{krs.length === 0 ? (
<p className="text-center text-muted-foreground py-8">暂无 KR</p>
) : (
krs.map(kr => {
const progress = calculateProgress(kr);
const daysLeft = getDaysRemaining(kr.period_end);
const isActive = isKRActive(kr);
return (
<Link key={kr.id} href={`/krs/${kr.id}`} className="block">
<div className={`flex items-center gap-4 p-4 rounded-lg border hover:bg-slate-50 transition-colors ${!isActive ? 'opacity-60' : ''}`}>
<ProgressRing progress={progress} status={kr.status} size={56} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="font-medium text-slate-800 truncate">{kr.title}</p>
{!isActive && (
<span className="text-xs bg-slate-200 text-slate-600 px-2 py-0.5 rounded">已结束</span>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>{kr.owner?.name || '未分配'}</span>
<span className="text-xs bg-slate-100 px-2 py-0.5 rounded">
{PERIOD_TYPES[kr.period_type]}
</span>
<span>{formatDateRange(kr.period_start, kr.period_end)}</span>
</div>
{kr.reason_code && kr.status !== 'green' && (
<div className="flex items-center gap-1 mt-1 text-xs text-amber-600">
<AlertTriangle className="h-3 w-3" />
{REASON_CODES[kr.reason_code]}
</div>
)}
</div>
<div className="text-right">
<p className="text-sm font-medium">
{kr.current_value}/{kr.target_value} {kr.unit}
</p>
{isActive && (
<p className={`text-xs ${daysLeft <= 3 ? 'text-amber-600' : 'text-muted-foreground'}`}>
<Clock className="h-3 w-3 inline mr-1" />
剩余 {daysLeft} 天
</p>
)}
</div>
</div>
</Link>
);
})
)}
</CardContent>
</Card>
<Card>
<TaskEditDialog
open={isTaskDialogOpen}
onOpenChange={setIsTaskDialogOpen}
task={editingTask}
projectId={projectId}
projectTitle={project?.name || ''}
onSuccess={fetchData}
/>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<CheckSquare className="h-5 w-5 text-indigo-600" />
执行计划 ({tasks.length})
</CardTitle>
{canAddKR && (
<Button
size="sm"
variant="outline"
className="text-indigo-600 border-indigo-200 hover:bg-indigo-50"
onClick={() => {
setEditingTask(null);
setIsTaskDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-1" />
新建计划
</Button>
)}
</CardHeader>
<CardContent>
{tasks.length === 0 ? (
<div className="text-center py-8 border border-dashed rounded-lg bg-slate-50">
<p className="text-muted-foreground text-sm">暂无计划,可添加项目级执行计划</p>
</div>
) : (
<div className="space-y-3">
{tasks.map(task => (
<div
key={task.id}
className="group p-3 bg-white border rounded-lg hover:shadow-sm transition-all cursor-pointer"
onClick={() => {
setEditingTask(task);
setIsTaskDialogOpen(true);
}}
>
<div className="flex items-start gap-3">
<div
className="mt-1"
onClick={(e) => {
e.stopPropagation();
handleTaskStatusChange(task.id, task.status === 'done' ? 'todo' : 'done');
}}
>
<div className={`w-5 h-5 rounded border flex items-center justify-center ${task.status === 'done' ? 'bg-indigo-600 border-indigo-600' : 'border-slate-300 hover:border-indigo-500'}`}>
{task.status === 'done' && <CheckSquare className="h-3.5 w-3.5 text-white" />}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<p className={`font-medium text-slate-800 truncate ${task.status === 'done' ? 'line-through text-slate-400' : ''}`}>
{task.title}
</p>
<StatusBadge
status={task.status === 'done' ? 'green' : (task.status === 'in_progress' ? 'yellow' : 'red')}
size="sm"
text={task.status === 'done' ? '已完成' : (task.status === 'in_progress' ? '进行中' : '待办')}
/>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{task.owner_user_id && (
<span className="flex items-center gap-1">
<UserIcon className="h-3 w-3" />
{users.find(u => u.id === task.owner_user_id)?.name || '未知'}
</span>
)}
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDateRange(task.start_date || '', task.end_date || '')}
</span>
{task.steps.length > 0 && (
<span className="bg-slate-100 px-1.5 rounded">
{task.steps.filter(s => s.is_completed).length}/{task.steps.length} 步骤
</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">项目信息</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-muted-foreground">项目类型</p>
<p className="font-medium">{project.project_type || '通用'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">负责人</p>
<p className="font-medium">{project.owner?.name || '未指定'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">项目经理</p>
<p className="font-medium">{project.pm?.name || '未指定'}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Users className="h-5 w-5" />
团队成员
</CardTitle>
</CardHeader>
<CardContent>
{project.members && project.members.length > 0 ? (
<div className="space-y-2">
{project.members.map(m => (
<div key={m.user_id} className="flex items-center gap-2 p-2 rounded bg-slate-50">
<div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center text-sm font-medium">
{m.users.name.charAt(0)}
</div>
<span className="text-sm">{m.users.name}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">暂无成员</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">快速操作</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button asChild variant="outline" className="w-full justify-start">
<Link href={`/meeting?project_id=${projectId}`}>
<Calendar className="h-4 w-4 mr-2" />
发起例会
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
<AlertDialog open={isDeleteObjectiveOpen} onOpenChange={setIsDeleteObjectiveOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定要删除此目标吗?</AlertDialogTitle>
<AlertDialogDescription>
关联的 KR 将会被解除关联。此操作无法撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingObjective}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteObjective}
className="bg-rose-600 hover:bg-rose-700"
disabled={isDeletingObjective}
>
{isDeletingObjective && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}