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

2
.gitignore vendored
View File

@@ -34,3 +34,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.trae

View File

@@ -18,6 +18,16 @@ import type { Metric } from '@/types/database';
import { toast } from 'sonner';
import { Plus, Pencil, Trash2, Gauge, Loader2, TrendingUp, TrendingDown } from 'lucide-react';
import { getErrorMessage } from '@/lib/error-utils';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
const metricSchema = z.object({
name: z.string().min(1, '名称不能为空'),
@@ -35,6 +45,9 @@ export default function AdminMetricsPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingMetric, setEditingMetric] = useState<Metric | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [deleteMetricId, setDeleteMetricId] = useState<string | null>(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const form = useForm<MetricFormValues>({
resolver: zodResolver(metricSchema),
@@ -121,17 +134,26 @@ export default function AdminMetricsPage() {
}
};
const handleDelete = async (metricId: string) => {
if (!confirm('确定要删除此指标吗?')) return;
const requestDelete = (metricId: string) => {
setDeleteMetricId(metricId);
setIsDeleteOpen(true);
};
const confirmDelete = async () => {
if (!deleteMetricId) return;
setIsDeleting(true);
try {
const res = await fetch(`/api/metrics/${metricId}`, { method: 'DELETE' });
const res = await fetch(`/api/metrics/${deleteMetricId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('删除失败');
toast.success('指标已删除');
fetchMetrics();
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsDeleting(false);
setIsDeleteOpen(false);
setDeleteMetricId(null);
}
};
@@ -309,7 +331,7 @@ export default function AdminMetricsPage() {
<Button variant="ghost" size="sm" onClick={() => handleEdit(metric)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(metric.id)}>
<Button variant="ghost" size="sm" onClick={() => requestDelete(metric.id)}>
<Trash2 className="h-4 w-4 text-rose-500" />
</Button>
</TableCell>
@@ -327,6 +349,27 @@ export default function AdminMetricsPage() {
</Table>
</CardContent>
</Card>
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除指标?</AlertDialogTitle>
<AlertDialogDescription>
删除后无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={confirmDelete}
disabled={isDeleting}
>
{isDeleting ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -18,6 +18,16 @@ import type { Template } from '@/types/database';
import { toast } from 'sonner';
import { Plus, Pencil, Trash2, Files, Loader2, X } from 'lucide-react';
import { getErrorMessage } from '@/lib/error-utils';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
const templateItemSchema = z.object({
title: z.string().min(1, '标题不能为空'),
@@ -44,6 +54,9 @@ export default function AdminTemplatesPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<Template | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [deleteTemplateId, setDeleteTemplateId] = useState<string | null>(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const form = useForm<TemplateFormValues>({
resolver: zodResolver(templateSchema),
@@ -135,17 +148,26 @@ export default function AdminTemplatesPage() {
}
};
const handleDelete = async (templateId: string) => {
if (!confirm('确定要删除此模板吗?')) return;
const requestDelete = (templateId: string) => {
setDeleteTemplateId(templateId);
setIsDeleteOpen(true);
};
const confirmDelete = async () => {
if (!deleteTemplateId) return;
setIsDeleting(true);
try {
const res = await fetch(`/api/templates/${templateId}`, { method: 'DELETE' });
const res = await fetch(`/api/templates/${deleteTemplateId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('删除失败');
toast.success('模板已删除');
fetchTemplates();
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsDeleting(false);
setIsDeleteOpen(false);
setDeleteTemplateId(null);
}
};
@@ -395,7 +417,7 @@ export default function AdminTemplatesPage() {
<Button variant="ghost" size="sm" onClick={() => handleEdit(template)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(template.id)}>
<Button variant="ghost" size="sm" onClick={() => requestDelete(template.id)}>
<Trash2 className="h-4 w-4 text-rose-500" />
</Button>
</div>
@@ -431,6 +453,27 @@ export default function AdminTemplatesPage() {
</div>
)}
</div>
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除模板?</AlertDialogTitle>
<AlertDialogDescription>
删除后无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={confirmDelete}
disabled={isDeleting}
>
{isDeleting ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -18,10 +18,22 @@ import type { User } from '@/types/database';
import { toast } from 'sonner';
import { getErrorMessage } from '@/lib/error-utils';
import { Plus, Pencil, Trash2, Users, Loader2, Crown, Briefcase, User as UserIcon } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
const userSchema = z.object({
name: z.string().min(1, '名称不能为空'),
role: z.enum(['owner', 'pm', 'member']),
username: z.string().min(1, '账号不能为空'),
password: z.string().optional(),
});
type UserFormValues = z.infer<typeof userSchema>;
@@ -38,25 +50,41 @@ export default function AdminUsersPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [deleteUserId, setDeleteUserId] = useState<string | null>(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
defaultValues: {
name: '',
role: 'member',
username: '',
password: '',
},
});
const onSubmit = async (values: UserFormValues) => {
setIsSubmitting(true);
try {
if (!editingUser && !values.password) {
throw new Error('请设置初始密码');
}
const url = editingUser ? `/api/users/${editingUser.id}` : '/api/users';
const method = editingUser ? 'PATCH' : 'POST';
const payload = {
name: values.name,
role: values.role,
username: values.username,
password: values.password ? values.password : undefined,
};
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
body: JSON.stringify(payload),
});
if (!res.ok) {
@@ -81,21 +109,33 @@ export default function AdminUsersPage() {
form.reset({
name: user.name,
role: user.role,
username: user.username || '',
password: '',
});
setIsDialogOpen(true);
};
const handleDelete = async (userId: string) => {
if (!confirm('确定要删除此用户吗?')) return;
const requestDelete = (userId: string) => {
setDeleteUserId(userId);
setIsDeleteOpen(true);
};
const confirmDelete = async () => {
if (!deleteUserId) return;
setIsDeleting(true);
try {
const res = await fetch(`/api/users/${userId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('删除失败');
const res = await fetch(`/api/users/${deleteUserId}`, { method: 'DELETE' });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error || '删除失败');
toast.success('用户已删除');
refreshUsers();
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsDeleting(false);
setIsDeleteOpen(false);
setDeleteUserId(null);
}
};
@@ -151,6 +191,34 @@ export default function AdminUsersPage() {
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>账号 *</FormLabel>
<FormControl>
<Input placeholder="输入登录账号" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{editingUser ? '重置密码' : '密码 *'}</FormLabel>
<FormControl>
<Input type="password" placeholder={editingUser ? '留空则不修改' : '输入初始密码'} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
@@ -197,6 +265,7 @@ export default function AdminUsersPage() {
<TableHeader>
<TableRow>
<TableHead>用户</TableHead>
<TableHead>账号</TableHead>
<TableHead>角色</TableHead>
<TableHead>创建时间</TableHead>
<TableHead className="text-right">操作</TableHead>
@@ -217,6 +286,9 @@ export default function AdminUsersPage() {
<span className="font-medium">{user.name}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{user.username || '-'}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<RoleIcon className="h-4 w-4 text-muted-foreground" />
@@ -233,8 +305,8 @@ export default function AdminUsersPage() {
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(user.id)}
disabled={user.id === currentUser?.id}
onClick={() => requestDelete(user.id)}
disabled={user.role === 'owner'}
>
<Trash2 className="h-4 w-4 text-rose-500" />
</Button>
@@ -246,6 +318,27 @@ export default function AdminUsersPage() {
</Table>
</CardContent>
</Card>
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除用户?</AlertDialogTitle>
<AlertDialogDescription>
删除后无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={confirmDelete}
disabled={isDeleting}
>
{isDeleting ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

117
app/api/auth/login/route.ts Normal file
View File

@@ -0,0 +1,117 @@
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { hashPassword, verifyPassword } from '@/lib/password';
const DEFAULT_OWNER_USERNAME = 'baicaimiao';
const DEFAULT_OWNER_PASSWORD = 'baicaimiao123';
const DEFAULT_OWNER_NAME = '兜爸';
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
const username = typeof body.username === 'string' ? body.username.trim() : '';
const password = typeof body.password === 'string' ? body.password : '';
if (!username || !password) {
return NextResponse.json({ error: '请输入账号和密码' }, { status: 400 });
}
const { data: userByUsername, error: usernameError } = await supabase
.from('users')
.select('id,name,role,avatar_url,username,password_hash,created_at,updated_at')
.eq('username', username)
.maybeSingle();
if (usernameError) {
return NextResponse.json({ error: usernameError.message }, { status: 500 });
}
if (userByUsername?.password_hash) {
const ok = verifyPassword(password, userByUsername.password_hash);
if (!ok) {
return NextResponse.json({ error: '账号或密码错误' }, { status: 401 });
}
const { password_hash, ...safeUser } = userByUsername as any;
const res = NextResponse.json({ user: safeUser });
res.cookies.set('okr_user_id', safeUser.id, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
});
return res;
}
if (username === DEFAULT_OWNER_USERNAME && password === DEFAULT_OWNER_PASSWORD) {
const { data: ownerUser, error: ownerError } = await supabase
.from('users')
.select('id,name,role,avatar_url,username,password_hash,created_at,updated_at')
.eq('role', 'owner')
.order('created_at', { ascending: true })
.limit(1)
.maybeSingle();
if (ownerError) {
return NextResponse.json({ error: ownerError.message }, { status: 500 });
}
const passwordHash = hashPassword(DEFAULT_OWNER_PASSWORD);
if (ownerUser) {
const { data: updated, error: updateError } = await supabase
.from('users')
.update({
name: DEFAULT_OWNER_NAME,
username: DEFAULT_OWNER_USERNAME,
password_hash: passwordHash,
role: 'owner',
updated_at: new Date().toISOString(),
})
.eq('id', ownerUser.id)
.select('id,name,role,avatar_url,username,created_at,updated_at')
.single();
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 });
}
const res = NextResponse.json({ user: updated });
res.cookies.set('okr_user_id', updated.id, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
});
return res;
}
const { data: created, error: createError } = await supabase
.from('users')
.insert({
name: DEFAULT_OWNER_NAME,
role: 'owner',
avatar_url: null,
username: DEFAULT_OWNER_USERNAME,
password_hash: passwordHash,
})
.select('id,name,role,avatar_url,username,created_at,updated_at')
.single();
if (createError) {
return NextResponse.json({ error: createError.message }, { status: 500 });
}
const res = NextResponse.json({ user: created });
res.cookies.set('okr_user_id', created.id, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
});
return res;
}
return NextResponse.json({ error: '账号或密码错误' }, { status: 401 });
}

View File

@@ -0,0 +1,15 @@
export const dynamic = 'force-dynamic';
import { NextResponse } from 'next/server';
export async function POST() {
const res = NextResponse.json({ success: true });
res.cookies.set('okr_user_id', '', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
expires: new Date(0),
});
return res;
}

27
app/api/auth/me/route.ts Normal file
View File

@@ -0,0 +1,27 @@
export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
export async function GET(request: NextRequest) {
const userId = request.cookies.get('okr_user_id')?.value;
if (!userId) {
return NextResponse.json({ user: null }, { status: 200 });
}
const { data, error } = await supabase
.from('users')
.select('id,name,role,avatar_url,username,created_at,updated_at')
.eq('id', userId)
.maybeSingle();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
if (!data) {
return NextResponse.json({ user: null }, { status: 200 });
}
return NextResponse.json({ user: data }, { status: 200 });
}

View File

@@ -4,8 +4,15 @@ import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { determineKRStatus, determineProjectStatus } from '@/lib/kr-utils';
import type { KR, Checkin } from '@/types/database';
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
export async function GET(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const krId = searchParams.get('kr_id');
const projectId = searchParams.get('project_id');
@@ -21,6 +28,34 @@ export async function GET(request: NextRequest) {
`)
.order('created_at', { ascending: false });
if (!ctx!.isAdmin) {
if (userId && userId !== ctx!.user.id) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
if (projectId && !hasProjectAccess(ctx!, projectId)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
if (krId) {
const { data: kr, error: krErr } = await supabase
.from('krs')
.select('id,project_id')
.eq('id', krId)
.maybeSingle();
if (krErr) return NextResponse.json({ error: krErr.message }, { status: 500 });
if (!kr) return NextResponse.json({ error: 'KR不存在' }, { status: 404 });
if (!hasProjectAccess(ctx!, kr.project_id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
}
if (!projectId && !krId) {
if (ctx!.accessibleProjectIds.length === 0) {
return NextResponse.json([]);
}
query = query.in('project_id', ctx!.accessibleProjectIds);
}
}
if (krId) query = query.eq('kr_id', krId);
if (projectId) query = query.eq('project_id', projectId);
if (userId) query = query.eq('user_id', userId);
@@ -36,6 +71,12 @@ export async function GET(request: NextRequest) {
}
export async function POST(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const { kr_id, project_id, user_id, date, current_value, confidence, reason_code, note } = body;
@@ -58,6 +99,19 @@ export async function POST(request: NextRequest) {
const kr = krData as KR;
if (kr.project_id !== project_id) {
return NextResponse.json({ error: '项目与 KR 不匹配' }, { status: 400 });
}
if (!ctx!.isAdmin) {
if (!hasProjectAccess(ctx!, project_id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
if (user_id && user_id !== ctx!.user.id) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
}
const { data: recentCheckins } = await supabase
.from('checkins')
.select('*')
@@ -91,7 +145,7 @@ export async function POST(request: NextRequest) {
.insert({
kr_id,
project_id,
user_id,
user_id: ctx!.isAdmin ? user_id : ctx!.user.id,
date: checkinDate,
current_value,
delta_value,

View File

@@ -2,11 +2,18 @@ export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAdmin, ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { data, error } = await supabase
.from('krs')
.select(`
@@ -26,6 +33,10 @@ export async function GET(
return NextResponse.json({ error: 'KR不存在' }, { status: 404 });
}
if (!ctx!.isAdmin && !hasProjectAccess(ctx!, data.project_id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
return NextResponse.json(data);
}
@@ -33,6 +44,12 @@ export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const {
title,
@@ -96,6 +113,12 @@ export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { error } = await supabase
.from('krs')
.delete()

View File

@@ -2,8 +2,15 @@ export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
export async function GET(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const projectId = searchParams.get('project_id');
const objectiveId = searchParams.get('objective_id');
@@ -19,6 +26,19 @@ export async function GET(request: NextRequest) {
`)
.order('period_start', { ascending: false });
if (!ctx!.isAdmin) {
if (projectId) {
if (!hasProjectAccess(ctx!, projectId)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
} else {
if (ctx!.accessibleProjectIds.length === 0) {
return NextResponse.json([]);
}
query = query.in('project_id', ctx!.accessibleProjectIds);
}
}
if (projectId) query = query.eq('project_id', projectId);
if (objectiveId) query = query.eq('objective_id', objectiveId);
if (ownerUserId) query = query.eq('owner_user_id', ownerUserId);
@@ -34,6 +54,12 @@ export async function GET(request: NextRequest) {
}
export async function POST(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const {
project_id,
@@ -59,6 +85,10 @@ export async function POST(request: NextRequest) {
);
}
if (!ctx!.isAdmin && !hasProjectAccess(ctx!, project_id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
if (target_value <= 0) {
return NextResponse.json({ error: '目标值必须大于0' }, { status: 400 });
}

View File

@@ -1,10 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { data, error } = await supabase
.from('meetings')
.select(`
@@ -23,6 +30,10 @@ export async function GET(
return NextResponse.json({ error: '会议记录不存在' }, { status: 404 });
}
if (!ctx!.isAdmin && !hasProjectAccess(ctx!, data.project_id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
return NextResponse.json(data);
}
@@ -30,6 +41,25 @@ export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
if (!ctx!.isAdmin) {
const { data: meeting, error: mErr } = await supabase
.from('meetings')
.select('id,project_id')
.eq('id', params.id)
.maybeSingle();
if (mErr) return NextResponse.json({ error: mErr.message }, { status: 500 });
if (!meeting) return NextResponse.json({ error: '会议记录不存在' }, { status: 404 });
if (!hasProjectAccess(ctx!, meeting.project_id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
}
const body = await request.json();
const { summary, decisions, risks, next_period_commitments } = body;
@@ -57,6 +87,25 @@ export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
if (!ctx!.isAdmin) {
const { data: meeting, error: mErr } = await supabase
.from('meetings')
.select('id,project_id')
.eq('id', params.id)
.maybeSingle();
if (mErr) return NextResponse.json({ error: mErr.message }, { status: 500 });
if (!meeting) return NextResponse.json({ error: '会议记录不存在' }, { status: 404 });
if (!hasProjectAccess(ctx!, meeting.project_id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
}
const { error } = await supabase
.from('meetings')
.delete()

View File

@@ -1,7 +1,14 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
export async function GET(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const projectId = searchParams.get('project_id');
@@ -14,6 +21,19 @@ export async function GET(request: NextRequest) {
`)
.order('created_at', { ascending: false });
if (!ctx!.isAdmin) {
if (projectId) {
if (!hasProjectAccess(ctx!, projectId)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
} else {
if (ctx!.accessibleProjectIds.length === 0) {
return NextResponse.json([]);
}
query = query.in('project_id', ctx!.accessibleProjectIds);
}
}
if (projectId) query = query.eq('project_id', projectId);
const { data, error } = await query;
@@ -26,6 +46,12 @@ export async function GET(request: NextRequest) {
}
export async function POST(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const { project_id, period_start, period_end, summary, decisions, risks, next_period_commitments, created_by } = body;
@@ -36,6 +62,10 @@ export async function POST(request: NextRequest) {
);
}
if (!ctx!.isAdmin && !hasProjectAccess(ctx!, project_id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
const { data, error } = await supabase
.from('meetings')
.insert({
@@ -46,7 +76,7 @@ export async function POST(request: NextRequest) {
decisions,
risks,
next_period_commitments,
created_by,
created_by: ctx!.isAdmin ? created_by : ctx!.user.id,
})
.select()
.single();

View File

@@ -1,15 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const { data, error } = await supabase
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
let query = supabase
.from('metrics')
.select('*')
.eq('id', params.id)
.maybeSingle();
.eq('id', params.id);
if (!ctx!.isAdmin) query = query.eq('is_active', true);
const { data, error } = await query.maybeSingle();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
@@ -26,6 +36,12 @@ export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const { name, unit, direction, category, is_active } = body;
@@ -54,6 +70,12 @@ export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { error } = await supabase
.from('metrics')
.delete()

View File

@@ -1,14 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
export async function GET(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const activeOnly = searchParams.get('active_only') === 'true';
const category = searchParams.get('category');
let query = supabase.from('metrics').select('*').order('name', { ascending: true });
if (activeOnly) query = query.eq('is_active', true);
if (!ctx!.isAdmin || activeOnly) query = query.eq('is_active', true);
if (category) query = query.eq('category', category);
const { data, error } = await query;
@@ -21,6 +28,12 @@ export async function GET(request: NextRequest) {
}
export async function POST(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const { name, unit, direction, category } = body;

View File

@@ -2,11 +2,18 @@ export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAdmin, ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { data, error } = await supabase
.from('objectives')
.select('*')
@@ -21,6 +28,10 @@ export async function GET(
return NextResponse.json({ error: '目标不存在' }, { status: 404 });
}
if (!ctx!.isAdmin && !hasProjectAccess(ctx!, data.project_id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
return NextResponse.json(data);
}
@@ -28,6 +39,12 @@ export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const { title, description, period_start, period_end } = body;
@@ -55,6 +72,12 @@ export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { error } = await supabase
.from('objectives')
.delete()

View File

@@ -2,17 +2,35 @@ export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
export async function GET(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const projectId = searchParams.get('project_id');
let query = supabase.from('objectives').select('*').order('period_start', { ascending: false });
if (projectId) {
query = query.eq('project_id', projectId);
if (!ctx!.isAdmin) {
if (projectId) {
if (!hasProjectAccess(ctx!, projectId)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
} else {
if (ctx!.accessibleProjectIds.length === 0) {
return NextResponse.json([]);
}
query = query.in('project_id', ctx!.accessibleProjectIds);
}
}
if (projectId) query = query.eq('project_id', projectId);
const { data, error } = await query;
if (error) {
@@ -23,6 +41,12 @@ export async function GET(request: NextRequest) {
}
export async function POST(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const { project_id, title, description, period_start, period_end, created_by } = body;
@@ -34,6 +58,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '结束日期不能早于开始日期' }, { status: 400 });
}
if (!ctx!.isAdmin && !hasProjectAccess(ctx!, project_id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
const { data, error } = await supabase
.from('objectives')
.insert({ project_id, title, description, period_start, period_end, created_by })

View File

@@ -1,10 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAdmin, ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
if (!hasProjectAccess(ctx!, params.id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
const { data, error } = await supabase
.from('project_members')
.select('user_id, users(id, name, role, avatar_url)')
@@ -21,6 +31,12 @@ export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const { user_id } = body;
@@ -45,6 +61,12 @@ export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const userId = searchParams.get('user_id');

View File

@@ -2,11 +2,21 @@ export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAdmin, ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
if (!hasProjectAccess(ctx!, params.id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
const { data, error } = await supabase
.from('projects')
.select(`
@@ -37,6 +47,12 @@ export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const { name, description, owner_id, pm_id, project_type, status, member_ids } = body;
@@ -78,6 +94,12 @@ export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { error } = await supabase
.from('projects')
.delete()

View File

@@ -2,9 +2,15 @@ export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { getAuthContext } from '@/lib/access-control';
export async function GET() {
const { data, error } = await supabase
export async function GET(request: NextRequest) {
const ctx = await getAuthContext(request);
if (!ctx) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
let query = supabase
.from('projects')
.select(`
*,
@@ -13,6 +19,15 @@ export async function GET() {
`)
.order('created_at', { ascending: false });
if (!ctx.isAdmin) {
if (ctx.accessibleProjectIds.length === 0) {
return NextResponse.json([]);
}
query = query.in('id', ctx.accessibleProjectIds);
}
const { data, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
@@ -21,6 +36,14 @@ export async function GET() {
}
export async function POST(request: NextRequest) {
const ctx = await getAuthContext(request);
if (!ctx) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
if (!ctx.isAdmin) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
const body = await request.json();
const { name, description, owner_id, pm_id, project_type, member_ids } = body;

240
app/api/task-steps/route.ts Normal file
View File

@@ -0,0 +1,240 @@
export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAuthenticated, getAuthContext } from '@/lib/access-control';
async function canAccessTask(userId: string, isAdmin: boolean, accessibleProjectIds: string[], taskId: string) {
if (isAdmin) return { ok: true as const };
const { data: task, error } = await supabase
.from('tasks')
.select('id,project_id,owner_user_id,executor_ids,help_requested_to_user_id')
.eq('id', taskId)
.maybeSingle();
if (error) return { ok: false as const, status: 500, error: error.message };
if (!task) return { ok: false as const, status: 404, error: '计划不存在' };
if (task.project_id && accessibleProjectIds.includes(task.project_id)) return { ok: true as const };
if (task.owner_user_id === userId) return { ok: true as const };
if (task.help_requested_to_user_id === userId) return { ok: true as const };
if (Array.isArray(task.executor_ids) && task.executor_ids.includes(userId)) return { ok: true as const };
return { ok: false as const, status: 403, error: '无权限' };
}
export async function GET(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const searchParams = request.nextUrl.searchParams;
const task_id = searchParams.get('task_id');
const help_requested_to_user_id = searchParams.get('help_requested_to_user_id');
let query = supabase
.from('task_steps')
.select(`
*,
task:tasks(
id,
title,
project_id,
kr_id,
owner_user_id,
help_requested_to_user_id,
help_requested_by_user_id,
help_requested_at
)
`)
.order('created_at', { ascending: false });
if (!ctx!.isAdmin) {
if (help_requested_to_user_id && help_requested_to_user_id !== ctx!.user.id) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
if (task_id) {
const access = await canAccessTask(ctx!.user.id, ctx!.isAdmin, ctx!.accessibleProjectIds, task_id);
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
}
if (!task_id && !help_requested_to_user_id) {
query = query.or(`executor_id.eq.${ctx!.user.id},help_requested_to_user_id.eq.${ctx!.user.id}`);
}
}
if (task_id) query = query.eq('task_id', task_id);
if (help_requested_to_user_id) query = query.eq('help_requested_to_user_id', help_requested_to_user_id);
const { data, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(data);
}
export async function POST(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const {
task_id,
content,
note,
executor_id,
start_date,
end_date,
is_completed,
help_requested_to_user_id,
help_requested_by_user_id,
} = body;
const isHelpRequested = Boolean(help_requested_to_user_id);
if (!task_id) {
return NextResponse.json({ error: '计划ID为必填项' }, { status: 400 });
}
const access = await canAccessTask(ctx!.user.id, ctx!.isAdmin, ctx!.accessibleProjectIds, task_id);
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
const { data, error } = await supabase
.from('task_steps')
.insert({
task_id,
content,
note,
executor_id,
start_date,
end_date,
is_completed,
help_requested_to_user_id: help_requested_to_user_id || null,
help_requested_by_user_id: isHelpRequested ? (ctx!.isAdmin ? (help_requested_by_user_id || null) : ctx!.user.id) : null,
help_requested_at: isHelpRequested ? new Date().toISOString() : null,
})
.select()
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(data);
}
export async function PATCH(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const {
id,
content,
note,
executor_id,
start_date,
end_date,
is_completed,
help_requested_to_user_id,
help_requested_by_user_id,
} = body;
if (!id) {
return NextResponse.json({ error: 'Missing step id' }, { status: 400 });
}
const { data: stepRow, error: stepErr } = await supabase
.from('task_steps')
.select('id,task_id')
.eq('id', id)
.maybeSingle();
if (stepErr) return NextResponse.json({ error: stepErr.message }, { status: 500 });
if (!stepRow) return NextResponse.json({ error: '步骤不存在' }, { status: 404 });
const access = await canAccessTask(ctx!.user.id, ctx!.isAdmin, ctx!.accessibleProjectIds, stepRow.task_id);
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
const updateData: Record<string, unknown> = {};
if (content !== undefined) updateData.content = content;
if (note !== undefined) updateData.note = note;
if (executor_id !== undefined) updateData.executor_id = executor_id;
if (start_date !== undefined) updateData.start_date = start_date;
if (end_date !== undefined) updateData.end_date = end_date;
if (is_completed !== undefined) updateData.is_completed = is_completed;
if (help_requested_to_user_id !== undefined) {
updateData.help_requested_to_user_id = help_requested_to_user_id;
if (help_requested_to_user_id) {
updateData.help_requested_by_user_id = ctx!.isAdmin ? (help_requested_by_user_id || null) : ctx!.user.id;
updateData.help_requested_at = new Date().toISOString();
} else {
updateData.help_requested_by_user_id = null;
updateData.help_requested_at = null;
}
}
const { data, error } = await supabase
.from('task_steps')
.update(updateData)
.eq('id', id)
.select()
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(data);
}
export async function DELETE(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const searchParams = request.nextUrl.searchParams;
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
const { data: stepRow, error: stepErr } = await supabase
.from('task_steps')
.select('id,task_id')
.eq('id', id)
.maybeSingle();
if (stepErr) return NextResponse.json({ error: stepErr.message }, { status: 500 });
if (!stepRow) return NextResponse.json({ error: '步骤不存在' }, { status: 404 });
const access = await canAccessTask(ctx!.user.id, ctx!.isAdmin, ctx!.accessibleProjectIds, stepRow.task_id);
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
const { error } = await supabase
.from('task_steps')
.delete()
.eq('id', id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}

118
app/api/tasks/[id]/route.ts Normal file
View File

@@ -0,0 +1,118 @@
export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAuthenticated, getAuthContext } from '@/lib/access-control';
async function canAccessTask(userId: string, isAdmin: boolean, accessibleProjectIds: string[], taskId: string) {
if (isAdmin) return { ok: true as const };
const { data: task, error } = await supabase
.from('tasks')
.select('id,project_id,owner_user_id,executor_ids,help_requested_to_user_id')
.eq('id', taskId)
.maybeSingle();
if (error) return { ok: false as const, status: 500, error: error.message };
if (!task) return { ok: false as const, status: 404, error: '计划不存在' };
if (task.project_id && accessibleProjectIds.includes(task.project_id)) return { ok: true as const };
if (task.owner_user_id === userId) return { ok: true as const };
if (task.help_requested_to_user_id === userId) return { ok: true as const };
if (Array.isArray(task.executor_ids) && task.executor_ids.includes(userId)) return { ok: true as const };
return { ok: false as const, status: 403, error: '无权限' };
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const access = await canAccessTask(ctx!.user.id, ctx!.isAdmin, ctx!.accessibleProjectIds, params.id);
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
const body = await request.json();
const {
title,
description,
status,
priority,
urgency,
owner_user_id,
executor_ids,
start_date,
end_date,
help_requested_to_user_id,
help_requested_by_user_id,
} = body;
const updateData: Record<string, unknown> = { updated_at: new Date().toISOString() };
if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description;
if (status !== undefined) updateData.status = status;
if (priority !== undefined) updateData.priority = priority;
if (urgency !== undefined) updateData.urgency = urgency;
if (owner_user_id !== undefined) updateData.owner_user_id = owner_user_id;
if (executor_ids !== undefined) updateData.executor_ids = executor_ids;
if (start_date !== undefined) updateData.start_date = start_date;
if (end_date !== undefined) updateData.end_date = end_date;
if (help_requested_to_user_id !== undefined) {
updateData.help_requested_to_user_id = help_requested_to_user_id;
if (help_requested_to_user_id) {
updateData.help_requested_by_user_id = help_requested_by_user_id || null;
updateData.help_requested_at = new Date().toISOString();
} else {
updateData.help_requested_by_user_id = null;
updateData.help_requested_at = null;
}
}
const { data, error } = await supabase
.from('tasks')
.update(updateData)
.eq('id', params.id)
.select()
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(data);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const access = await canAccessTask(ctx!.user.id, ctx!.isAdmin, ctx!.accessibleProjectIds, params.id);
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
const { error } = await supabase
.from('tasks')
.delete()
.eq('id', params.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}

189
app/api/tasks/route.ts Normal file
View File

@@ -0,0 +1,189 @@
export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAuthenticated, getAuthContext, hasProjectAccess } from '@/lib/access-control';
export async function GET(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const searchParams = request.nextUrl.searchParams;
const kr_id = searchParams.get('kr_id');
const project_id = searchParams.get('project_id');
const user_id = searchParams.get('user_id');
const help_requested_to_user_id = searchParams.get('help_requested_to_user_id');
const include_help = searchParams.get('include_help') === 'true';
if (!ctx!.isAdmin) {
if (user_id && user_id !== ctx!.user.id) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
if (help_requested_to_user_id && help_requested_to_user_id !== ctx!.user.id) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
if (project_id && !hasProjectAccess(ctx!, project_id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
if (kr_id) {
const { data: kr, error: krErr } = await supabase
.from('krs')
.select('id,project_id')
.eq('id', kr_id)
.maybeSingle();
if (krErr) return NextResponse.json({ error: krErr.message }, { status: 500 });
if (!kr) return NextResponse.json({ error: 'KR不存在' }, { status: 404 });
if (!hasProjectAccess(ctx!, kr.project_id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
}
}
const baseQuery = () =>
supabase
.from('tasks')
.select(`
*,
steps:task_steps(*)
`)
.order('created_at', { ascending: false });
const applyParams = (query: ReturnType<typeof baseQuery>) => {
let q = query;
if (kr_id) {
q = q.eq('kr_id', kr_id);
} else if (project_id) {
q = q.eq('project_id', project_id).is('kr_id', null);
}
if (help_requested_to_user_id) {
q = q.eq('help_requested_to_user_id', help_requested_to_user_id);
} else if (user_id) {
if (include_help) {
q = q.or(`owner_user_id.eq.${user_id},help_requested_to_user_id.eq.${user_id}`);
} else {
q = q.eq('owner_user_id', user_id);
}
}
return q;
};
if (ctx!.isAdmin) {
const { data, error } = await applyParams(baseQuery());
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(data);
}
if (project_id || kr_id) {
const { data, error } = await applyParams(baseQuery());
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(data);
}
const relatedTasks = new Map<string, any>();
const addRows = (rows: any[] | null) => {
if (!rows) return;
for (const row of rows) {
relatedTasks.set(row.id, row);
}
};
const projectIds = ctx!.accessibleProjectIds;
const builders: any[] = [];
if (projectIds.length > 0) {
builders.push(applyParams(baseQuery().in('project_id', projectIds)));
}
builders.push(applyParams(baseQuery().or(`owner_user_id.eq.${ctx!.user.id},help_requested_to_user_id.eq.${ctx!.user.id}`)));
builders.push(applyParams(baseQuery().contains('executor_ids', [ctx!.user.id])));
const results = await Promise.all(builders.map(b => b));
for (const r of results) {
if (r.error) {
return NextResponse.json({ error: r.error.message }, { status: 500 });
}
addRows(r.data);
}
return NextResponse.json(Array.from(relatedTasks.values()));
}
export async function POST(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const {
kr_id,
project_id,
title,
description,
status,
priority,
urgency,
owner_user_id,
executor_ids,
start_date,
end_date,
help_requested_to_user_id,
help_requested_by_user_id,
} = body;
const isHelpRequested = Boolean(help_requested_to_user_id);
if (!ctx!.isAdmin) {
if (project_id && !hasProjectAccess(ctx!, project_id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
if (kr_id) {
const { data: kr, error: krErr } = await supabase
.from('krs')
.select('id,project_id')
.eq('id', kr_id)
.maybeSingle();
if (krErr) return NextResponse.json({ error: krErr.message }, { status: 500 });
if (!kr) return NextResponse.json({ error: 'KR不存在' }, { status: 404 });
if (!hasProjectAccess(ctx!, kr.project_id)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
}
}
const { data, error } = await supabase
.from('tasks')
.insert({
kr_id: kr_id || null,
project_id: project_id || null,
title,
description,
status: status || 'todo',
priority: priority || 3,
urgency: urgency || 3,
owner_user_id,
executor_ids: executor_ids || [],
start_date,
end_date,
help_requested_to_user_id: help_requested_to_user_id || null,
help_requested_by_user_id: help_requested_by_user_id || null,
help_requested_at: isHelpRequested ? new Date().toISOString() : null,
})
.select()
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(data);
}

View File

@@ -1,15 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const { data, error } = await supabase
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
let query = supabase
.from('templates')
.select('*')
.eq('id', params.id)
.maybeSingle();
.eq('id', params.id);
if (!ctx!.isAdmin) query = query.eq('is_active', true);
const { data, error } = await query.maybeSingle();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
@@ -26,6 +36,12 @@ export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const { name, description, project_type, tags, items, is_active } = body;
@@ -55,6 +71,12 @@ export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { error } = await supabase
.from('templates')
.delete()

View File

@@ -1,14 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
export async function GET(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const activeOnly = searchParams.get('active_only') === 'true';
const projectType = searchParams.get('project_type');
let query = supabase.from('templates').select('*').order('name', { ascending: true });
if (activeOnly) query = query.eq('is_active', true);
if (!ctx!.isAdmin || activeOnly) query = query.eq('is_active', true);
if (projectType) query = query.eq('project_type', projectType);
const { data, error } = await query;
@@ -21,6 +28,12 @@ export async function GET(request: NextRequest) {
}
export async function POST(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const { name, description, project_type, tags, items } = body;

View File

@@ -1,13 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
if (!ctx!.isAdmin && ctx!.user.id !== params.id) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
const { data, error } = await supabase
.from('users')
.select('*')
.select('id,name,role,avatar_url,username,created_at,updated_at')
.eq('id', params.id)
.maybeSingle();
@@ -26,14 +36,30 @@ export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const { name, role, avatar_url } = body;
const { name, role, avatar_url, username, password } = body;
const updateData: Record<string, unknown> = { updated_at: new Date().toISOString() };
if (name !== undefined) updateData.name = name;
if (role !== undefined) updateData.role = role;
if (avatar_url !== undefined) updateData.avatar_url = avatar_url;
if (username !== undefined) updateData.username = username || null;
if (password) {
const { hashPassword } = await import('@/lib/password');
updateData.password_hash = hashPassword(String(password));
}
const { data, error } = await supabase
.from('users')
.update({ name, role, avatar_url, updated_at: new Date().toISOString() })
.update(updateData)
.eq('id', params.id)
.select()
.select('id,name,role,avatar_url,username,created_at,updated_at')
.single();
if (error) {
@@ -47,6 +73,30 @@ export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { data: user, error: userError } = await supabase
.from('users')
.select('id,role')
.eq('id', params.id)
.maybeSingle();
if (userError) {
return NextResponse.json({ error: userError.message }, { status: 500 });
}
if (!user) {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
}
if (user.role === 'owner') {
return NextResponse.json({ error: '不能删除老板账号' }, { status: 400 });
}
const { error } = await supabase
.from('users')
.delete()

View File

@@ -2,11 +2,54 @@ export const dynamic = 'force-dynamic';
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
import { ensureAdmin, ensureAuthenticated, getAuthContext } from '@/lib/access-control';
export async function GET() {
export async function GET(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAuthenticated(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
if (ctx!.isAdmin) {
const { data, error } = await supabase
.from('users')
.select('id,name,role,avatar_url,username,created_at,updated_at')
.order('created_at', { ascending: true });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(data);
}
const projectIds = ctx!.accessibleProjectIds;
const allowedUserIds = new Set<string>([ctx!.user.id]);
if (projectIds.length > 0) {
const [membersRes, projectsRes] = await Promise.all([
supabase.from('project_members').select('user_id').in('project_id', projectIds),
supabase.from('projects').select('owner_id,pm_id').in('id', projectIds),
]);
if (membersRes.data) {
for (const row of membersRes.data as Array<{ user_id: string }>) {
allowedUserIds.add(row.user_id);
}
}
if (projectsRes.data) {
for (const row of projectsRes.data as Array<{ owner_id: string | null; pm_id: string | null }>) {
if (row.owner_id) allowedUserIds.add(row.owner_id);
if (row.pm_id) allowedUserIds.add(row.pm_id);
}
}
}
const ids = Array.from(allowedUserIds);
const { data, error } = await supabase
.from('users')
.select('*')
.select('id,name,role,avatar_url,username,created_at,updated_at')
.in('id', ids)
.order('created_at', { ascending: true });
if (error) {
@@ -17,17 +60,35 @@ export async function GET() {
}
export async function POST(request: NextRequest) {
const ctx = await getAuthContext(request);
const auth = ensureAdmin(ctx);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const body = await request.json();
const { name, role, avatar_url } = body;
const { name, role, avatar_url, username, password } = body;
if (!name || !role) {
return NextResponse.json({ error: '名称和角色为必填项' }, { status: 400 });
}
let password_hash: string | null = null;
if (password) {
const { hashPassword } = await import('@/lib/password');
password_hash = hashPassword(String(password));
}
const { data, error } = await supabase
.from('users')
.insert({ name, role, avatar_url })
.select()
.insert({
name,
role,
avatar_url,
username: username || null,
password_hash,
})
.select('id,name,role,avatar_url,username,created_at,updated_at')
.single();
if (error) {

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

View File

@@ -7,7 +7,7 @@ import { Toaster } from '@/components/ui/sonner';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'OKR 执行系统 - 目标管理',
title: '兜爸目标管理',
description: '让目标被持续看见、被拆解、被跟进、被纠偏、被完成',
};

80
app/login/page.tsx Normal file
View File

@@ -0,0 +1,80 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { useUser } from '@/components/user-context';
const DEFAULT_USERNAME = 'baicaimiao';
const DEFAULT_PASSWORD = 'baicaimiao123';
export default function LoginPage() {
const router = useRouter();
const { isAuthenticated, isInitialized, isLoading, refreshMe } = useUser();
const [username, setUsername] = useState(DEFAULT_USERNAME);
const [password, setPassword] = useState(DEFAULT_PASSWORD);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (isInitialized && !isLoading && isAuthenticated) {
router.replace('/');
}
}, [isAuthenticated, isInitialized, isLoading, router]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.error || '登录失败');
}
const userId = data?.user?.id as string | undefined;
if (!userId) throw new Error('登录失败');
await refreshMe();
toast.success('登录成功');
router.replace('/');
} catch (err) {
toast.error(err instanceof Error ? err.message : '登录失败');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>登录</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
<div>
<Label htmlFor="username">账号</Label>
<Input id="username" value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div>
<Label htmlFor="password">密码</Label>
<Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<Button type="submit" className="w-full bg-teal-600 hover:bg-teal-700" disabled={isSubmitting}>
{isSubmitting ? '登录中...' : '登录'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -19,10 +19,22 @@ interface KRWithRelations extends KR {
}
export default function MyKRsPage() {
const { currentUser } = useUser();
const { currentUser, users } = useUser();
const [krs, setKRs] = useState<KRWithRelations[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Group KRs by owner for owner view
const krsByOwner = users.map(user => ({
user,
krs: krs.filter(kr => kr.owner_user_id === user.id && isKRActive(kr))
})).filter(group => group.krs.length > 0 || group.user.role !== 'owner') // Filter out users with no active KRs unless it's a member/pm we want to track
.sort((a, b) => {
// Sort by KR count descending (users with KRs first)
if (a.krs.length > 0 && b.krs.length === 0) return -1;
if (a.krs.length === 0 && b.krs.length > 0) return 1;
return 0;
});
useEffect(() => {
async function fetchKRs() {
try {
@@ -46,6 +58,8 @@ export default function MyKRsPage() {
const completedKRs = activeKRs.filter(kr => calculateProgress(kr) >= 1);
const pastKRs = myKRs.filter(kr => !isKRActive(kr));
const isOwner = currentUser?.role === 'owner';
if (isLoading) {
return (
<div className="max-w-5xl mx-auto px-4 py-8">
@@ -64,13 +78,54 @@ export default function MyKRsPage() {
return (
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-800">我的 KR</h1>
<h1 className="text-2xl font-bold text-slate-800">
{isOwner ? '全员 KR 概览' : '我的 KR'}
</h1>
<p className="text-muted-foreground mt-1">
{currentUser?.name} 负责的执行目标
{isOwner
? '查看所有成员的当前周期目标执行情况'
: `${currentUser?.name} 负责的执行目标`
}
</p>
</div>
<div className="grid grid-cols-3 gap-4 mb-8">
{isOwner ? (
<div className="space-y-8">
{krsByOwner.map(({ user, krs }) => (
<div key={user.id} className="space-y-4">
<div className="flex items-center gap-3 border-b pb-2">
<div className="h-8 w-8 rounded-full bg-slate-200 flex items-center justify-center text-sm font-medium text-slate-600">
{user.name[0]}
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800">{user.name}</h2>
<p className="text-xs text-muted-foreground">
{krs.length} 个进行中的 KR
</p>
</div>
</div>
{krs.length === 0 ? (
<div className="p-8 text-center bg-slate-50 rounded-lg border border-dashed">
<p className="text-muted-foreground text-sm">暂无进行中的 KR</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{krs.map(kr => <KRCard key={kr.id} kr={kr} />)}
</div>
)}
</div>
))}
{krsByOwner.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground">没有任何成员有正在进行的 KR</p>
</div>
)}
</div>
) : (
<>
<div className="grid grid-cols-3 gap-4 mb-8">
<Card>
<CardContent className="pt-6 text-center">
<Target className="h-8 w-8 text-slate-300 mx-auto mb-2" />
@@ -142,6 +197,8 @@ export default function MyKRsPage() {
)}
</TabsContent>
</Tabs>
</>
)}
</div>
);
}

485
app/my-tasks/page.tsx Normal file
View File

@@ -0,0 +1,485 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { StatusBadge } from '@/components/status-badge';
import { useUser } from '@/components/user-context';
import { formatDateRange } from '@/lib/kr-utils';
import type { Task, TaskStep, User } from '@/types/database';
import { CheckSquare, Clock, Calendar, AlertTriangle, User as UserIcon, Plus } from 'lucide-react';
import { TaskEditDialog } from '@/components/task-edit-dialog';
import { toast } from 'sonner';
interface TaskWithRelations extends Task {
steps: TaskStep[];
owner: User | null;
// We might want project info too, but the API doesn't return it joined yet.
// We can fetch projects separately or just show task details.
}
interface HelpStepWithRelations extends TaskStep {
task: Pick<Task, 'id' | 'title' | 'project_id' | 'kr_id' | 'owner_user_id' | 'help_requested_to_user_id' | 'help_requested_by_user_id' | 'help_requested_at'> | null;
}
export default function MyTasksPage() {
const { currentUser, users } = useUser();
const [tasks, setTasks] = useState<TaskWithRelations[]>([]);
const [helpSteps, setHelpSteps] = useState<HelpStepWithRelations[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Dialog state
const [isTaskDialogOpen, setIsTaskDialogOpen] = useState(false);
const [editingTask, setEditingTask] = useState<(Task & { steps: TaskStep[] }) | null>(null);
const fetchTasks = async () => {
try {
const isOwnerView = currentUser?.role === 'owner';
const url = isOwnerView
? '/api/tasks'
: `/api/tasks?user_id=${currentUser?.id}&include_help=true`;
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
// Enrich with owner info locally since API doesn't join
const enrichedData = data.map((t: any) => ({
...t,
owner: users.find(u => u.id === t.owner_user_id) || null
}));
setTasks(enrichedData);
}
} catch (error) {
console.error('Failed to fetch tasks:', error);
} finally {
setIsLoading(false);
}
};
const fetchHelpSteps = async () => {
if (!currentUser?.id || currentUser.role === 'owner') return;
try {
const res = await fetch(`/api/task-steps?help_requested_to_user_id=${currentUser.id}`);
if (res.ok) {
const data = await res.json();
setHelpSteps(data);
}
} catch (error) {
console.error('Failed to fetch help steps:', error);
}
};
useEffect(() => {
if (users.length > 0 && currentUser) {
fetchTasks();
fetchHelpSteps();
}
}, [users, currentUser]);
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('更新失败');
}
};
// Group Tasks by owner for owner view
const tasksByOwner = users.map(user => ({
user,
tasks: tasks.filter(t => t.owner_user_id === user.id && t.status !== 'done')
})).filter(group => group.tasks.length > 0 || group.user.role !== 'owner')
.sort((a, b) => {
if (a.tasks.length > 0 && b.tasks.length === 0) return -1;
if (a.tasks.length === 0 && b.tasks.length > 0) return 1;
return 0;
});
const myTasks = tasks.filter(t => t.owner_user_id === currentUser?.id);
const incomingHelpTasks = tasks.filter(t => t.help_requested_to_user_id === currentUser?.id && t.owner_user_id !== currentUser?.id);
const activeTasks = myTasks.filter(t => t.status !== 'done');
const completedTasks = myTasks.filter(t => t.status === 'done');
// "Pending" tasks can be defined as todo/in_progress/expected
const todoTasks = activeTasks.filter(t => t.status === 'todo');
const inProgressTasks = activeTasks.filter(t => t.status === 'in_progress');
const isOwner = currentUser?.role === 'owner';
if (isLoading) {
return (
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-8 bg-slate-200 rounded w-32" />
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-24 bg-slate-200 rounded-lg" />
))}
</div>
</div>
</div>
);
}
return (
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="mb-8 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-slate-800">
{isOwner ? '全员计划概览' : '我的计划'}
</h1>
<p className="text-muted-foreground mt-1">
{isOwner
? '查看所有成员的计划执行情况'
: `${currentUser?.name} 的计划清单`
}
</p>
</div>
{!isOwner && (
<Button onClick={() => {
setEditingTask(null);
setIsTaskDialogOpen(true);
}} className="bg-indigo-600 hover:bg-indigo-700">
<Plus className="h-4 w-4 mr-2" />
新建计划
</Button>
)}
</div>
{isOwner ? (
<div className="space-y-8">
{tasksByOwner.map(({ user, tasks }) => (
<div key={user.id} className="space-y-4">
<div className="flex items-center gap-3 border-b pb-2">
<div className="h-8 w-8 rounded-full bg-slate-200 flex items-center justify-center text-sm font-medium text-slate-600">
{user.name[0]}
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800">{user.name}</h2>
<p className="text-xs text-muted-foreground">
{tasks.length} 个进行中的计划
</p>
</div>
</div>
{tasks.length === 0 ? (
<div className="p-8 text-center bg-slate-50 rounded-lg border border-dashed">
<p className="text-muted-foreground text-sm">暂无进行中的计划</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3">
{tasks.map(task => (
<TaskCard
key={task.id}
task={task}
onStatusChange={handleTaskStatusChange}
onClick={() => {
setEditingTask(task);
setIsTaskDialogOpen(true);
}}
/>
))}
</div>
)}
</div>
))}
{tasksByOwner.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground">没有任何成员有正在进行的计划</p>
</div>
)}
</div>
) : (
<>
<div className="grid grid-cols-3 gap-4 mb-8">
<Card>
<CardContent className="pt-6 text-center">
<CheckSquare className="h-8 w-8 text-slate-300 mx-auto mb-2" />
<p className="text-3xl font-bold text-slate-800">{activeTasks.length}</p>
<p className="text-sm text-muted-foreground">待完成</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<Clock className="h-8 w-8 text-indigo-300 mx-auto mb-2" />
<p className="text-3xl font-bold text-indigo-600">{inProgressTasks.length}</p>
<p className="text-sm text-muted-foreground">进行中</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<AlertTriangle className="h-8 w-8 text-rose-300 mx-auto mb-2" />
<p className="text-3xl font-bold text-rose-600">
{activeTasks.filter(t => t.priority >= 4).length}
</p>
<p className="text-sm text-muted-foreground">高优先级</p>
</CardContent>
</Card>
</div>
<Tabs defaultValue="active" className="space-y-6">
<TabsList>
<TabsTrigger value="active">进行中 ({activeTasks.length})</TabsTrigger>
<TabsTrigger value="help_plans">协助我的计划 ({incomingHelpTasks.length})</TabsTrigger>
<TabsTrigger value="help_steps">协助我的步骤 ({helpSteps.length})</TabsTrigger>
<TabsTrigger value="completed">已完成 ({completedTasks.length})</TabsTrigger>
<TabsTrigger value="all">全部 ({myTasks.length})</TabsTrigger>
</TabsList>
<TabsContent value="active" className="space-y-4">
{activeTasks.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<CheckSquare className="h-12 w-12 text-slate-300 mx-auto mb-4" />
<p className="text-muted-foreground">暂无进行中的计划</p>
</CardContent>
</Card>
) : (
activeTasks.map(task => (
<TaskCard
key={task.id}
task={task}
onStatusChange={handleTaskStatusChange}
onClick={() => {
setEditingTask(task);
setIsTaskDialogOpen(true);
}}
/>
))
)}
</TabsContent>
<TabsContent value="help_plans" className="space-y-4">
{incomingHelpTasks.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<CheckSquare className="h-12 w-12 text-slate-300 mx-auto mb-4" />
<p className="text-muted-foreground">暂无需要你协助的计划</p>
</CardContent>
</Card>
) : (
incomingHelpTasks.map(task => (
<TaskCard
key={task.id}
task={task}
onStatusChange={handleTaskStatusChange}
onClick={() => {
setEditingTask(task);
setIsTaskDialogOpen(true);
}}
variant="help"
/>
))
)}
</TabsContent>
<TabsContent value="help_steps" className="space-y-4">
{helpSteps.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<CheckSquare className="h-12 w-12 text-slate-300 mx-auto mb-4" />
<p className="text-muted-foreground">暂无需要你协助的步骤</p>
</CardContent>
</Card>
) : (
helpSteps.map(step => (
<Card
key={step.id}
className="border-amber-200 bg-amber-50/40 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => {
if (step.task) {
const task = tasks.find(t => t.id === step.task?.id);
if (task) {
setEditingTask(task);
setIsTaskDialogOpen(true);
}
}
}}
>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded border border-amber-200">
协助步骤
</span>
{step.help_requested_by_user_id && (
<span className="text-xs text-muted-foreground">
发起人:{users.find(u => u.id === step.help_requested_by_user_id)?.name || '未知'}
</span>
)}
</div>
<p className="font-medium text-slate-800 truncate">{step.content}</p>
{step.task?.title && (
<p className="text-sm text-muted-foreground mt-1 truncate">
来自计划:{step.task.title}
</p>
)}
<div className="text-xs text-muted-foreground mt-2 flex items-center gap-2">
<Calendar className="h-3 w-3" />
{formatDateRange(step.start_date || '', step.end_date || '')}
</div>
</div>
<div className="shrink-0">
<StatusBadge status={step.is_completed ? 'green' : 'yellow'} size="sm" text={step.is_completed ? '已完成' : '待协助'} />
</div>
</div>
</CardContent>
</Card>
))
)}
</TabsContent>
<TabsContent value="completed" className="space-y-4">
{completedTasks.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<CheckSquare className="h-12 w-12 text-slate-300 mx-auto mb-4" />
<p className="text-muted-foreground">暂无已完成的计划</p>
</CardContent>
</Card>
) : (
completedTasks.map(task => (
<TaskCard
key={task.id}
task={task}
onStatusChange={handleTaskStatusChange}
onClick={() => {
setEditingTask(task);
setIsTaskDialogOpen(true);
}}
/>
))
)}
</TabsContent>
<TabsContent value="all" className="space-y-4">
{myTasks.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<CheckSquare className="h-12 w-12 text-slate-300 mx-auto mb-4" />
<p className="text-muted-foreground">暂无计划</p>
</CardContent>
</Card>
) : (
myTasks.map(task => (
<TaskCard
key={task.id}
task={task}
onStatusChange={handleTaskStatusChange}
onClick={() => {
setEditingTask(task);
setIsTaskDialogOpen(true);
}}
/>
))
)}
</TabsContent>
</Tabs>
</>
)}
<TaskEditDialog
open={isTaskDialogOpen}
onOpenChange={setIsTaskDialogOpen}
projectId="" // Global task or we assume user selects project in dialog if we enable it?
// Wait, TaskEditDialog requires projectId for context.
// If we create task from here, it might not belong to a project initially or we need to select one.
// But the dialog currently assumes projectId is passed for display context "Project: xxx".
// Let's pass a placeholder or modify dialog to allow selecting project if not provided.
// For now, let's pass empty string and "个人任务" or similar.
projectTitle="个人/全局计划"
task={editingTask}
onSuccess={fetchTasks}
/>
</div>
);
}
function TaskCard({ task, onStatusChange, onClick, variant }: { task: TaskWithRelations; onStatusChange: (id: string, status: string) => void; onClick: () => void; variant?: 'normal' | 'help' }) {
const { currentUser, users } = useUser();
const isDone = task.status === 'done';
const isIncomingHelp = variant === 'help' || (task.help_requested_to_user_id && task.help_requested_to_user_id === currentUser?.id && task.owner_user_id !== currentUser?.id);
const isOutgoingHelp = task.help_requested_to_user_id && task.owner_user_id === currentUser?.id;
const requesterName = task.help_requested_by_user_id ? (users.find(u => u.id === task.help_requested_by_user_id)?.name || '未知') : null;
const helperName = task.help_requested_to_user_id ? (users.find(u => u.id === task.help_requested_to_user_id)?.name || '未知') : null;
return (
<Card
className={`hover:shadow-md transition-shadow cursor-pointer ${isDone ? 'opacity-70 bg-slate-50' : ''} ${isIncomingHelp ? 'border-amber-200 bg-amber-50/40' : ''} ${isOutgoingHelp ? 'border-indigo-200 bg-indigo-50/40' : ''}`}
onClick={onClick}
>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div
className="mt-1"
onClick={(e) => {
e.stopPropagation();
onStatusChange(task.id, isDone ? 'todo' : 'done');
}}
>
<div className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${isDone ? 'bg-indigo-600 border-indigo-600' : 'border-slate-300 hover:border-indigo-500'}`}>
{isDone && <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">
<h3 className={`font-medium text-slate-800 truncate ${isDone ? 'line-through text-slate-500' : ''}`}>
{task.title}
</h3>
<StatusBadge
status={task.status === 'done' ? 'green' : (task.status === 'in_progress' ? 'yellow' : 'red')}
size="sm"
text={task.status === 'done' ? '已完成' : (task.status === 'in_progress' ? '进行中' : '待办')}
/>
</div>
{(isIncomingHelp || isOutgoingHelp) && (
<div className="flex items-center gap-2 mb-2">
<span className={`text-xs px-2 py-0.5 rounded border ${isIncomingHelp ? 'bg-amber-100 text-amber-800 border-amber-200' : 'bg-indigo-100 text-indigo-800 border-indigo-200'}`}>
{isIncomingHelp ? '协助请求' : '已请求协助'}
</span>
{isIncomingHelp && requesterName && (
<span className="text-xs text-muted-foreground">发起人:{requesterName}</span>
)}
{isOutgoingHelp && helperName && (
<span className="text-xs text-muted-foreground">协助人:{helperName}</span>
)}
</div>
)}
<div className="flex items-center gap-3 text-sm text-muted-foreground">
{task.owner && (
<span className="flex items-center gap-1">
<UserIcon className="h-3 w-3" />
{task.owner.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 text-xs">
{task.steps.filter(s => s.is_completed).length}/{task.steps.length} 步骤
</span>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -96,7 +96,7 @@ export default function DashboardPage() {
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-800">目标驾驶舱</h1>
<h1 className="text-2xl font-bold text-slate-800">目标总览</h1>
<p className="text-muted-foreground mt-1">
{currentUser?.role === 'owner' ? '全局视角 - 所有项目健康度一览' : `${ROLE_LABELS[currentUser?.role || 'member']}视角`}
</p>

View File

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

View File

@@ -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 />

View File

@@ -2,26 +2,20 @@
import { useState, useEffect, ReactNode } from 'react';
import { UserProvider, useUser } from './user-context';
import { UserSwitcher } from './user-switcher';
import { MainNav } from './main-nav';
import { InitWizard } from './init-wizard';
import { Loader2 } from 'lucide-react';
import { usePathname, useRouter } from 'next/navigation';
function AppContent({ children }: { children: ReactNode }) {
const { isLoading, isInitialized, users, refreshUsers } = useUser();
const [showWizard, setShowWizard] = useState(false);
const { isLoading, isInitialized, isAuthenticated } = useUser();
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
if (!isLoading && users.length === 0) {
setShowWizard(true);
if (!isLoading && isInitialized && !isAuthenticated && pathname !== '/login') {
router.replace('/login');
}
}, [isLoading, users.length]);
const handleInitComplete = async () => {
await refreshUsers();
setShowWizard(false);
window.location.reload();
};
}, [isAuthenticated, isInitialized, isLoading, pathname, router]);
if (isLoading) {
return (
@@ -34,10 +28,6 @@ function AppContent({ children }: { children: ReactNode }) {
);
}
if (showWizard) {
return <InitWizard onComplete={handleInitComplete} />;
}
if (!isInitialized) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -49,13 +39,27 @@ function AppContent({ children }: { children: ReactNode }) {
);
}
if (pathname === '/login') {
return <>{children}</>;
}
if (!isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-teal-600" />
<p className="text-sm text-muted-foreground">跳转登录...</p>
</div>
</div>
);
}
return (
<>
<MainNav />
<main className="pt-14 pb-24 min-h-screen bg-slate-50">
{children}
</main>
<UserSwitcher />
</>
);
}

View File

@@ -48,7 +48,7 @@ export function InitWizard({ onComplete }: InitWizardProps) {
<div className="mx-auto w-16 h-16 bg-teal-100 rounded-full flex items-center justify-center">
<Target className="h-8 w-8 text-teal-600" />
</div>
<CardTitle className="text-2xl">欢迎使用 OKR 执行系统</CardTitle>
<CardTitle className="text-2xl">欢迎使用 兜爸目标管理</CardTitle>
<CardDescription className="text-base">
系统检测到数据库为空,点击下方按钮初始化演示数据,开始体验完整功能
</CardDescription>

View File

@@ -4,6 +4,8 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { useUser } from './user-context';
import { Button } from '@/components/ui/button';
import { useRouter } from 'next/navigation';
import {
LayoutDashboard,
FolderKanban,
@@ -13,12 +15,14 @@ import {
Users,
Gauge,
Files,
CheckSquare,
} from 'lucide-react';
const mainNavItems = [
{ href: '/', label: '驾驶舱', icon: LayoutDashboard },
{ href: '/', label: '总览', icon: LayoutDashboard },
{ href: '/projects', label: '项目', icon: FolderKanban },
{ href: '/my-krs', label: '我的KR', icon: Target },
{ href: '/my-tasks', label: '我的计划', icon: CheckSquare },
{ href: '/meeting', label: '例会', icon: CalendarClock },
{ href: '/analysis', label: '分析', icon: BarChart3 },
];
@@ -31,7 +35,8 @@ const adminNavItems = [
export function MainNav() {
const pathname = usePathname();
const { currentUser } = useUser();
const router = useRouter();
const { currentUser, logout } = useUser();
const isOwner = currentUser?.role === 'owner';
return (
@@ -41,7 +46,7 @@ export function MainNav() {
<div className="flex items-center gap-6">
<Link href="/" className="flex items-center gap-2 font-bold text-lg">
<Target className="h-6 w-6 text-teal-600" />
<span className="text-slate-800">OKR 执行系统</span>
<span className="text-slate-800">兜爸目标管理</span>
</Link>
<div className="hidden md:flex items-center gap-1">
{mainNavItems.map((item) => {
@@ -67,30 +72,46 @@ export function MainNav() {
</div>
</div>
{isOwner && (
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground mr-2 hidden lg:block">管理</span>
{adminNavItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-slate-800 text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
)}
>
<Icon className="h-4 w-4" />
<span className="hidden lg:inline">{item.label}</span>
</Link>
);
})}
<div className="flex items-center gap-3">
{isOwner && (
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground mr-2 hidden lg:block">管理</span>
{adminNavItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-slate-800 text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
)}
>
<Icon className="h-4 w-4" />
<span className="hidden lg:inline">{item.label}</span>
</Link>
);
})}
</div>
)}
<div className="flex items-center gap-2">
<span className="text-sm text-slate-700 hidden sm:inline">{currentUser?.name}</span>
<Button
variant="outline"
size="sm"
onClick={() => {
logout();
router.replace('/login');
}}
>
退出
</Button>
</div>
)}
</div>
</div>
</div>
</nav>

View File

@@ -8,9 +8,10 @@ interface StatusBadgeProps {
status: ProjectStatus;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
text?: string;
}
export function StatusBadge({ status, size = 'md', showLabel = true }: StatusBadgeProps) {
export function StatusBadge({ status, size = 'md', showLabel = true, text }: StatusBadgeProps) {
const colors = STATUS_COLORS[status];
const sizeClasses = {
sm: 'h-2 w-2',
@@ -23,7 +24,7 @@ export function StatusBadge({ status, size = 'md', showLabel = true }: StatusBad
<div className={cn('rounded-full', sizeClasses[size], colors.bg, 'ring-2', colors.border.replace('border', 'ring'))} />
{showLabel && (
<span className={cn('text-sm font-medium', colors.text)}>
{colors.label}
{text || colors.label}
</span>
)}
</div>

View File

@@ -0,0 +1,741 @@
'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';
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 fetch(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 =>
fetch('/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 fetch(`/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 fetch('/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 fetch('/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 fetch(`/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>
);
}

View File

@@ -8,8 +8,11 @@ interface UserContextType {
users: User[];
isLoading: boolean;
isInitialized: boolean;
isAuthenticated: boolean;
setCurrentUserId: (id: string) => void;
logout: () => void;
refreshUsers: () => Promise<void>;
refreshMe: () => Promise<User | null>;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
@@ -19,6 +22,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const refreshUsers = useCallback(async () => {
try {
@@ -34,43 +38,82 @@ export function UserProvider({ children }: { children: ReactNode }) {
return [];
}, []);
const refreshMe = useCallback(async () => {
try {
const meRes = await fetch('/api/auth/me', { cache: 'no-store' });
const meData = await meRes.json().catch(() => ({}));
const me = meData?.user as User | null | undefined;
if (me && me.id) {
setCurrentUser(me);
setIsAuthenticated(true);
localStorage.setItem('okrAuthUserId', me.id);
localStorage.setItem('currentUserId', me.id);
await refreshUsers();
return me;
}
localStorage.removeItem('okrAuthUserId');
localStorage.removeItem('currentUserId');
setCurrentUser(null);
setIsAuthenticated(false);
setUsers([]);
return null;
} catch (error) {
console.error('Failed to refresh auth:', error);
localStorage.removeItem('okrAuthUserId');
localStorage.removeItem('currentUserId');
setCurrentUser(null);
setIsAuthenticated(false);
setUsers([]);
return null;
}
}, [refreshUsers]);
useEffect(() => {
async function init() {
setIsLoading(true);
const fetchedUsers = await refreshUsers();
if (fetchedUsers.length > 0) {
const storedId = localStorage.getItem('currentUserId');
const foundUser = storedId
? fetchedUsers.find((u: User) => u.id === storedId)
: null;
if (foundUser) {
setCurrentUser(foundUser);
} else {
const owner = fetchedUsers.find((u: User) => u.role === 'owner') || fetchedUsers[0];
setCurrentUser(owner);
localStorage.setItem('currentUserId', owner.id);
}
try {
await refreshMe();
} catch (error) {
console.error('Failed to initialize auth:', error);
localStorage.removeItem('okrAuthUserId');
localStorage.removeItem('currentUserId');
setCurrentUser(null);
setIsAuthenticated(false);
setUsers([]);
} finally {
setIsInitialized(true);
setIsLoading(false);
}
setIsLoading(false);
}
init();
}, [refreshUsers]);
}, [refreshMe]);
const setCurrentUserId = useCallback((id: string) => {
const authUserId = localStorage.getItem('okrAuthUserId');
if (!authUserId) return;
if (authUserId !== id) return;
const user = users.find(u => u.id === id);
if (user) {
setCurrentUser(user);
localStorage.setItem('currentUserId', id);
setIsAuthenticated(true);
}
}, [users]);
const logout = useCallback(() => {
localStorage.removeItem('okrAuthUserId');
localStorage.removeItem('currentUserId');
setCurrentUser(null);
setIsAuthenticated(false);
setUsers([]);
fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
}, []);
return (
<UserContext.Provider value={{ currentUser, users, isLoading, isInitialized, setCurrentUserId, refreshUsers }}>
<UserContext.Provider value={{ currentUser, users, isLoading, isInitialized, isAuthenticated, setCurrentUserId, logout, refreshUsers, refreshMe }}>
{children}
</UserContext.Provider>
);

73
lib/access-control.ts Normal file
View File

@@ -0,0 +1,73 @@
import type { NextRequest } from 'next/server';
import { supabase } from '@/lib/supabase';
import type { User } from '@/types/database';
export type AuthContext = {
user: User;
isAdmin: boolean;
accessibleProjectIds: string[];
};
export async function getAuthContext(request: NextRequest): Promise<AuthContext | null> {
const userId = request.cookies.get('okr_user_id')?.value;
if (!userId) return null;
const { data: user, error: userError } = await supabase
.from('users')
.select('id,name,role,avatar_url,username,created_at,updated_at')
.eq('id', userId)
.maybeSingle();
if (userError || !user) return null;
const isAdmin = user.role === 'owner';
const accessibleProjectIds = await getAccessibleProjectIds(user.id, isAdmin);
return { user, isAdmin, accessibleProjectIds };
}
export async function getAccessibleProjectIds(userId: string, isAdmin: boolean) {
if (isAdmin) return [];
const [projectsRes, memberRes] = await Promise.all([
supabase
.from('projects')
.select('id')
.or(`owner_id.eq.${userId},pm_id.eq.${userId}`),
supabase
.from('project_members')
.select('project_id')
.eq('user_id', userId),
]);
const ids = new Set<string>();
if (projectsRes.data) {
for (const row of projectsRes.data as Array<{ id: string }>) ids.add(row.id);
}
if (memberRes.data) {
for (const row of memberRes.data as Array<{ project_id: string }>) ids.add(row.project_id);
}
return Array.from(ids);
}
export function ensureAuthenticated(ctx: AuthContext | null) {
if (!ctx) {
return { ok: false as const, status: 401, error: '未登录' };
}
return { ok: true as const };
}
export function ensureAdmin(ctx: AuthContext | null) {
if (!ctx) {
return { ok: false as const, status: 401, error: '未登录' };
}
if (!ctx.isAdmin) {
return { ok: false as const, status: 403, error: '无权限' };
}
return { ok: true as const };
}
export function hasProjectAccess(ctx: AuthContext, projectId: string | null | undefined) {
if (ctx.isAdmin) return true;
if (!projectId) return false;
return ctx.accessibleProjectIds.includes(projectId);
}

25
lib/password.ts Normal file
View File

@@ -0,0 +1,25 @@
import crypto from 'crypto';
export function hashPassword(password: string) {
const salt = crypto.randomBytes(16).toString('hex');
const iterations = 120000;
const keylen = 32;
const digest = 'sha256';
const derivedKey = crypto.pbkdf2Sync(password, salt, iterations, keylen, digest).toString('hex');
return `${iterations}:${salt}:${derivedKey}`;
}
export function verifyPassword(password: string, stored: string) {
const parts = stored.split(':');
if (parts.length !== 3) return false;
const iterations = Number(parts[0]);
const salt = parts[1];
const expectedHex = parts[2];
if (!Number.isFinite(iterations) || iterations <= 0) return false;
const keylen = Buffer.from(expectedHex, 'hex').length;
if (keylen <= 0) return false;
const actual = crypto.pbkdf2Sync(password, salt, iterations, keylen, 'sha256');
const expected = Buffer.from(expectedHex, 'hex');
if (actual.length !== expected.length) return false;
return crypto.timingSafeEqual(actual, expected);
}

View File

@@ -1,9 +1,17 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone', // 输出独立的目录
basePath: '/ok-r', // 部署到子路径时需要设置
eslint: {
ignoreDuringBuilds: true,
},
images: { unoptimized: true },
webpack: (config, { dev }) => {
if (dev) {
config.cache = false;
}
return config;
},
};
module.exports = nextConfig;

5746
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
-- Create tasks table
CREATE TABLE IF NOT EXISTS tasks (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
kr_id UUID NOT NULL REFERENCES krs(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT, -- 思路
status TEXT NOT NULL DEFAULT 'todo', -- todo, in_progress, expected, done
priority INTEGER DEFAULT 50, -- 重要级 0-100
urgency INTEGER DEFAULT 50, -- 紧急程度 0-100
owner_user_id UUID REFERENCES users(id), -- 负责人
executor_ids UUID[] DEFAULT '{}', -- 执行人列表
start_date DATE,
end_date DATE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Create task_steps table
CREATE TABLE IF NOT EXISTS task_steps (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
content TEXT NOT NULL,
note TEXT,
executor_id UUID REFERENCES users(id),
start_date DATE,
end_date DATE,
is_completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Add indexes
CREATE INDEX IF NOT EXISTS idx_tasks_kr_id ON tasks(kr_id);
CREATE INDEX IF NOT EXISTS idx_task_steps_task_id ON task_steps(task_id);
-- Enable RLS
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE task_steps ENABLE ROW LEVEL SECURITY;
-- RLS Policies for tasks
CREATE POLICY "Users can view tasks of visible KRs" ON tasks
FOR SELECT USING (
EXISTS (
SELECT 1 FROM krs
WHERE krs.id = tasks.kr_id
AND (
-- KR visibility logic (simplified for now: if you can see the project, you can see the KR)
EXISTS (
SELECT 1 FROM projects WHERE projects.id = krs.project_id
)
)
)
);
CREATE POLICY "Users can insert tasks" ON tasks
FOR INSERT WITH CHECK (true); -- Allow all authenticated users to create tasks for now
CREATE POLICY "Users can update tasks" ON tasks
FOR UPDATE USING (true);
CREATE POLICY "Users can delete tasks" ON tasks
FOR DELETE USING (true);
-- RLS Policies for task_steps
CREATE POLICY "Users can view steps" ON task_steps FOR SELECT USING (true);
CREATE POLICY "Users can insert steps" ON task_steps FOR INSERT WITH CHECK (true);
CREATE POLICY "Users can update steps" ON task_steps FOR UPDATE USING (true);
CREATE POLICY "Users can delete steps" ON task_steps FOR DELETE USING (true);

View File

@@ -0,0 +1,10 @@
-- Add project_id column to tasks table and make kr_id nullable
ALTER TABLE tasks
ADD COLUMN project_id UUID REFERENCES projects(id) ON DELETE CASCADE;
-- Make kr_id nullable since tasks can now belong directly to a project
ALTER TABLE tasks
ALTER COLUMN kr_id DROP NOT NULL;
-- Create index for project_id for better query performance
CREATE INDEX idx_tasks_project_id ON tasks(project_id);

View File

@@ -0,0 +1,56 @@
alter table public.tasks
add column if not exists help_requested_to_user_id uuid null,
add column if not exists help_requested_by_user_id uuid null,
add column if not exists help_requested_at timestamptz null;
alter table public.task_steps
add column if not exists help_requested_to_user_id uuid null,
add column if not exists help_requested_by_user_id uuid null,
add column if not exists help_requested_at timestamptz null;
do $$
begin
if not exists (
select 1 from pg_constraint where conname = 'tasks_help_requested_to_user_id_fkey'
) then
alter table public.tasks
add constraint tasks_help_requested_to_user_id_fkey
foreign key (help_requested_to_user_id) references public.users(id);
end if;
if not exists (
select 1 from pg_constraint where conname = 'tasks_help_requested_by_user_id_fkey'
) then
alter table public.tasks
add constraint tasks_help_requested_by_user_id_fkey
foreign key (help_requested_by_user_id) references public.users(id);
end if;
if not exists (
select 1 from pg_constraint where conname = 'task_steps_help_requested_to_user_id_fkey'
) then
alter table public.task_steps
add constraint task_steps_help_requested_to_user_id_fkey
foreign key (help_requested_to_user_id) references public.users(id);
end if;
if not exists (
select 1 from pg_constraint where conname = 'task_steps_help_requested_by_user_id_fkey'
) then
alter table public.task_steps
add constraint task_steps_help_requested_by_user_id_fkey
foreign key (help_requested_by_user_id) references public.users(id);
end if;
end $$;
create index if not exists tasks_help_requested_to_user_id_idx
on public.tasks(help_requested_to_user_id);
create index if not exists tasks_help_requested_by_user_id_idx
on public.tasks(help_requested_by_user_id);
create index if not exists task_steps_help_requested_to_user_id_idx
on public.task_steps(help_requested_to_user_id);
create index if not exists task_steps_help_requested_by_user_id_idx
on public.task_steps(help_requested_by_user_id);

View File

@@ -0,0 +1,15 @@
alter table public.users
add column if not exists username text null,
add column if not exists password_hash text null;
do $$
begin
if not exists (
select 1
from pg_indexes
where schemaname = 'public'
and indexname = 'users_username_unique'
) then
create unique index users_username_unique on public.users (username) where username is not null;
end if;
end $$;

View File

@@ -0,0 +1,2 @@
alter table public.tasks disable row level security;
alter table public.task_steps disable row level security;

View File

@@ -9,6 +9,7 @@ export interface User {
name: string;
role: UserRole;
avatar_url: string | null;
username: string | null;
created_at: string;
updated_at: string;
}
@@ -126,6 +127,41 @@ export interface Template {
updated_at: string;
}
export interface Task {
id: string;
kr_id: string | null;
project_id: string | null;
title: string;
description: string | null;
status: 'todo' | 'in_progress' | 'expected' | 'done';
priority: number;
urgency: number;
owner_user_id: string | null;
executor_ids: string[];
start_date: string | null;
end_date: string | null;
help_requested_to_user_id: string | null;
help_requested_by_user_id: string | null;
help_requested_at: string | null;
created_at: string;
updated_at: string;
}
export interface TaskStep {
id: string;
task_id: string;
content: string;
note: string | null;
executor_id: string | null;
start_date: string | null;
end_date: string | null;
is_completed: boolean;
help_requested_to_user_id: string | null;
help_requested_by_user_id: string | null;
help_requested_at: string | null;
created_at: string;
}
export interface Database {
public: {
Tables: {
@@ -174,6 +210,16 @@ export interface Database {
Insert: Omit<Template, 'id' | 'created_at' | 'updated_at'> & { id?: string };
Update: Partial<Omit<Template, 'id' | 'created_at'>>;
};
tasks: {
Row: Task;
Insert: Omit<Task, 'id' | 'created_at' | 'updated_at'> & { id?: string };
Update: Partial<Omit<Task, 'id' | 'created_at'>>;
};
task_steps: {
Row: TaskStep;
Insert: Omit<TaskStep, 'id' | 'created_at'> & { id?: string };
Update: Partial<Omit<TaskStep, 'id' | 'created_at'>>;
};
};
};
}