437 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|