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:
2026-01-28 12:57:36 +08:00
parent e3e5e89df6
commit 7948b1afc7
50 changed files with 9506 additions and 150 deletions

View File

@@ -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>
);
}