feat: 实现用户登录认证与权限控制系统

- 新增用户登录页面与API端点(/api/auth/login、/api/auth/logout、/api/auth/me)
- 引入基于密码哈希的认证机制,支持默认管理员账号登录
- 创建访问控制模块(lib/access-control.ts),实现基于角色的权限验证
- 在现有API端点中集成权限检查,限制非管理员用户只能访问其所属项目数据
- 更新应用布局,要求用户登录后才能访问主界面
- 扩展用户表结构,增加用户名和密码哈希字段
- 调整项目、KR、目标、会议记录等资源的查询逻辑,根据用户权限过滤数据
- 改进管理页面(指标、模板)的删除操作,使用确认对话框替代直接确认
- 优化“我的KR”页面,为管理员提供全员KR概览视图
- 修复.gitignore文件中的拼写错误(.trae → .travis)
- 添加数据库迁移脚本,支持任务直接关联项目、禁用RLS、添加求助字段等
- 配置Next.js以支持独立输出和子路径部署
This commit is contained in:
2026-01-28 12:57:36 +08:00
parent e3e5e89df6
commit 7948b1afc7
50 changed files with 9506 additions and 150 deletions

View File

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