Files
ok_r/app/my-tasks/page.tsx
karlcarlo 7948b1afc7 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以支持独立输出和子路径部署
2026-01-28 12:57:36 +08:00

486 lines
19 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 } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { StatusBadge } from '@/components/status-badge';
import { useUser } from '@/components/user-context';
import { formatDateRange } from '@/lib/kr-utils';
import type { Task, TaskStep, User } from '@/types/database';
import { CheckSquare, Clock, Calendar, AlertTriangle, User as UserIcon, Plus } from 'lucide-react';
import { TaskEditDialog } from '@/components/task-edit-dialog';
import { toast } from 'sonner';
interface TaskWithRelations extends Task {
steps: TaskStep[];
owner: User | null;
// We might want project info too, but the API doesn't return it joined yet.
// We can fetch projects separately or just show task details.
}
interface HelpStepWithRelations extends TaskStep {
task: Pick<Task, 'id' | 'title' | 'project_id' | 'kr_id' | 'owner_user_id' | 'help_requested_to_user_id' | 'help_requested_by_user_id' | 'help_requested_at'> | null;
}
export default function MyTasksPage() {
const { currentUser, users } = useUser();
const [tasks, setTasks] = useState<TaskWithRelations[]>([]);
const [helpSteps, setHelpSteps] = useState<HelpStepWithRelations[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Dialog state
const [isTaskDialogOpen, setIsTaskDialogOpen] = useState(false);
const [editingTask, setEditingTask] = useState<(Task & { steps: TaskStep[] }) | null>(null);
const fetchTasks = async () => {
try {
const isOwnerView = currentUser?.role === 'owner';
const url = isOwnerView
? '/api/tasks'
: `/api/tasks?user_id=${currentUser?.id}&include_help=true`;
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
// Enrich with owner info locally since API doesn't join
const enrichedData = data.map((t: any) => ({
...t,
owner: users.find(u => u.id === t.owner_user_id) || null
}));
setTasks(enrichedData);
}
} catch (error) {
console.error('Failed to fetch tasks:', error);
} finally {
setIsLoading(false);
}
};
const fetchHelpSteps = async () => {
if (!currentUser?.id || currentUser.role === 'owner') return;
try {
const res = await fetch(`/api/task-steps?help_requested_to_user_id=${currentUser.id}`);
if (res.ok) {
const data = await res.json();
setHelpSteps(data);
}
} catch (error) {
console.error('Failed to fetch help steps:', error);
}
};
useEffect(() => {
if (users.length > 0 && currentUser) {
fetchTasks();
fetchHelpSteps();
}
}, [users, currentUser]);
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) {
setTasks(tasks.map(t => t.id === taskId ? { ...t, status: newStatus as any } : t));
toast.success('计划状态已更新');
}
} catch (error) {
toast.error('更新失败');
}
};
// Group Tasks by owner for owner view
const tasksByOwner = users.map(user => ({
user,
tasks: tasks.filter(t => t.owner_user_id === user.id && t.status !== 'done')
})).filter(group => group.tasks.length > 0 || group.user.role !== 'owner')
.sort((a, b) => {
if (a.tasks.length > 0 && b.tasks.length === 0) return -1;
if (a.tasks.length === 0 && b.tasks.length > 0) return 1;
return 0;
});
const myTasks = tasks.filter(t => t.owner_user_id === currentUser?.id);
const incomingHelpTasks = tasks.filter(t => t.help_requested_to_user_id === currentUser?.id && t.owner_user_id !== currentUser?.id);
const activeTasks = myTasks.filter(t => t.status !== 'done');
const completedTasks = myTasks.filter(t => t.status === 'done');
// "Pending" tasks can be defined as todo/in_progress/expected
const todoTasks = activeTasks.filter(t => t.status === 'todo');
const inProgressTasks = activeTasks.filter(t => t.status === 'in_progress');
const isOwner = currentUser?.role === 'owner';
if (isLoading) {
return (
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-8 bg-slate-200 rounded w-32" />
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-24 bg-slate-200 rounded-lg" />
))}
</div>
</div>
</div>
);
}
return (
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="mb-8 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-slate-800">
{isOwner ? '全员计划概览' : '我的计划'}
</h1>
<p className="text-muted-foreground mt-1">
{isOwner
? '查看所有成员的计划执行情况'
: `${currentUser?.name} 的计划清单`
}
</p>
</div>
{!isOwner && (
<Button onClick={() => {
setEditingTask(null);
setIsTaskDialogOpen(true);
}} className="bg-indigo-600 hover:bg-indigo-700">
<Plus className="h-4 w-4 mr-2" />
新建计划
</Button>
)}
</div>
{isOwner ? (
<div className="space-y-8">
{tasksByOwner.map(({ user, tasks }) => (
<div key={user.id} className="space-y-4">
<div className="flex items-center gap-3 border-b pb-2">
<div className="h-8 w-8 rounded-full bg-slate-200 flex items-center justify-center text-sm font-medium text-slate-600">
{user.name[0]}
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800">{user.name}</h2>
<p className="text-xs text-muted-foreground">
{tasks.length} 个进行中的计划
</p>
</div>
</div>
{tasks.length === 0 ? (
<div className="p-8 text-center bg-slate-50 rounded-lg border border-dashed">
<p className="text-muted-foreground text-sm">暂无进行中的计划</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3">
{tasks.map(task => (
<TaskCard
key={task.id}
task={task}
onStatusChange={handleTaskStatusChange}
onClick={() => {
setEditingTask(task);
setIsTaskDialogOpen(true);
}}
/>
))}
</div>
)}
</div>
))}
{tasksByOwner.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground">没有任何成员有正在进行的计划</p>
</div>
)}
</div>
) : (
<>
<div className="grid grid-cols-3 gap-4 mb-8">
<Card>
<CardContent className="pt-6 text-center">
<CheckSquare className="h-8 w-8 text-slate-300 mx-auto mb-2" />
<p className="text-3xl font-bold text-slate-800">{activeTasks.length}</p>
<p className="text-sm text-muted-foreground">待完成</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<Clock className="h-8 w-8 text-indigo-300 mx-auto mb-2" />
<p className="text-3xl font-bold text-indigo-600">{inProgressTasks.length}</p>
<p className="text-sm text-muted-foreground">进行中</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<AlertTriangle className="h-8 w-8 text-rose-300 mx-auto mb-2" />
<p className="text-3xl font-bold text-rose-600">
{activeTasks.filter(t => t.priority >= 4).length}
</p>
<p className="text-sm text-muted-foreground">高优先级</p>
</CardContent>
</Card>
</div>
<Tabs defaultValue="active" className="space-y-6">
<TabsList>
<TabsTrigger value="active">进行中 ({activeTasks.length})</TabsTrigger>
<TabsTrigger value="help_plans">协助我的计划 ({incomingHelpTasks.length})</TabsTrigger>
<TabsTrigger value="help_steps">协助我的步骤 ({helpSteps.length})</TabsTrigger>
<TabsTrigger value="completed">已完成 ({completedTasks.length})</TabsTrigger>
<TabsTrigger value="all">全部 ({myTasks.length})</TabsTrigger>
</TabsList>
<TabsContent value="active" className="space-y-4">
{activeTasks.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<CheckSquare className="h-12 w-12 text-slate-300 mx-auto mb-4" />
<p className="text-muted-foreground">暂无进行中的计划</p>
</CardContent>
</Card>
) : (
activeTasks.map(task => (
<TaskCard
key={task.id}
task={task}
onStatusChange={handleTaskStatusChange}
onClick={() => {
setEditingTask(task);
setIsTaskDialogOpen(true);
}}
/>
))
)}
</TabsContent>
<TabsContent value="help_plans" className="space-y-4">
{incomingHelpTasks.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<CheckSquare className="h-12 w-12 text-slate-300 mx-auto mb-4" />
<p className="text-muted-foreground">暂无需要你协助的计划</p>
</CardContent>
</Card>
) : (
incomingHelpTasks.map(task => (
<TaskCard
key={task.id}
task={task}
onStatusChange={handleTaskStatusChange}
onClick={() => {
setEditingTask(task);
setIsTaskDialogOpen(true);
}}
variant="help"
/>
))
)}
</TabsContent>
<TabsContent value="help_steps" className="space-y-4">
{helpSteps.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<CheckSquare className="h-12 w-12 text-slate-300 mx-auto mb-4" />
<p className="text-muted-foreground">暂无需要你协助的步骤</p>
</CardContent>
</Card>
) : (
helpSteps.map(step => (
<Card
key={step.id}
className="border-amber-200 bg-amber-50/40 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => {
if (step.task) {
const task = tasks.find(t => t.id === step.task?.id);
if (task) {
setEditingTask(task);
setIsTaskDialogOpen(true);
}
}
}}
>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded border border-amber-200">
协助步骤
</span>
{step.help_requested_by_user_id && (
<span className="text-xs text-muted-foreground">
发起人:{users.find(u => u.id === step.help_requested_by_user_id)?.name || '未知'}
</span>
)}
</div>
<p className="font-medium text-slate-800 truncate">{step.content}</p>
{step.task?.title && (
<p className="text-sm text-muted-foreground mt-1 truncate">
来自计划:{step.task.title}
</p>
)}
<div className="text-xs text-muted-foreground mt-2 flex items-center gap-2">
<Calendar className="h-3 w-3" />
{formatDateRange(step.start_date || '', step.end_date || '')}
</div>
</div>
<div className="shrink-0">
<StatusBadge status={step.is_completed ? 'green' : 'yellow'} size="sm" text={step.is_completed ? '已完成' : '待协助'} />
</div>
</div>
</CardContent>
</Card>
))
)}
</TabsContent>
<TabsContent value="completed" className="space-y-4">
{completedTasks.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<CheckSquare className="h-12 w-12 text-slate-300 mx-auto mb-4" />
<p className="text-muted-foreground">暂无已完成的计划</p>
</CardContent>
</Card>
) : (
completedTasks.map(task => (
<TaskCard
key={task.id}
task={task}
onStatusChange={handleTaskStatusChange}
onClick={() => {
setEditingTask(task);
setIsTaskDialogOpen(true);
}}
/>
))
)}
</TabsContent>
<TabsContent value="all" className="space-y-4">
{myTasks.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<CheckSquare className="h-12 w-12 text-slate-300 mx-auto mb-4" />
<p className="text-muted-foreground">暂无计划</p>
</CardContent>
</Card>
) : (
myTasks.map(task => (
<TaskCard
key={task.id}
task={task}
onStatusChange={handleTaskStatusChange}
onClick={() => {
setEditingTask(task);
setIsTaskDialogOpen(true);
}}
/>
))
)}
</TabsContent>
</Tabs>
</>
)}
<TaskEditDialog
open={isTaskDialogOpen}
onOpenChange={setIsTaskDialogOpen}
projectId="" // Global task or we assume user selects project in dialog if we enable it?
// Wait, TaskEditDialog requires projectId for context.
// If we create task from here, it might not belong to a project initially or we need to select one.
// But the dialog currently assumes projectId is passed for display context "Project: xxx".
// Let's pass a placeholder or modify dialog to allow selecting project if not provided.
// For now, let's pass empty string and "个人任务" or similar.
projectTitle="个人/全局计划"
task={editingTask}
onSuccess={fetchTasks}
/>
</div>
);
}
function TaskCard({ task, onStatusChange, onClick, variant }: { task: TaskWithRelations; onStatusChange: (id: string, status: string) => void; onClick: () => void; variant?: 'normal' | 'help' }) {
const { currentUser, users } = useUser();
const isDone = task.status === 'done';
const isIncomingHelp = variant === 'help' || (task.help_requested_to_user_id && task.help_requested_to_user_id === currentUser?.id && task.owner_user_id !== currentUser?.id);
const isOutgoingHelp = task.help_requested_to_user_id && task.owner_user_id === currentUser?.id;
const requesterName = task.help_requested_by_user_id ? (users.find(u => u.id === task.help_requested_by_user_id)?.name || '未知') : null;
const helperName = task.help_requested_to_user_id ? (users.find(u => u.id === task.help_requested_to_user_id)?.name || '未知') : null;
return (
<Card
className={`hover:shadow-md transition-shadow cursor-pointer ${isDone ? 'opacity-70 bg-slate-50' : ''} ${isIncomingHelp ? 'border-amber-200 bg-amber-50/40' : ''} ${isOutgoingHelp ? 'border-indigo-200 bg-indigo-50/40' : ''}`}
onClick={onClick}
>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div
className="mt-1"
onClick={(e) => {
e.stopPropagation();
onStatusChange(task.id, isDone ? 'todo' : 'done');
}}
>
<div className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${isDone ? 'bg-indigo-600 border-indigo-600' : 'border-slate-300 hover:border-indigo-500'}`}>
{isDone && <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">
<h3 className={`font-medium text-slate-800 truncate ${isDone ? 'line-through text-slate-500' : ''}`}>
{task.title}
</h3>
<StatusBadge
status={task.status === 'done' ? 'green' : (task.status === 'in_progress' ? 'yellow' : 'red')}
size="sm"
text={task.status === 'done' ? '已完成' : (task.status === 'in_progress' ? '进行中' : '待办')}
/>
</div>
{(isIncomingHelp || isOutgoingHelp) && (
<div className="flex items-center gap-2 mb-2">
<span className={`text-xs px-2 py-0.5 rounded border ${isIncomingHelp ? 'bg-amber-100 text-amber-800 border-amber-200' : 'bg-indigo-100 text-indigo-800 border-indigo-200'}`}>
{isIncomingHelp ? '协助请求' : '已请求协助'}
</span>
{isIncomingHelp && requesterName && (
<span className="text-xs text-muted-foreground">发起人:{requesterName}</span>
)}
{isOutgoingHelp && helperName && (
<span className="text-xs text-muted-foreground">协助人:{helperName}</span>
)}
</div>
)}
<div className="flex items-center gap-3 text-sm text-muted-foreground">
{task.owner && (
<span className="flex items-center gap-1">
<UserIcon className="h-3 w-3" />
{task.owner.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 text-xs">
{task.steps.filter(s => s.is_completed).length}/{task.steps.length} 步骤
</span>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}