Files
ok_r/app/admin/templates/page.tsx
karlcarlo fc9cfda29e feat: 添加 API 客户端以处理 basePath 并更新所有 fetch 调用
移除 next.config.js 中的 standalone 输出配置,添加 lib/api-client.ts 封装 fetch 请求,
自动为所有 API 调用添加配置的 basePath。更新所有页面和组件中的 fetch 调用以使用新的 fetchApi 函数,
确保在部署到子路径时 API 请求能正确工作。同时调整 tsconfig.json 的格式并修复项目创建时的字段名。
2026-01-28 15:54:19 +08:00

481 lines
18 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, 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';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { fetchApi } from '@/lib/api-client';
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 [deleteTemplateId, setDeleteTemplateId] = useState<string | null>(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = 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 fetchApi('/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 fetchApi(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 fetchApi(`/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 requestDelete = (templateId: string) => {
setDeleteTemplateId(templateId);
setIsDeleteOpen(true);
};
const confirmDelete = async () => {
if (!deleteTemplateId) return;
setIsDeleting(true);
try {
const res = await fetchApi(`/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);
}
};
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={() => requestDelete(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>
<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>
);
}