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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user