Files
ok_r/app/krs/[id]/client-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

819 lines
32 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, useCallback } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Switch } from '@/components/ui/switch';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { StatusBadge, StatusDot } from '@/components/status-badge';
import { ProgressRing } from '@/components/progress-ring';
import { useUser } from '@/components/user-context';
import { Label } from '@/components/ui/label';
import {
calculateProgress,
calculateExpectedProgress,
getDaysRemaining,
formatDateRange,
isKRActive,
} from '@/lib/kr-utils';
import { REASON_CODES, PERIOD_TYPES, DIRECTION_LABELS } from '@/lib/constants';
import { toast } from 'sonner';
import { TaskEditDialog } from '@/components/task-edit-dialog';
import type { KR, User, Checkin, Objective, Task, TaskStep } from '@/types/database';
import {
ArrowLeft,
Plus,
Clock,
TrendingUp,
TrendingDown,
Target,
AlertTriangle,
Calendar,
User as UserIcon,
HandHelping,
Loader2,
Pencil,
Trash2,
CheckSquare,
} from 'lucide-react';
import { getErrorMessage } from '@/lib/error-utils';
interface KRWithRelations extends KR {
owner: User | null;
project: { id: string; name: string; status: string } | null;
objective: Objective | null;
}
interface CheckinWithRelations extends Checkin {
user: User | null;
}
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
const checkinSchema = z.object({
current_value: z.number(),
confidence: z.number().min(0).max(100),
reason_code: z.string().optional(),
note: z.string().optional(),
});
type CheckinFormValues = z.infer<typeof checkinSchema>;
export default function KRDetailPage({ krId }: { krId: string }) {
const router = useRouter();
const { currentUser } = useUser();
const [kr, setKR] = useState<KRWithRelations | null>(null);
const [checkins, setCheckins] = useState<CheckinWithRelations[]>([]);
const [tasks, setTasks] = useState<(Task & { steps: TaskStep[] })[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [isCheckinOpen, setIsCheckinOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isTaskDialogOpen, setIsTaskDialogOpen] = useState(false);
const [editingTask, setEditingTask] = useState<(Task & { steps: TaskStep[] }) | null>(null);
const fetchData = useCallback(async () => {
try {
const [krRes, checkinsRes, tasksRes] = await Promise.all([
fetch(`/api/krs/${krId}`),
fetch(`/api/checkins?kr_id=${krId}`),
fetch(`/api/tasks?kr_id=${krId}`),
]);
if (krRes.status === 403) {
setLoadError('你没有权限查看此 KR');
setKR(null);
return;
}
if (krRes.status === 404) {
setLoadError('KR 不存在');
setKR(null);
return;
}
if (krRes.ok) {
const krData = await krRes.json();
setKR(krData);
setLoadError(null);
}
if (checkinsRes.ok) {
const checkinsData = await checkinsRes.json();
setCheckins(checkinsData);
}
if (tasksRes.ok) {
const tasksData = await tasksRes.json();
setTasks(tasksData);
}
} catch (error) {
console.error('Failed to fetch KR data:', error);
setLoadError('加载失败');
} finally {
setIsLoading(false);
}
}, [krId]);
useEffect(() => {
fetchData();
}, [fetchData]);
const form = useForm<CheckinFormValues>({
resolver: zodResolver(checkinSchema),
defaultValues: {
current_value: kr?.current_value || 0,
confidence: kr?.confidence || 80,
reason_code: '',
note: '',
},
});
useEffect(() => {
if (kr) {
form.reset({
current_value: kr.current_value,
confidence: kr.confidence,
reason_code: kr.reason_code || '',
note: '',
});
}
}, [kr, form]);
const onCheckinSubmit = async (values: CheckinFormValues) => {
if (!kr) return;
setIsSubmitting(true);
try {
const res = await fetch('/api/checkins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kr_id: kr.id,
project_id: kr.project_id,
user_id: currentUser?.id,
current_value: values.current_value,
confidence: values.confidence,
reason_code: values.reason_code || null,
note: values.note || null,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || '打卡失败');
}
toast.success('打卡成功');
setIsCheckinOpen(false);
fetchData();
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsSubmitting(false);
}
};
const toggleNeedHelp = async () => {
if (!kr) return;
try {
const res = await fetch(`/api/krs/${kr.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
need_owner_help: !kr.need_owner_help,
skipReasonCodeCheck: true,
}),
});
if (res.ok) {
toast.success(kr.need_owner_help ? '已取消求助' : '已发送求助请求');
fetchData();
}
} catch (error) {
toast.error('操作失败');
}
};
const handleDeleteKR = async () => {
if (!kr) return;
setIsDeleting(true);
try {
const res = await fetch(`/api/krs/${kr.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('删除失败');
toast.success('KR 已删除');
router.push(`/projects/${kr.project_id}`);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsDeleting(false);
}
};
const handleEditKR = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!kr) return;
setIsEditing(true);
const formData = new FormData(e.currentTarget);
try {
const res = await fetch(`/api/krs/${kr.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.get('title'),
metric_name: formData.get('metric_name'),
unit: formData.get('unit'),
target_value: parseFloat(formData.get('target_value') as string),
period_start: formData.get('period_start'),
period_end: formData.get('period_end'),
}),
});
if (!res.ok) throw new Error('更新失败');
toast.success('KR 已更新');
setIsEditDialogOpen(false);
fetchData();
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsEditing(false);
}
};
if (isLoading) {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-6 bg-slate-200 rounded w-24" />
<div className="h-10 bg-slate-200 rounded w-64" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 h-96 bg-slate-200 rounded-lg" />
<div className="h-64 bg-slate-200 rounded-lg" />
</div>
</div>
</div>
);
}
if (!kr) {
return (
<div className="max-w-4xl mx-auto px-4 py-8 text-center">
<p className="text-muted-foreground">{loadError || 'KR 不存在'}</p>
<Button asChild className="mt-4" variant="outline">
<Link href="/my-krs">返回我的 KR</Link>
</Button>
</div>
);
}
const progress = calculateProgress(kr);
const expectedProgress = calculateExpectedProgress(kr);
const daysLeft = getDaysRemaining(kr.period_end);
const isActive = isKRActive(kr);
const isOwner = currentUser?.id === kr.owner_user_id;
const TrendIcon = kr.direction === 'increase' ? TrendingUp : TrendingDown;
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('更新失败');
}
};
const isBooleanMetric = kr?.unit === '状态' && kr?.metric_name === '完成状态';
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-6">
<Link
href="/my-krs"
className="inline-flex items-center text-sm text-muted-foreground hover:text-slate-800 mb-4"
>
<ArrowLeft className="h-4 w-4 mr-1" />
返回我的 KR
</Link>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold text-slate-800">{kr.title}</h1>
<StatusBadge status={kr.status} />
{kr.need_owner_help && (
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded flex items-center gap-1">
<HandHelping className="h-3 w-3" />
需要帮助
</span>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Link href={`/projects/${kr.project_id}`} className="hover:text-slate-800">
{kr.project?.name}
</Link>
<span>|</span>
<span className="flex items-center gap-1">
<UserIcon className="h-4 w-4" />
{kr.owner?.name || '未分配'}
</span>
<span>|</span>
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDateRange(kr.period_start, kr.period_end)}
</span>
</div>
</div>
{isActive && (
<div className="flex gap-2">
{currentUser?.role === 'owner' && (
<>
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>编辑 KR</DialogTitle>
</DialogHeader>
<form onSubmit={handleEditKR} className="space-y-4">
<div>
<Label htmlFor="kr-title">KR 标题</Label>
<Input id="kr-title" name="title" defaultValue={kr.title} required />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="kr-metric">指标名称</Label>
<Input id="kr-metric" name="metric_name" defaultValue={kr.metric_name} required />
</div>
<div>
<Label htmlFor="kr-unit">单位</Label>
<Input id="kr-unit" name="unit" defaultValue={kr.unit} required />
</div>
</div>
<div>
<Label htmlFor="kr-target">目标值</Label>
<Input type="number" step="any" id="kr-target" name="target_value" defaultValue={kr.target_value} required />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="kr-start">开始日期</Label>
<Input type="date" id="kr-start" name="period_start" defaultValue={kr.period_start} required />
</div>
<div>
<Label htmlFor="kr-end">结束日期</Label>
<Input type="date" id="kr-end" name="period_end" defaultValue={kr.period_end} required />
</div>
</div>
<Button type="submit" disabled={isEditing} className="w-full">
{isEditing && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
保存修改
</Button>
</form>
</DialogContent>
</Dialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="icon" className="text-rose-500 hover:text-rose-600">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定要删除此 KR 吗?</AlertDialogTitle>
<AlertDialogDescription>
此操作无法撤销。这将永久删除 KR 及其所有打卡记录。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteKR}
className="bg-rose-600 hover:bg-rose-700"
disabled={isDeleting}
>
{isDeleting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
<Dialog open={isCheckinOpen} onOpenChange={setIsCheckinOpen}>
<DialogTrigger asChild>
<Button className="bg-teal-600 hover:bg-teal-700">
<Plus className="h-4 w-4 mr-2" />
打卡更新
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>KR 进度打卡</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onCheckinSubmit)} className="space-y-4">
<FormField
control={form.control}
name="current_value"
render={({ field }) => (
<FormItem>
<FormLabel>当前状态</FormLabel>
<FormControl>
{isBooleanMetric ? (
<RadioGroup
onValueChange={(val) => field.onChange(parseInt(val))}
defaultValue={field.value.toString()}
className="flex flex-col space-y-1"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="0" id="r1" />
<Label htmlFor="r1">待完成</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="1" id="r2" />
<Label htmlFor="r2">已完成</Label>
</div>
</RadioGroup>
) : (
<>
<Input
type="number"
step="any"
{...field}
onChange={e => field.onChange(parseFloat(e.target.value) || 0)}
/>
<p className="text-xs text-muted-foreground">
目标值: {kr.target_value} {kr.unit}
</p>
</>
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confidence"
render={({ field }) => (
<FormItem>
<FormLabel>信心度 ({field.value}%)</FormLabel>
<FormControl>
<Input
type="range"
min="0"
max="100"
{...field}
onChange={e => field.onChange(parseInt(e.target.value))}
className="w-full"
/>
</FormControl>
<div className="flex justify-between text-xs text-muted-foreground">
<span>0%</span>
<span>50%</span>
<span>100%</span>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="reason_code"
render={({ field }) => (
<FormItem>
<FormLabel>问题原因 (如有)</FormLabel>
<Select
onValueChange={(value) => field.onChange(value === "unspecified" ? "" : value)}
value={field.value || "unspecified"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择原因类型" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="unspecified">无问题</SelectItem>
{Object.entries(REASON_CODES).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="note"
render={({ field }) => (
<FormItem>
<FormLabel>备注</FormLabel>
<FormControl>
<Textarea placeholder="补充说明" rows={2} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
提交打卡
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-6">
<ProgressRing progress={progress} status={kr.status} size={100} strokeWidth={8} />
<div>
<p className="text-3xl font-bold text-slate-800">
{isBooleanMetric
? (kr.current_value >= 1 ? '已完成' : '待完成')
: `${kr.current_value} / ${kr.target_value}`
}
</p>
{!isBooleanMetric && <p className="text-muted-foreground">{kr.unit}</p>}
<div className="flex items-center gap-2 mt-2">
<TrendIcon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{DIRECTION_LABELS[kr.direction]}
</span>
</div>
</div>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground mb-1">期望进度</p>
<p className="text-2xl font-semibold text-slate-600">
{Math.round(expectedProgress * 100)}%
</p>
{isActive && (
<p className={`text-sm ${daysLeft <= 3 ? 'text-amber-600' : 'text-muted-foreground'}`}>
<Clock className="h-3 w-3 inline mr-1" />
剩余 {daysLeft} 天
</p>
)}
</div>
</div>
{kr.reason_code && kr.status !== 'green' && (
<div className="bg-amber-50 rounded-lg p-4 flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-500 mt-0.5" />
<div>
<p className="font-medium text-amber-800">{REASON_CODES[kr.reason_code]}</p>
{kr.note && <p className="text-sm text-amber-700 mt-1">{kr.note}</p>}
</div>
</div>
)}
</CardContent>
</Card>
<Card>
<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>
{isActive && (
<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">暂无计划,拆解 KR 为具体执行步骤</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" />
{currentUser?.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>
<Card>
<CardHeader>
<CardTitle className="text-lg">打卡记录</CardTitle>
</CardHeader>
<CardContent>
{checkins.length === 0 ? (
<p className="text-center text-muted-foreground py-8">暂无打卡记录</p>
) : (
<div className="space-y-4">
{checkins.map((checkin, index) => (
<div key={checkin.id} className="flex items-start gap-4">
<div className="flex flex-col items-center">
<StatusDot status={checkin.status_snapshot} size="md" />
{index < checkins.length - 1 && (
<div className="w-0.5 h-12 bg-slate-200 mt-2" />
)}
</div>
<div className="flex-1 pb-4">
<div className="flex items-center justify-between mb-1">
<span className="font-medium">
{isBooleanMetric
? (checkin.current_value >= 1 ? '已完成' : '待完成')
: `${checkin.current_value} ${kr.unit}`
}
</span>
<span className="text-sm text-muted-foreground">
{new Date(checkin.created_at).toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
<div className="text-sm text-muted-foreground">
{checkin.user?.name} | 信心度 {checkin.confidence}%
{checkin.delta_value !== null && checkin.delta_value !== 0 && (
<span className={checkin.delta_value > 0 ? 'text-emerald-600' : 'text-rose-600'}>
{' '}({checkin.delta_value > 0 ? '+' : ''}{checkin.delta_value})
</span>
)}
</div>
{checkin.reason_code && (
<p className="text-sm text-amber-600 mt-1">
{REASON_CODES[checkin.reason_code]}
</p>
)}
{checkin.note && (
<p className="text-sm text-slate-600 mt-1">{checkin.note}</p>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">KR 详情</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-muted-foreground">周期类型</p>
<p className="font-medium">{PERIOD_TYPES[kr.period_type]}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">指标名称</p>
<p className="font-medium">{kr.metric_name}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">信心度</p>
<p className="font-medium">{kr.confidence}%</p>
</div>
{kr.objective && (
<div>
<p className="text-sm text-muted-foreground">关联目标</p>
<p className="font-medium">{kr.objective.title}</p>
</div>
)}
</CardContent>
</Card>
{isOwner && isActive && (
<Card>
<CardHeader>
<CardTitle className="text-lg">快捷操作</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">请求老板帮助</p>
<p className="text-sm text-muted-foreground">遇到困难时发送求助</p>
</div>
<Switch checked={kr.need_owner_help} onCheckedChange={toggleNeedHelp} />
</div>
</CardContent>
</Card>
)}
</div>
</div>
<TaskEditDialog
open={isTaskDialogOpen}
onOpenChange={setIsTaskDialogOpen}
krId={krId}
projectId={kr.project_id}
projectTitle={kr.project?.name || ''}
task={editingTask}
onSuccess={fetchData}
/>
</div>
);
}