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:
@@ -27,8 +27,9 @@ import {
|
||||
isKRActive,
|
||||
} from '@/lib/kr-utils';
|
||||
import { REASON_CODES, PERIOD_TYPES, DIRECTION_LABELS } from '@/lib/constants';
|
||||
import type { KR, User, Checkin, Objective } from '@/types/database';
|
||||
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,
|
||||
@@ -43,7 +44,9 @@ import {
|
||||
Loader2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
CheckSquare,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getErrorMessage } from '@/lib/error-utils';
|
||||
|
||||
interface KRWithRelations extends KR {
|
||||
@@ -56,6 +59,8 @@ 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),
|
||||
@@ -71,31 +76,54 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
|
||||
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] = await Promise.all([
|
||||
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);
|
||||
}
|
||||
@@ -248,7 +276,7 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
if (!kr) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8 text-center">
|
||||
<p className="text-muted-foreground">KR 不存在</p>
|
||||
<p className="text-muted-foreground">{loadError || 'KR 不存在'}</p>
|
||||
<Button asChild className="mt-4" variant="outline">
|
||||
<Link href="/my-krs">返回我的 KR</Link>
|
||||
</Button>
|
||||
@@ -263,6 +291,24 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
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">
|
||||
@@ -400,18 +446,37 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
name="current_value"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>当前值 ({kr.unit})</FormLabel>
|
||||
<FormLabel>当前状态</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
{...field}
|
||||
onChange={e => field.onChange(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
{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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
目标值: {kr.target_value} {kr.unit}
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -508,9 +573,12 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
<ProgressRing progress={progress} status={kr.status} size={100} strokeWidth={8} />
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-slate-800">
|
||||
{kr.current_value} / {kr.target_value}
|
||||
{isBooleanMetric
|
||||
? (kr.current_value >= 1 ? '已完成' : '待完成')
|
||||
: `${kr.current_value} / ${kr.target_value}`
|
||||
}
|
||||
</p>
|
||||
<p className="text-muted-foreground">{kr.unit}</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">
|
||||
@@ -545,6 +613,93 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
</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>
|
||||
@@ -565,7 +720,10 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
<div className="flex-1 pb-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium">
|
||||
{checkin.current_value} {kr.unit}
|
||||
{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', {
|
||||
@@ -646,6 +804,15 @@ export default function KRDetailPage({ krId }: { krId: string }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TaskEditDialog
|
||||
open={isTaskDialogOpen}
|
||||
onOpenChange={setIsTaskDialogOpen}
|
||||
krId={krId}
|
||||
projectId={kr.project_id}
|
||||
projectTitle={kr.project?.name || ''}
|
||||
task={editingTask}
|
||||
onSuccess={fetchData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user