Files
ok_r/app/my-tasks/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

487 lines
20 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';
import { fetchApi } from '@/lib/api-client';
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 fetchApi(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 fetchApi(`/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 fetchApi(`/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>
);
}