feat: 实现用户登录认证与权限控制系统

- 新增用户登录页面与API端点(/api/auth/login、/api/auth/logout、/api/auth/me)
- 引入基于密码哈希的认证机制,支持默认管理员账号登录
- 创建访问控制模块(lib/access-control.ts),实现基于角色的权限验证
- 在现有API端点中集成权限检查,限制非管理员用户只能访问其所属项目数据
- 更新应用布局,要求用户登录后才能访问主界面
- 扩展用户表结构,增加用户名和密码哈希字段
- 调整项目、KR、目标、会议记录等资源的查询逻辑,根据用户权限过滤数据
- 改进管理页面(指标、模板)的删除操作,使用确认对话框替代直接确认
- 优化“我的KR”页面,为管理员提供全员KR概览视图
- 修复.gitignore文件中的拼写错误(.trae → .travis)
- 添加数据库迁移脚本,支持任务直接关联项目、禁用RLS、添加求助字段等
- 配置Next.js以支持独立输出和子路径部署
This commit is contained in:
2026-01-28 12:57:36 +08:00
parent e3e5e89df6
commit 7948b1afc7
50 changed files with 9506 additions and 150 deletions

View File

@@ -10,12 +10,15 @@ 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 } from '@/types/database';
import type { Project, User, Objective, KR, Task, TaskStep } from '@/types/database';
import { toast } from 'sonner';
import {
ArrowLeft,
@@ -27,8 +30,11 @@ import {
Clock,
Pencil,
Trash2,
Loader2
Loader2,
CheckSquare,
User as UserIcon,
} from 'lucide-react';
import { TaskEditDialog } from '@/components/task-edit-dialog';
interface ProjectWithRelations extends Project {
owner: User | null;
@@ -42,29 +48,79 @@ interface KRWithRelations extends KR {
export default function ProjectDetailPage({ projectId }: { projectId: string }) {
// Force re-compile
const { currentUser } = useUser();
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] = await Promise.all([
const [projectRes, objectivesRes, krsRes, tasksRes] = await Promise.all([
fetch(`/api/projects/${projectId}`),
fetch(`/api/objectives?project_id=${projectId}`),
fetch(`/api/krs?project_id=${projectId}`),
fetch(`/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) {
@@ -76,8 +132,14 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
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);
}
@@ -88,6 +150,8 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
}, [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);
@@ -103,24 +167,21 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
}
};
const handleEditProject = async (e: React.FormEvent<HTMLFormElement>) => {
const handleEditProject = async (e: React.FormEvent) => {
e.preventDefault();
setIsEditing(true);
const formData = new FormData(e.currentTarget);
try {
const res = await fetch(`/api/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.get('name'),
description: formData.get('description'),
}),
body: JSON.stringify(editForm),
});
if (!res.ok) throw new Error('更新失败');
toast.success('项目已更新');
setIsProjectEditDialogOpen(false);
fetchData();
} catch (error) {
toast.error('更新失败');
@@ -129,16 +190,25 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
}
};
const handleDeleteObjective = async (objectiveId: string) => {
if (!confirm('确定要删除此目标吗?关联的 KR 也会被解除关联。')) return;
const requestDeleteObjective = (objectiveId: string) => {
setDeleteObjectiveId(objectiveId);
setIsDeleteObjectiveOpen(true);
};
const handleDeleteObjective = async () => {
if (!deleteObjectiveId) return;
setIsDeletingObjective(true);
try {
const res = await fetch(`/api/objectives/${objectiveId}`, { method: 'DELETE' });
const res = await fetch(`/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);
}
};
@@ -174,6 +244,23 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
}
};
const handleTaskStatusChange = async (taskId: string, newStatus: string) => {
try {
const res = await fetch(`/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);
@@ -196,6 +283,22 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
);
}
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">
@@ -229,28 +332,117 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
)}
</div>
{canEdit && (
{canAddKR && (
<div className="flex gap-2">
{currentUser?.role === 'owner' && (
{canEdit && (
<>
<Dialog>
{currentUser?.role === 'owner' && (
<>
<Dialog open={isProjectEditDialogOpen} onOpenChange={setIsProjectEditDialogOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>编辑项目</DialogTitle>
</DialogHeader>
<form onSubmit={handleEditProject} className="space-y-4">
<div>
<Label htmlFor="name">项目名称</Label>
<Input id="name" name="name" defaultValue={project.name} required />
<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" name="description" defaultValue={project.description || ''} />
<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" />}
@@ -294,6 +486,8 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
新建目标
</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" />
@@ -401,7 +595,7 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-rose-500 hover:text-rose-600"
onClick={() => handleDeleteObjective(obj.id)}
onClick={() => requestDeleteObjective(obj.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
@@ -478,6 +672,100 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
)}
</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">
@@ -541,6 +829,28 @@ export default function ProjectDetailPage({ projectId }: { projectId: string })
</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>
);
}

View File

@@ -35,6 +35,9 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
export default function NewKRPage() {
const router = useRouter();
const params = useParams();
@@ -45,6 +48,8 @@ export default function NewKRPage() {
const [metrics, setMetrics] = useState<Metric[]>([]);
const [templates, setTemplates] = useState<Template[]>([]);
const [isBooleanMetric, setIsBooleanMetric] = useState(false);
const today = new Date().toISOString().split('T')[0];
const form = useForm<FormValues>({
@@ -130,6 +135,19 @@ export default function NewKRPage() {
}
};
const handleBooleanToggle = (checked: boolean) => {
setIsBooleanMetric(checked);
if (checked) {
form.setValue('metric_name', '完成状态');
form.setValue('unit', '状态');
form.setValue('target_value', 1);
} else {
form.setValue('metric_name', '');
form.setValue('unit', '');
form.setValue('target_value', 0);
}
};
const onSubmit = async (values: FormValues) => {
if (new Date(values.period_end) < new Date(values.period_start)) {
toast.error('结束日期不能早于开始日期');
@@ -299,6 +317,12 @@ export default function NewKRPage() {
</div>
)}
<div className="flex items-center space-x-2 my-4">
<Switch id="boolean-mode" onCheckedChange={handleBooleanToggle} />
<Label htmlFor="boolean-mode">这是里程碑式 KR(仅需标记完成/未完成)</Label>
</div>
{!isBooleanMetric && (
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
@@ -328,6 +352,7 @@ export default function NewKRPage() {
)}
/>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<FormField
@@ -336,7 +361,7 @@ export default function NewKRPage() {
render={({ field }) => (
<FormItem>
<FormLabel>方向</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={isBooleanMetric}>
<FormControl>
<SelectTrigger>
<SelectValue />
@@ -367,6 +392,7 @@ export default function NewKRPage() {
step="any"
{...field}
onChange={e => field.onChange(parseFloat(e.target.value) || 0)}
disabled={isBooleanMetric}
/>
</FormControl>
<FormMessage />