- 新增用户登录页面与API端点(/api/auth/login、/api/auth/logout、/api/auth/me) - 引入基于密码哈希的认证机制,支持默认管理员账号登录 - 创建访问控制模块(lib/access-control.ts),实现基于角色的权限验证 - 在现有API端点中集成权限检查,限制非管理员用户只能访问其所属项目数据 - 更新应用布局,要求用户登录后才能访问主界面 - 扩展用户表结构,增加用户名和密码哈希字段 - 调整项目、KR、目标、会议记录等资源的查询逻辑,根据用户权限过滤数据 - 改进管理页面(指标、模板)的删除操作,使用确认对话框替代直接确认 - 优化“我的KR”页面,为管理员提供全员KR概览视图 - 修复.gitignore文件中的拼写错误(.trae → .travis) - 添加数据库迁移脚本,支持任务直接关联项目、禁用RLS、添加求助字段等 - 配置Next.js以支持独立输出和子路径部署
480 lines
18 KiB
TypeScript
480 lines
18 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';
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from '@/components/ui/alert-dialog';
|
||
|
||
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 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 requestDelete = (templateId: string) => {
|
||
setDeleteTemplateId(templateId);
|
||
setIsDeleteOpen(true);
|
||
};
|
||
|
||
const confirmDelete = async () => {
|
||
if (!deleteTemplateId) return;
|
||
setIsDeleting(true);
|
||
try {
|
||
const res = await fetch(`/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>
|
||
);
|
||
}
|