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

376 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Switch } from '@/components/ui/switch';
import { useUser } from '@/components/user-context';
import { DIRECTION_LABELS } from '@/lib/constants';
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, '名称不能为空'),
unit: z.string().min(1, '单位不能为空'),
direction: z.enum(['increase', 'decrease']),
category: z.string().optional(),
});
type MetricFormValues = z.infer<typeof metricSchema>;
export default function AdminMetricsPage() {
const { currentUser } = useUser();
const [metrics, setMetrics] = useState<Metric[]>([]);
const [isLoading, setIsLoading] = useState(true);
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),
defaultValues: {
name: '',
unit: '',
direction: 'increase',
category: '',
},
});
const fetchMetrics = async () => {
try {
const res = await fetch('/api/metrics');
if (res.ok) {
const data = await res.json();
setMetrics(data);
}
} catch (error) {
console.error('Failed to fetch metrics:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchMetrics();
}, []);
const onSubmit = async (values: MetricFormValues) => {
setIsSubmitting(true);
try {
const url = editingMetric ? `/api/metrics/${editingMetric.id}` : '/api/metrics';
const method = editingMetric ? 'PATCH' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || '操作失败');
}
toast.success(editingMetric ? '指标已更新' : '指标已创建');
setIsDialogOpen(false);
setEditingMetric(null);
form.reset();
fetchMetrics();
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (metric: Metric) => {
setEditingMetric(metric);
form.reset({
name: metric.name,
unit: metric.unit,
direction: metric.direction,
category: metric.category || '',
});
setIsDialogOpen(true);
};
const handleToggleActive = async (metric: Metric) => {
try {
const res = await fetch(`/api/metrics/${metric.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !metric.is_active }),
});
if (!res.ok) throw new Error('操作失败');
toast.success(metric.is_active ? '已停用' : '已启用');
fetchMetrics();
} catch (error) {
toast.error(getErrorMessage(error));
}
};
const requestDelete = (metricId: string) => {
setDeleteMetricId(metricId);
setIsDeleteOpen(true);
};
const confirmDelete = async () => {
if (!deleteMetricId) return;
setIsDeleting(true);
try {
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);
}
};
const handleDialogClose = () => {
setIsDialogOpen(false);
setEditingMetric(null);
form.reset();
};
if (currentUser?.role !== 'owner') {
return (
<div className="max-w-4xl mx-auto px-4 py-8 text-center">
<p className="text-muted-foreground">仅老板可访问此页面</p>
</div>
);
}
if (isLoading) {
return (
<div className="max-w-4xl 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="h-64 bg-slate-200 rounded-lg" />
</div>
</div>
);
}
const categories = Array.from(new Set(metrics.map(m => m.category).filter(Boolean)));
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
<Gauge className="h-6 w-6 text-teal-600" />
指标库管理
</h1>
<p className="text-muted-foreground mt-1">管理可复用的 KR 指标</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="bg-teal-600 hover:bg-teal-700">
<Plus className="h-4 w-4 mr-2" />
新建指标
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingMetric ? '编辑指标' : '新建指标'}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>指标名称 *</FormLabel>
<FormControl>
<Input placeholder="如:页面加载时间" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="unit"
render={({ field }) => (
<FormItem>
<FormLabel>单位 *</FormLabel>
<FormControl>
<Input placeholder="如:秒、个、%" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="direction"
render={({ field }) => (
<FormItem>
<FormLabel>方向</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(DIRECTION_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>分类</FormLabel>
<FormControl>
<Input placeholder="如:技术性能、业务" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2 pt-4">
<Button type="submit" disabled={isSubmitting} className="bg-teal-600 hover:bg-teal-700">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{editingMetric ? '保存' : '创建'}
</Button>
<Button type="button" variant="outline" onClick={handleDialogClose}>
取消
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>指标名称</TableHead>
<TableHead>单位</TableHead>
<TableHead>方向</TableHead>
<TableHead>分类</TableHead>
<TableHead>状态</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{metrics.map(metric => (
<TableRow key={metric.id} className={!metric.is_active ? 'opacity-50' : ''}>
<TableCell className="font-medium">{metric.name}</TableCell>
<TableCell>{metric.unit}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{metric.direction === 'increase' ? (
<TrendingUp className="h-4 w-4 text-emerald-500" />
) : (
<TrendingDown className="h-4 w-4 text-rose-500" />
)}
{DIRECTION_LABELS[metric.direction]}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{metric.category || '-'}
</TableCell>
<TableCell>
<Switch
checked={metric.is_active}
onCheckedChange={() => handleToggleActive(metric)}
/>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => handleEdit(metric)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => requestDelete(metric.id)}>
<Trash2 className="h-4 w-4 text-rose-500" />
</Button>
</TableCell>
</TableRow>
))}
{metrics.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
暂无指标
</TableCell>
</TableRow>
)}
</TableBody>
</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>
);
}