Files
ok_r/app/admin/metrics/page.tsx
2026-01-25 21:55:29 +08:00

333 lines
11 KiB
TypeScript

'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';
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 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 handleDelete = async (metricId: string) => {
if (!confirm('确定要删除此指标吗?')) return;
try {
const res = await fetch(`/api/metrics/${metricId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('删除失败');
toast.success('指标已删除');
fetchMetrics();
} catch (error) {
toast.error(getErrorMessage(error));
}
};
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={() => handleDelete(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>
</div>
);
}