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

437 lines
16 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Switch } from '@/components/ui/switch';
import { useUser } from '@/components/user-context';
import { DIRECTION_LABELS } from '@/lib/constants';
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';
const templateItemSchema = z.object({
title: z.string().min(1, '标题不能为空'),
metricName: z.string().min(1, '指标名称不能为空'),
unit: z.string().min(1, '单位不能为空'),
direction: z.enum(['increase', 'decrease']),
targetValue: z.number().positive('目标值必须大于0'),
});
const templateSchema = z.object({
name: z.string().min(1, '名称不能为空'),
description: z.string().optional(),
project_type: z.string().optional(),
items: z.array(templateItemSchema).min(1, '至少需要一个模板项'),
});
type TemplateFormValues = z.infer<typeof templateSchema>;
export default function AdminTemplatesPage() {
// Force re-compile
const { currentUser } = useUser();
const [templates, setTemplates] = useState<Template[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<Template | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<TemplateFormValues>({
resolver: zodResolver(templateSchema),
defaultValues: {
name: '',
description: '',
project_type: '',
items: [{ title: '', metricName: '', unit: '', direction: 'increase', targetValue: 0 }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'items',
});
const fetchTemplates = async () => {
try {
const res = await fetch('/api/templates');
if (res.ok) {
const data = await res.json();
setTemplates(data);
}
} catch (error) {
console.error('Failed to fetch templates:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchTemplates();
}, []);
const onSubmit = async (values: TemplateFormValues) => {
setIsSubmitting(true);
try {
const url = editingTemplate ? `/api/templates/${editingTemplate.id}` : '/api/templates';
const method = editingTemplate ? '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(editingTemplate ? '模板已更新' : '模板已创建');
setIsDialogOpen(false);
setEditingTemplate(null);
form.reset();
fetchTemplates();
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (template: Template) => {
setEditingTemplate(template);
form.reset({
name: template.name,
description: template.description || '',
project_type: template.project_type || '',
items: template.items,
});
setIsDialogOpen(true);
};
const handleToggleActive = async (template: Template) => {
try {
const res = await fetch(`/api/templates/${template.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !template.is_active }),
});
if (!res.ok) throw new Error('操作失败');
toast.success(template.is_active ? '已停用' : '已启用');
fetchTemplates();
} catch (error) {
toast.error(getErrorMessage(error));
}
};
const handleDelete = async (templateId: string) => {
if (!confirm('确定要删除此模板吗?')) return;
try {
const res = await fetch(`/api/templates/${templateId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('删除失败');
toast.success('模板已删除');
fetchTemplates();
} catch (error) {
toast.error(getErrorMessage(error));
}
};
const handleDialogClose = () => {
setIsDialogOpen(false);
setEditingTemplate(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-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="grid grid-cols-1 md:grid-cols-2 gap-6">
{[1, 2].map(i => (
<div key={i} className="h-48 bg-slate-200 rounded-lg" />
))}
</div>
</div>
</div>
);
}
return (
<div className="max-w-5xl 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">
<Files 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 className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingTemplate ? '编辑模板' : '新建模板'}</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="description"
render={({ field }) => (
<FormItem>
<FormLabel>描述</FormLabel>
<FormControl>
<Textarea placeholder="模板描述" rows={2} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="project_type"
render={({ field }) => (
<FormItem>
<FormLabel>适用项目类型</FormLabel>
<FormControl>
<Input placeholder="如:产品研发" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-4">
<FormLabel>模板项 *</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ title: '', metricName: '', unit: '', direction: 'increase', targetValue: 0 })}
>
<Plus className="h-4 w-4 mr-1" />
添加项
</Button>
</div>
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="p-4 bg-slate-50 rounded-lg relative">
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-2 right-2"
onClick={() => remove(index)}
>
<X className="h-4 w-4" />
</Button>
)}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name={`items.${index}.title`}
render={({ field }) => (
<FormItem>
<FormLabel>KR 标题</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`items.${index}.metricName`}
render={({ field }) => (
<FormItem>
<FormLabel>指标名称</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`items.${index}.unit`}
render={({ field }) => (
<FormItem>
<FormLabel>单位</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`items.${index}.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={`items.${index}.targetValue`}
render={({ field }) => (
<FormItem>
<FormLabel>默认目标值</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={e => field.onChange(parseFloat(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
))}
</div>
</div>
<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" />}
{editingTemplate ? '保存' : '创建'}
</Button>
<Button type="button" variant="outline" onClick={handleDialogClose}>
取消
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{templates.map(template => (
<Card key={template.id} className={!template.is_active ? 'opacity-50' : ''}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{template.name}</CardTitle>
{template.project_type && (
<span className="text-xs text-muted-foreground bg-slate-100 px-2 py-0.5 rounded mt-1 inline-block">
{template.project_type}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Switch
checked={template.is_active}
onCheckedChange={() => handleToggleActive(template)}
/>
<Button variant="ghost" size="sm" onClick={() => handleEdit(template)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(template.id)}>
<Trash2 className="h-4 w-4 text-rose-500" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{template.description && (
<p className="text-sm text-muted-foreground mb-3">{template.description}</p>
)}
<div className="space-y-2">
{template.items.slice(0, 3).map((item, index) => (
<div key={index} className="flex items-center justify-between text-sm p-2 bg-slate-50 rounded">
<span>{item.title}</span>
<span className="text-muted-foreground">
{item.targetValue} {item.unit}
</span>
</div>
))}
{template.items.length > 3 && (
<p className="text-xs text-muted-foreground">
+ {template.items.length - 3} 更多项
</p>
)}
</div>
</CardContent>
</Card>
))}
{templates.length === 0 && (
<div className="col-span-full text-center py-12">
<Files className="h-12 w-12 text-slate-300 mx-auto mb-4" />
<p className="text-muted-foreground">暂无模板</p>
</div>
)}
</div>
</div>
);
}