Files
ok_r/components/task-edit-dialog.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

743 lines
28 KiB
TypeScript
Raw Permalink 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 { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Slider } from '@/components/ui/slider';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Checkbox } from '@/components/ui/checkbox';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Loader2, Calendar, Trash2, Plus, Sparkles, X } from 'lucide-react';
import { useUser } from '@/components/user-context';
import { toast } from 'sonner';
import { formatDateRange } from '@/lib/kr-utils';
import type { Task, TaskStep } from '@/types/database';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { fetchApi } from '@/lib/api-client';
const taskSchema = z.object({
title: z.string().min(1, '计划标题不能为空'),
description: z.string().optional(),
status: z.enum(['todo', 'in_progress', 'expected', 'done']),
priority: z.number().min(1).max(5),
urgency: z.number().min(1).max(5),
owner_user_id: z.string().optional(),
executor_ids: z.array(z.string()),
start_date: z.string().optional(),
end_date: z.string().optional(),
help_requested_to_user_id: z.string().optional(),
});
type TaskFormValues = z.infer<typeof taskSchema>;
interface TaskEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
krId?: string;
projectId: string; // Used for context
projectTitle: string;
task?: Task & { steps?: TaskStep[] } | null; // If null, creating new plan
onSuccess: () => void;
}
export function TaskEditDialog({
open,
onOpenChange,
krId,
projectId,
projectTitle,
task,
onSuccess,
}: TaskEditDialogProps) {
const HELP_NONE_VALUE = '__none__';
const { users, currentUser } = useUser();
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState('details');
const [steps, setSteps] = useState<TaskStep[]>([]);
const [newStepContent, setNewStepContent] = useState('');
const [newStepStartDate, setNewStepStartDate] = useState(new Date().toISOString().split('T')[0]);
const [newStepEndDate, setNewStepEndDate] = useState(new Date().toISOString().split('T')[0]);
const [newStepHelpToUserId, setNewStepHelpToUserId] = useState(HELP_NONE_VALUE);
const [isDeleteTaskOpen, setIsDeleteTaskOpen] = useState(false);
const [isDeletingTask, setIsDeletingTask] = useState(false);
const [deleteStepId, setDeleteStepId] = useState<string | null>(null);
const [isDeleteStepOpen, setIsDeleteStepOpen] = useState(false);
const [isDeletingStep, setIsDeletingStep] = useState(false);
// Initialize form
const form = useForm<TaskFormValues>({
resolver: zodResolver(taskSchema),
defaultValues: {
title: '',
description: '',
status: 'todo',
priority: 3,
urgency: 3,
owner_user_id: '',
executor_ids: [],
start_date: new Date().toISOString().split('T')[0],
end_date: new Date().toISOString().split('T')[0],
help_requested_to_user_id: HELP_NONE_VALUE,
},
});
// Reset form when task changes
useEffect(() => {
if (open) {
if (task) {
form.reset({
title: task.title,
description: task.description || '',
status: task.status as any,
priority: task.priority || 3,
urgency: task.urgency || 3,
owner_user_id: task.owner_user_id || '',
executor_ids: task.executor_ids || [],
start_date: task.start_date || '',
end_date: task.end_date || '',
help_requested_to_user_id: task.help_requested_to_user_id || HELP_NONE_VALUE,
});
setSteps(task.steps || []);
} else {
form.reset({
title: '',
description: '',
status: 'todo',
priority: 3,
urgency: 3,
owner_user_id: '',
executor_ids: [],
start_date: new Date().toISOString().split('T')[0],
end_date: new Date().toISOString().split('T')[0],
help_requested_to_user_id: HELP_NONE_VALUE,
});
setSteps([]);
}
setNewStepHelpToUserId(HELP_NONE_VALUE);
setIsDeleteTaskOpen(false);
setIsDeletingTask(false);
setDeleteStepId(null);
setIsDeleteStepOpen(false);
setIsDeletingStep(false);
}
}, [open, task, form]);
const onSubmit = async (values: TaskFormValues) => {
setIsSubmitting(true);
try {
// 1. Create or update task
const url = task ? `/api/tasks/${task.id}` : '/api/tasks';
const method = task ? 'PATCH' : 'POST';
const taskRes = await fetchApi(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kr_id: krId || null,
project_id: projectId && projectId !== "" ? projectId : null,
title: values.title,
description: values.description || null,
status: values.status,
priority: values.priority,
urgency: values.urgency,
owner_user_id: values.owner_user_id && values.owner_user_id !== "" ? values.owner_user_id : null,
executor_ids: values.executor_ids || [],
start_date: values.start_date && values.start_date !== "" ? values.start_date : null,
end_date: values.end_date && values.end_date !== "" ? values.end_date : null,
help_requested_to_user_id:
values.help_requested_to_user_id && values.help_requested_to_user_id !== HELP_NONE_VALUE
? values.help_requested_to_user_id
: null,
help_requested_by_user_id:
values.help_requested_to_user_id && values.help_requested_to_user_id !== HELP_NONE_VALUE
? (currentUser?.id || null)
: null,
}),
});
if (!taskRes.ok) {
let errorMessage = '保存计划失败';
try {
const errorData = await taskRes.json();
if (errorData?.error) errorMessage = errorData.error;
} catch {
const text = await taskRes.text().catch(() => '');
if (text) errorMessage = text;
}
throw new Error(errorMessage);
}
const savedTask = await taskRes.json();
// 2. Handle steps (only for new steps added in UI, existing ones are handled individually)
// Note: In a real app, we might want to batch this. For now, we'll just save the task.
// Steps created during edit are saved immediately in this implementation below.
// Save any local (temp) steps
const localSteps = steps.filter(s => s.id.startsWith('temp-'));
if (localSteps.length > 0) {
await Promise.all(localSteps.map(step =>
fetchApi('/api/task-steps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
task_id: savedTask.id,
content: step.content,
start_date: step.start_date,
end_date: step.end_date,
is_completed: step.is_completed,
}),
})
));
}
toast.success('计划保存成功');
onSuccess();
onOpenChange(false);
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : '保存失败');
} finally {
setIsSubmitting(false);
}
};
const handleDeleteTask = async () => {
if (!task) return;
try {
setIsDeletingTask(true);
await fetchApi(`/api/tasks/${task.id}`, { method: 'DELETE' });
toast.success('计划已删除');
onSuccess();
onOpenChange(false);
} catch (error) {
toast.error('删除失败');
} finally {
setIsDeletingTask(false);
setIsDeleteTaskOpen(false);
}
};
const handleAddStep = async () => {
if (!newStepContent.trim()) return;
// If task exists, save step to DB immediately
if (task) {
try {
const res = await fetchApi('/api/task-steps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
task_id: task.id,
content: newStepContent,
start_date: newStepStartDate,
end_date: newStepEndDate,
help_requested_to_user_id: newStepHelpToUserId !== HELP_NONE_VALUE ? newStepHelpToUserId : null,
help_requested_by_user_id:
newStepHelpToUserId !== HELP_NONE_VALUE ? (currentUser?.id || null) : null,
}),
});
if (res.ok) {
const newStep = await res.json();
setSteps([...steps, newStep]);
setNewStepContent('');
setNewStepHelpToUserId(HELP_NONE_VALUE);
}
} catch (error) {
toast.error('添加步骤失败');
}
} else {
// If task is new (not saved yet), add to local state with temp ID
const tempStep: TaskStep = {
id: `temp-${Date.now()}`,
task_id: '', // Placeholder
content: newStepContent,
note: null,
executor_id: null,
start_date: newStepStartDate,
end_date: newStepEndDate,
is_completed: false,
help_requested_to_user_id: newStepHelpToUserId !== HELP_NONE_VALUE ? newStepHelpToUserId : null,
help_requested_by_user_id:
newStepHelpToUserId !== HELP_NONE_VALUE ? (currentUser?.id || null) : null,
help_requested_at: newStepHelpToUserId !== HELP_NONE_VALUE ? new Date().toISOString() : null,
created_at: new Date().toISOString(),
};
setSteps([...steps, tempStep]);
setNewStepContent('');
setNewStepHelpToUserId(HELP_NONE_VALUE);
}
};
const toggleStepComplete = async (step: TaskStep) => {
// If it's a local step, just update state
if (step.id.startsWith('temp-')) {
setSteps(steps.map(s => s.id === step.id ? { ...s, is_completed: !s.is_completed } : s));
return;
}
try {
const res = await fetchApi('/api/task-steps', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: step.id,
is_completed: !step.is_completed,
}),
});
if (res.ok) {
setSteps(steps.map(s => s.id === step.id ? { ...s, is_completed: !s.is_completed } : s));
}
} catch (error) {
toast.error('更新状态失败');
}
};
const deleteStepNow = async (stepId: string) => {
// If it's a local step, just remove from state
if (stepId.startsWith('temp-')) {
setSteps(steps.filter(s => s.id !== stepId));
return;
}
try {
const res = await fetchApi(`/api/task-steps?id=${stepId}`, { method: 'DELETE' });
if (res.ok) {
setSteps(steps.filter(s => s.id !== stepId));
}
} catch (error) {
toast.error('删除步骤失败');
}
};
const requestDeleteStep = (stepId: string) => {
setDeleteStepId(stepId);
setIsDeleteStepOpen(true);
};
const confirmDeleteStep = async () => {
if (!deleteStepId) return;
try {
setIsDeletingStep(true);
await deleteStepNow(deleteStepId);
} finally {
setIsDeletingStep(false);
setIsDeleteStepOpen(false);
setDeleteStepId(null);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b">
<div>
<DialogTitle className="text-xl font-bold">
{task ? '编辑计划' : '新建计划'}
</DialogTitle>
<p className="text-sm text-muted-foreground flex items-center gap-1 mt-1">
<span className="bg-slate-100 px-1.5 rounded text-xs">项目</span>
{projectTitle}
</p>
</div>
{/* Close button is built-in to DialogContent but we can add custom header actions here */}
</div>
<div className="flex-1 flex overflow-hidden">
{/* Left Column: Task Form */}
<div className="w-1/2 p-6 border-r overflow-y-auto">
<form id="task-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4">
<div>
<Label>所属项目/子项目</Label>
<div className="mt-1.5 p-2 bg-slate-50 border rounded-md text-sm text-slate-700">
└ {projectTitle}
</div>
</div>
<div>
<Label htmlFor="title">计划标题</Label>
<Input
id="title"
{...form.register('title')}
className="mt-1.5"
placeholder="输入计划标题"
/>
{form.formState.errors.title && (
<p className="text-red-500 text-xs mt-1">{form.formState.errors.title.message}</p>
)}
</div>
<div>
<Label htmlFor="description">思路</Label>
<Textarea
id="description"
{...form.register('description')}
className="mt-1.5 min-h-[100px]"
placeholder="描述计划思路或备注..."
/>
</div>
<div className="space-y-4">
<div>
<div className="flex justify-between mb-2">
<Label>重要级</Label>
<span className="text-xs text-muted-foreground">{form.watch('priority')}/5</span>
</div>
<Slider
defaultValue={[form.watch('priority')]}
max={5}
step={1}
onValueChange={(val) => form.setValue('priority', val[0])}
className="[&>.relative>.absolute]:bg-rose-500"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-1 px-1">
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
<span>5</span>
</div>
</div>
<div>
<div className="flex justify-between mb-2">
<Label>紧急程度</Label>
<span className="text-xs text-muted-foreground">{form.watch('urgency')}/5</span>
</div>
<Slider
defaultValue={[form.watch('urgency')]}
max={5}
step={1}
onValueChange={(val) => form.setValue('urgency', val[0])}
className="[&>.relative>.absolute]:bg-amber-400"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-1 px-1">
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
<span>5</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>状态</Label>
<Select
onValueChange={(val) => form.setValue('status', val as any)}
defaultValue={form.watch('status')}
>
<SelectTrigger className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="todo">待办</SelectItem>
<SelectItem value="in_progress">进行中</SelectItem>
<SelectItem value="expected">预期</SelectItem>
<SelectItem value="done">已完成</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>负责人</Label>
<Select
onValueChange={(val) => form.setValue('owner_user_id', val)}
defaultValue={form.watch('owner_user_id')}
>
<SelectTrigger className="mt-1.5">
<SelectValue placeholder="选择负责人" />
</SelectTrigger>
<SelectContent>
{users.map(u => (
<SelectItem key={u.id} value={u.id}>{u.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>请求协助</Label>
<Select
onValueChange={(val) => form.setValue('help_requested_to_user_id', val)}
value={form.watch('help_requested_to_user_id') || HELP_NONE_VALUE}
>
<SelectTrigger className="mt-1.5">
<SelectValue placeholder="选择需要协助的成员" />
</SelectTrigger>
<SelectContent>
<SelectItem value={HELP_NONE_VALUE}>不请求协助</SelectItem>
{users.map(u => (
<SelectItem key={u.id} value={u.id}>{u.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>开始日期</Label>
<Input
type="date"
{...form.register('start_date')}
className="mt-1.5"
/>
</div>
<div>
<Label>截止日期</Label>
<Input
type="date"
{...form.register('end_date')}
className="mt-1.5"
/>
</div>
</div>
<div>
<Label className="mb-2 block">执行人 (多选)</Label>
<ScrollArea className="h-[100px] w-full border rounded-md p-3 bg-slate-50">
<div className="flex flex-wrap gap-2">
{users.map(user => (
<div key={user.id} className="flex items-center space-x-2 bg-white px-2 py-1 rounded border">
<Checkbox
id={`executor-${user.id}`}
checked={form.watch('executor_ids').includes(user.id)}
onCheckedChange={(checked) => {
const current = form.getValues('executor_ids');
if (checked) {
form.setValue('executor_ids', [...current, user.id]);
} else {
form.setValue('executor_ids', current.filter(id => id !== user.id));
}
}}
/>
<label
htmlFor={`executor-${user.id}`}
className="text-sm font-medium cursor-pointer"
>
{user.name}
</label>
</div>
))}
</div>
</ScrollArea>
</div>
</div>
</form>
</div>
{/* Right Column: Steps */}
<div className="w-1/2 p-6 bg-slate-50 flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold flex items-center gap-2">
<div className="bg-slate-200 p-1 rounded">
<Checkbox checked disabled className="h-4 w-4" />
</div>
执行步骤
</h3>
<Button variant="outline" size="sm" className="text-purple-600 border-purple-200 hover:bg-purple-50">
<Sparkles className="h-3 w-3 mr-1" />
AI 生成
</Button>
</div>
<ScrollArea className="flex-1 pr-4 -mr-4 mb-4">
<div className="space-y-3">
{steps.map((step, index) => (
<div key={step.id || index} className="bg-white p-3 rounded-lg border shadow-sm group">
<div className="flex items-start gap-3">
<Checkbox
checked={step.is_completed}
onCheckedChange={() => toggleStepComplete(step)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<p className={`font-medium text-sm ${step.is_completed ? 'line-through text-muted-foreground' : ''}`}>
{step.content}
</p>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
{step.start_date || '无日期'}
{step.help_requested_to_user_id && (
<>
<span>•</span>
<span className="text-amber-700 bg-amber-50 border border-amber-200 px-1.5 rounded">
请求协助:{users.find(u => u.id === step.help_requested_to_user_id)?.name || '未知'}
</span>
</>
)}
{step.executor_id && (
<>
<span>•</span>
<span>{users.find(u => u.id === step.executor_id)?.name}</span>
</>
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 text-rose-500 hover:text-rose-600 hover:bg-rose-50"
onClick={() => requestDeleteStep(step.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
{steps.length === 0 && (
<div className="text-center py-8 text-muted-foreground text-sm">
暂无执行步骤,请在下方添加
</div>
)}
</div>
</ScrollArea>
<div className="bg-white p-4 rounded-lg border space-y-3">
<Input
placeholder="步骤内容"
value={newStepContent}
onChange={e => setNewStepContent(e.target.value)}
className="border-blue-200 focus-visible:ring-blue-500"
/>
<Textarea
placeholder="步骤备注(可选)"
className="h-20 resize-none"
/>
<div className="grid grid-cols-2 gap-3">
<Input
type="date"
className="text-xs"
value={newStepStartDate}
onChange={e => setNewStepStartDate(e.target.value)}
/>
<Input
type="date"
className="text-xs"
value={newStepEndDate}
onChange={e => setNewStepEndDate(e.target.value)}
/>
</div>
<div>
<Label className="text-xs">请求协助(可选)</Label>
<Select value={newStepHelpToUserId} onValueChange={setNewStepHelpToUserId}>
<SelectTrigger className="mt-1.5">
<SelectValue placeholder="选择需要协助的成员" />
</SelectTrigger>
<SelectContent>
<SelectItem value={HELP_NONE_VALUE}>不请求协助</SelectItem>
{users.map(u => (
<SelectItem key={u.id} value={u.id}>{u.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
onClick={handleAddStep}
className="w-full bg-indigo-50 text-indigo-600 hover:bg-indigo-100 border border-indigo-200"
variant="outline"
>
<Plus className="h-4 w-4 mr-2" />
添加步骤
</Button>
</div>
</div>
</div>
<div className="p-4 border-t flex items-center justify-between bg-white">
{task ? (
<AlertDialog open={isDeleteTaskOpen} onOpenChange={setIsDeleteTaskOpen}>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
className="text-rose-500 hover:text-rose-600 hover:bg-rose-50"
>
<Trash2 className="h-4 w-4 mr-2" />
删除计划
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除计划?</AlertDialogTitle>
<AlertDialogDescription>
删除后无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={handleDeleteTask}
disabled={isDeletingTask}
>
{isDeletingTask ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<div />
)}
<div className="flex gap-3">
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
type="submit"
form="task-form"
disabled={isSubmitting}
className="bg-indigo-600 hover:bg-indigo-700 text-white min-w-[100px]"
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
保存更改
</Button>
</div>
</div>
<AlertDialog open={isDeleteStepOpen} onOpenChange={setIsDeleteStepOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除步骤?</AlertDialogTitle>
<AlertDialogDescription>
删除后无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingStep}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={confirmDeleteStep}
disabled={isDeletingStep}
>
{isDeletingStep ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DialogContent>
</Dialog>
);
}