Compare commits
2 Commits
19a2025931
...
a17da68fcd
| Author | SHA1 | Date | |
|---|---|---|---|
| a17da68fcd | |||
| 23e881215d |
@@ -42,7 +42,7 @@ export function LoginLogTable({ logs }: LoginLogTableProps) {
|
|||||||
{new Date(log.loginTime).toLocaleString('zh-CN')}
|
{new Date(log.loginTime).toLocaleString('zh-CN')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
<code className="text-xs px-2 py-1 rounded">
|
||||||
{log.ipAddress}
|
{log.ipAddress}
|
||||||
</code>
|
</code>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { CategoryFormData, CategoryDictionary } from '../types';
|
||||||
|
|
||||||
|
interface CategoryFormProps {
|
||||||
|
open: boolean;
|
||||||
|
editing: CategoryDictionary | null;
|
||||||
|
formData: CategoryFormData;
|
||||||
|
onFormDataChange: (data: Partial<CategoryFormData>) => void;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryForm({
|
||||||
|
open,
|
||||||
|
editing,
|
||||||
|
formData,
|
||||||
|
onFormDataChange,
|
||||||
|
onOpenChange,
|
||||||
|
onSave,
|
||||||
|
}: CategoryFormProps) {
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!formData.code.trim() || !formData.name.trim() || !formData.value.trim() || !formData.label.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
onSave();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editing ? '编辑字典' : '新增字典'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
{editing ? '编辑数据字典' : '添加新数据字典'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>编码 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.code}
|
||||||
|
onChange={(e) => onFormDataChange({ code: e.target.value })}
|
||||||
|
placeholder="GENDER_MALE"
|
||||||
|
disabled={editing?.isSystem}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>名称 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => onFormDataChange({ name: e.target.value })}
|
||||||
|
placeholder="性别-男"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>字典分类 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.category}
|
||||||
|
onValueChange={(value) => onFormDataChange({ category: value })}
|
||||||
|
disabled={editing?.isSystem}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="gender">性别</SelectItem>
|
||||||
|
<SelectItem value="status">状态</SelectItem>
|
||||||
|
<SelectItem value="unit">单位</SelectItem>
|
||||||
|
<SelectItem value="weather">天气</SelectItem>
|
||||||
|
<SelectItem value="soil_type">土壤类型</SelectItem>
|
||||||
|
<SelectItem value="irrigation_method">灌溉方式</SelectItem>
|
||||||
|
<SelectItem value="fertilizer_type">肥料类型</SelectItem>
|
||||||
|
<SelectItem value="pesticide_type">农药类型</SelectItem>
|
||||||
|
<SelectItem value="task_status">任务状态</SelectItem>
|
||||||
|
<SelectItem value="task_priority">任务优先级</SelectItem>
|
||||||
|
<SelectItem value="approval_status">审批状态</SelectItem>
|
||||||
|
<SelectItem value="operation_type">作业类型</SelectItem>
|
||||||
|
<SelectItem value="other">其他</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>值 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.value}
|
||||||
|
onChange={(e) => onFormDataChange({ value: e.target.value })}
|
||||||
|
placeholder="male"
|
||||||
|
disabled={editing?.isSystem}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
程序中使用的值,建议使用英文
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>标签 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.label}
|
||||||
|
onChange={(e) => onFormDataChange({ label: e.target.value })}
|
||||||
|
placeholder="男"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
界面上显示的文本
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>描述</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => onFormDataChange({ description: e.target.value })}
|
||||||
|
placeholder="请输入描述"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>排序</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.sortOrder}
|
||||||
|
onChange={(e) => onFormDataChange({ sortOrder: parseInt(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between pt-6">
|
||||||
|
<Label>是否启用</Label>
|
||||||
|
<Switch
|
||||||
|
checked={formData.isActive}
|
||||||
|
onCheckedChange={(checked) => onFormDataChange({ isActive: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Search, BookOpen, Edit, Trash2 } from 'lucide-react';
|
||||||
|
import { CategoryDictionary } from '../types';
|
||||||
|
|
||||||
|
interface CategoryListProps {
|
||||||
|
categories: CategoryDictionary[];
|
||||||
|
searchKeyword: string;
|
||||||
|
categoryFilter: string;
|
||||||
|
onSearchChange: (keyword: string) => void;
|
||||||
|
onCategoryFilterChange: (category: string) => void;
|
||||||
|
onEdit: (category: CategoryDictionary) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryList({
|
||||||
|
categories,
|
||||||
|
searchKeyword,
|
||||||
|
categoryFilter,
|
||||||
|
onSearchChange,
|
||||||
|
onCategoryFilterChange,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: CategoryListProps) {
|
||||||
|
// 过滤字典
|
||||||
|
const filteredCategories = categories.filter(category => {
|
||||||
|
const matchKeyword = !searchKeyword ||
|
||||||
|
category.name.includes(searchKeyword) ||
|
||||||
|
category.code.includes(searchKeyword) ||
|
||||||
|
category.label.includes(searchKeyword) ||
|
||||||
|
category.value.includes(searchKeyword);
|
||||||
|
const matchCategory = categoryFilter === 'all' || category.category === categoryFilter;
|
||||||
|
return matchKeyword && matchCategory;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按分类分组
|
||||||
|
const groupedCategories = filteredCategories.reduce((acc, category) => {
|
||||||
|
if (!acc[category.category]) {
|
||||||
|
acc[category.category] = [];
|
||||||
|
}
|
||||||
|
acc[category.category].push(category);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, CategoryDictionary[]>);
|
||||||
|
|
||||||
|
const getCategoryLabel = (category: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
gender: '性别',
|
||||||
|
status: '状态',
|
||||||
|
unit: '单位',
|
||||||
|
weather: '天气',
|
||||||
|
soil_type: '土壤类型',
|
||||||
|
irrigation_method: '灌溉方式',
|
||||||
|
fertilizer_type: '肥料类型',
|
||||||
|
pesticide_type: '农药类型',
|
||||||
|
task_status: '任务状态',
|
||||||
|
task_priority: '任务优先级',
|
||||||
|
approval_status: '审批状态',
|
||||||
|
operation_type: '作业类型',
|
||||||
|
other: '其他',
|
||||||
|
};
|
||||||
|
return labels[category] || category;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 搜索和筛选 */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索编码、名称、标签、值..."
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={categoryFilter} onValueChange={onCategoryFilterChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="字典分类" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部分类</SelectItem>
|
||||||
|
<SelectItem value="gender">性别</SelectItem>
|
||||||
|
<SelectItem value="status">状态</SelectItem>
|
||||||
|
<SelectItem value="unit">单位</SelectItem>
|
||||||
|
<SelectItem value="weather">天气</SelectItem>
|
||||||
|
<SelectItem value="soil_type">土壤类型</SelectItem>
|
||||||
|
<SelectItem value="irrigation_method">灌溉方式</SelectItem>
|
||||||
|
<SelectItem value="fertilizer_type">肥料类型</SelectItem>
|
||||||
|
<SelectItem value="pesticide_type">农药类型</SelectItem>
|
||||||
|
<SelectItem value="task_status">任务状态</SelectItem>
|
||||||
|
<SelectItem value="task_priority">任务优先级</SelectItem>
|
||||||
|
<SelectItem value="approval_status">审批状态</SelectItem>
|
||||||
|
<SelectItem value="operation_type">作业类型</SelectItem>
|
||||||
|
<SelectItem value="other">其他</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 字典列表 */}
|
||||||
|
{Object.entries(groupedCategories).map(([category, items]) => (
|
||||||
|
<Card key={category}>
|
||||||
|
<div className="p-4 border-b bg-muted/50">
|
||||||
|
<h3 className="flex items-center gap-2">
|
||||||
|
<BookOpen className="w-5 h-5 text-green-600" />
|
||||||
|
{getCategoryLabel(category)}
|
||||||
|
<Badge variant="outline">{items.length}</Badge>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>编码</TableHead>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>值</TableHead>
|
||||||
|
<TableHead>标签</TableHead>
|
||||||
|
<TableHead>排序</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.sort((a, b) => a.sortOrder - b.sortOrder).map((category) => (
|
||||||
|
<TableRow key={category.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs bg-muted px-2 py-1 rounded">{category.code}</code>
|
||||||
|
{category.isSystem && (
|
||||||
|
<Badge variant="outline" className="text-xs">系统</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div>{category.name}</div>
|
||||||
|
{category.description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{category.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-xs">{category.value}</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{category.label}</TableCell>
|
||||||
|
<TableCell>{category.sortOrder}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{category.isActive ? (
|
||||||
|
<Badge className="bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">启用</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">停用</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEdit(category)}
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{!category.isSystem && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(category.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filteredCategories.length === 0 && (
|
||||||
|
<Card className="p-12 text-center text-muted-foreground">
|
||||||
|
暂无字典数据
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 使用说明 */}
|
||||||
|
<Card className="p-4 bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800">
|
||||||
|
<h4 className="text-blue-900 dark:text-blue-100 mb-2">
|
||||||
|
<BookOpen className="w-4 h-4 inline mr-2" />
|
||||||
|
数据字典说明
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1 text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
<li>• 数据字典用于存储系统中的枚举值和下拉选项</li>
|
||||||
|
<li>• 编码应遵循命名规范,使用大写字母和下划线(如 GENDER_MALE)</li>
|
||||||
|
<li>• 值(value)用于程序逻辑,标签(label)用于界面显示</li>
|
||||||
|
<li>• 系统内置字典不可删除,但可以编辑标签和状态</li>
|
||||||
|
<li>• 支持按分类分组展示,便于管理和查找</li>
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { CategoryDictionary } from '../types';
|
||||||
|
|
||||||
|
interface DeleteConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
category: CategoryDictionary | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteConfirmDialog({
|
||||||
|
open,
|
||||||
|
category,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteConfirmDialogProps) {
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm();
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要删除字典项"{category?.name}"吗?此操作不可撤销。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
{category && (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">编码:</span>
|
||||||
|
<code className="text-xs bg-muted px-2 py-1 rounded">{category.code}</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">名称:</span>
|
||||||
|
<span>{category.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">分类:</span>
|
||||||
|
<span>{category.category}</span>
|
||||||
|
</div>
|
||||||
|
{category.isSystem && (
|
||||||
|
<div className="mt-2 p-2 bg-destructive/10 border border-destructive/20 rounded text-destructive text-xs">
|
||||||
|
⚠️ 这是系统内置字典,通常不建议删除
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={category?.isSystem}
|
||||||
|
>
|
||||||
|
{category?.isSystem ? '系统字典不可删除' : '确认删除'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,385 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useReducer, useLayoutEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Plus, Download } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { CategoryList } from './components/CategoryList';
|
||||||
|
import { CategoryForm } from './components/CategoryForm';
|
||||||
|
import { DeleteConfirmDialog } from './components/DeleteConfirmDialog';
|
||||||
|
import { CategoryDictionary } from './types';
|
||||||
|
import { categoryReducer, initialCategoryState } from './reducer';
|
||||||
|
|
||||||
|
// 模拟数据
|
||||||
|
const mockData: CategoryDictionary[] = [
|
||||||
|
// 性别
|
||||||
|
{
|
||||||
|
id: 'dict-1',
|
||||||
|
code: 'GENDER_MALE',
|
||||||
|
name: '性别-男',
|
||||||
|
category: 'gender',
|
||||||
|
value: 'male',
|
||||||
|
label: '男',
|
||||||
|
sortOrder: 1,
|
||||||
|
isSystem: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dict-2',
|
||||||
|
code: 'GENDER_FEMALE',
|
||||||
|
name: '性别-女',
|
||||||
|
category: 'gender',
|
||||||
|
value: 'female',
|
||||||
|
label: '女',
|
||||||
|
sortOrder: 2,
|
||||||
|
isSystem: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
// 状态
|
||||||
|
{
|
||||||
|
id: 'dict-3',
|
||||||
|
code: 'STATUS_ACTIVE',
|
||||||
|
name: '状态-激活',
|
||||||
|
category: 'status',
|
||||||
|
value: 'active',
|
||||||
|
label: '激活',
|
||||||
|
sortOrder: 1,
|
||||||
|
isSystem: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dict-4',
|
||||||
|
code: 'STATUS_INACTIVE',
|
||||||
|
name: '状态-停用',
|
||||||
|
category: 'status',
|
||||||
|
value: 'inactive',
|
||||||
|
label: '停用',
|
||||||
|
sortOrder: 2,
|
||||||
|
isSystem: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
// 单位类型
|
||||||
|
{
|
||||||
|
id: 'dict-5',
|
||||||
|
code: 'UNIT_AREA_MU',
|
||||||
|
name: '面积单位-亩',
|
||||||
|
category: 'unit',
|
||||||
|
value: 'mu',
|
||||||
|
label: '亩',
|
||||||
|
sortOrder: 1,
|
||||||
|
description: '中国传统面积单位',
|
||||||
|
isSystem: false,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dict-6',
|
||||||
|
code: 'UNIT_AREA_HECTARE',
|
||||||
|
name: '面积单位-公顷',
|
||||||
|
category: 'unit',
|
||||||
|
value: 'hectare',
|
||||||
|
label: '公顷',
|
||||||
|
sortOrder: 2,
|
||||||
|
description: '国际通用面积单位',
|
||||||
|
isSystem: false,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dict-7',
|
||||||
|
code: 'UNIT_WEIGHT_KG',
|
||||||
|
name: '重量单位-千克',
|
||||||
|
category: 'unit',
|
||||||
|
value: 'kg',
|
||||||
|
label: '千克',
|
||||||
|
sortOrder: 3,
|
||||||
|
isSystem: false,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dict-8',
|
||||||
|
code: 'UNIT_WEIGHT_TON',
|
||||||
|
name: '重量单位-吨',
|
||||||
|
category: 'unit',
|
||||||
|
value: 'ton',
|
||||||
|
label: '吨',
|
||||||
|
sortOrder: 4,
|
||||||
|
isSystem: false,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
// 天气
|
||||||
|
{
|
||||||
|
id: 'dict-9',
|
||||||
|
code: 'WEATHER_SUNNY',
|
||||||
|
name: '天气-晴',
|
||||||
|
category: 'weather',
|
||||||
|
value: 'sunny',
|
||||||
|
label: '晴',
|
||||||
|
sortOrder: 1,
|
||||||
|
isSystem: false,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dict-10',
|
||||||
|
code: 'WEATHER_CLOUDY',
|
||||||
|
name: '天气-多云',
|
||||||
|
category: 'weather',
|
||||||
|
value: 'cloudy',
|
||||||
|
label: '多云',
|
||||||
|
sortOrder: 2,
|
||||||
|
isSystem: false,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dict-11',
|
||||||
|
code: 'WEATHER_RAINY',
|
||||||
|
name: '天气-雨',
|
||||||
|
category: 'weather',
|
||||||
|
value: 'rainy',
|
||||||
|
label: '雨',
|
||||||
|
sortOrder: 3,
|
||||||
|
isSystem: false,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
// 土壤类型
|
||||||
|
{
|
||||||
|
id: 'dict-12',
|
||||||
|
code: 'SOIL_SANDY',
|
||||||
|
name: '土壤-砂土',
|
||||||
|
category: 'soil_type',
|
||||||
|
value: 'sandy',
|
||||||
|
label: '砂土',
|
||||||
|
sortOrder: 1,
|
||||||
|
description: '含砂粒较多的土壤',
|
||||||
|
isSystem: false,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dict-13',
|
||||||
|
code: 'SOIL_LOAMY',
|
||||||
|
name: '土壤-壤土',
|
||||||
|
category: 'soil_type',
|
||||||
|
value: 'loamy',
|
||||||
|
label: '壤土',
|
||||||
|
sortOrder: 2,
|
||||||
|
description: '砂粘适中的土壤',
|
||||||
|
isSystem: false,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dict-14',
|
||||||
|
code: 'SOIL_CLAY',
|
||||||
|
name: '土壤-黏土',
|
||||||
|
category: 'soil_type',
|
||||||
|
value: 'clay',
|
||||||
|
label: '黏土',
|
||||||
|
sortOrder: 3,
|
||||||
|
description: '含黏粒较多的土壤',
|
||||||
|
isSystem: false,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-01T00:00:00',
|
||||||
|
updatedAt: '2024-01-01T00:00:00',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function DataDictionaryPage() {
|
export default function DataDictionaryPage() {
|
||||||
|
const [state, dispatch] = useReducer(categoryReducer, initialCategoryState);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
|
||||||
|
const [categoryToDelete, setCategoryToDelete] = React.useState<CategoryDictionary | null>(null);
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const data = localStorage.getItem('smart_agriculture_category_dictionary');
|
||||||
|
if (data) {
|
||||||
|
try {
|
||||||
|
const categories = JSON.parse(data);
|
||||||
|
dispatch({ type: 'SET_CATEGORIES', payload: categories });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse category dictionary data:', error);
|
||||||
|
loadMockData();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadMockData();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMockData = () => {
|
||||||
|
localStorage.setItem('smart_agriculture_category_dictionary', JSON.stringify(mockData));
|
||||||
|
dispatch({ type: 'SET_CATEGORIES', payload: mockData });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCategories = (categories: CategoryDictionary[]) => {
|
||||||
|
localStorage.setItem('smart_agriculture_category_dictionary', JSON.stringify(categories));
|
||||||
|
dispatch({ type: 'SET_CATEGORIES', payload: categories });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理新增
|
||||||
|
const handleAdd = () => {
|
||||||
|
dispatch({ type: 'SET_DIALOG_STATE', payload: { open: true, editing: null } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理编辑
|
||||||
|
const handleEdit = (category: CategoryDictionary) => {
|
||||||
|
dispatch({ type: 'SET_DIALOG_STATE', payload: { open: true, editing: category } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理删除
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
const category = state.categories.find(c => c.id === id);
|
||||||
|
if (!category) return;
|
||||||
|
|
||||||
|
if (category.isSystem) {
|
||||||
|
toast.error('系统内置字典不能删除');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCategoryToDelete(category);
|
||||||
|
setDeleteConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认删除
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (!categoryToDelete) return;
|
||||||
|
|
||||||
|
const updated = state.categories.filter(c => c.id !== categoryToDelete.id);
|
||||||
|
saveCategories(updated);
|
||||||
|
toast.success('删除成功');
|
||||||
|
setCategoryToDelete(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理保存
|
||||||
|
const handleSave = () => {
|
||||||
|
const { formData, dialogState } = state;
|
||||||
|
|
||||||
|
if (!formData.code.trim() || !formData.name.trim() || !formData.value.trim() || !formData.label.trim()) {
|
||||||
|
toast.error('请填写必填项');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (dialogState.editing) {
|
||||||
|
// 编辑
|
||||||
|
const updated = state.categories.map(category =>
|
||||||
|
category.id === dialogState.editing!.id
|
||||||
|
? {
|
||||||
|
...category,
|
||||||
|
...formData,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
: category
|
||||||
|
);
|
||||||
|
saveCategories(updated);
|
||||||
|
toast.success('更新成功');
|
||||||
|
} else {
|
||||||
|
// 新增
|
||||||
|
const newCategory: CategoryDictionary = {
|
||||||
|
id: `dict-${Date.now()}`,
|
||||||
|
...formData,
|
||||||
|
isSystem: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
saveCategories([...state.categories, newCategory]);
|
||||||
|
toast.success('添加成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({ type: 'SET_DIALOG_STATE', payload: { open: false, editing: null } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理导出
|
||||||
|
const handleExport = () => {
|
||||||
|
const filteredCategories = state.categories.filter(category => {
|
||||||
|
const matchKeyword = !state.searchKeyword ||
|
||||||
|
category.name.includes(state.searchKeyword) ||
|
||||||
|
category.code.includes(state.searchKeyword) ||
|
||||||
|
category.label.includes(state.searchKeyword) ||
|
||||||
|
category.value.includes(state.searchKeyword);
|
||||||
|
const matchCategory = state.categoryFilter === 'all' || category.category === state.categoryFilter;
|
||||||
|
return matchKeyword && matchCategory;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataStr = JSON.stringify(filteredCategories, null, 2);
|
||||||
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(dataBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `category_dictionary_${new Date().getTime()}.json`;
|
||||||
|
link.click();
|
||||||
|
toast.success('导出成功');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="space-y-6 p-6">
|
||||||
<h1 className="text-2xl font-bold mb-4">数据字典</h1>
|
<div className="flex items-center justify-between">
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
<div>
|
||||||
<p>数据字典页面 - 路径: /config/system/dictionary</p>
|
<h2 className="text-green-800 dark:text-green-600">分类字典</h2>
|
||||||
|
<p className="text-muted-foreground">集中管理系统内所有基础字典项</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={handleExport}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
导出
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAdd}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
新增字典
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 字典列表 */}
|
||||||
|
<CategoryList
|
||||||
|
categories={state.categories}
|
||||||
|
searchKeyword={state.searchKeyword}
|
||||||
|
categoryFilter={state.categoryFilter}
|
||||||
|
onSearchChange={(keyword) => dispatch({ type: 'SET_SEARCH_KEYWORD', payload: keyword })}
|
||||||
|
onCategoryFilterChange={(category) => dispatch({ type: 'SET_CATEGORY_FILTER', payload: category })}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 编辑表单 */}
|
||||||
|
<CategoryForm
|
||||||
|
open={state.dialogState.open}
|
||||||
|
editing={state.dialogState.editing}
|
||||||
|
formData={state.formData}
|
||||||
|
onFormDataChange={(data) => dispatch({ type: 'SET_FORM_DATA', payload: data })}
|
||||||
|
onOpenChange={(open) => dispatch({ type: 'SET_DIALOG_STATE', payload: { open, editing: null } })}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 删除确认对话框 */}
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteConfirmOpen}
|
||||||
|
category={categoryToDelete}
|
||||||
|
onOpenChange={setDeleteConfirmOpen}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
109
crop-x/src/app/(app)/central-config/system/dictionary/reducer.ts
Normal file
109
crop-x/src/app/(app)/central-config/system/dictionary/reducer.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { CategoryState, CategoryAction, CategoryFormData } from './types';
|
||||||
|
|
||||||
|
// 初始状态
|
||||||
|
export const initialCategoryState: CategoryState = {
|
||||||
|
categories: [],
|
||||||
|
searchKeyword: '',
|
||||||
|
categoryFilter: 'all',
|
||||||
|
dialogState: {
|
||||||
|
open: false,
|
||||||
|
editing: null,
|
||||||
|
},
|
||||||
|
formData: {
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
category: 'other',
|
||||||
|
value: '',
|
||||||
|
label: '',
|
||||||
|
sortOrder: 0,
|
||||||
|
description: '',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始表单数据
|
||||||
|
export const initialFormData: CategoryFormData = {
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
category: 'other',
|
||||||
|
value: '',
|
||||||
|
label: '',
|
||||||
|
sortOrder: 0,
|
||||||
|
description: '',
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reducer
|
||||||
|
export function categoryReducer(state: CategoryState, action: CategoryAction): CategoryState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_CATEGORIES':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
categories: action.payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'ADD_CATEGORY':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
categories: [...state.categories, action.payload],
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'UPDATE_CATEGORY':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
categories: state.categories.map(category =>
|
||||||
|
category.id === action.payload.id
|
||||||
|
? { ...category, ...action.payload.updates, updatedAt: new Date().toISOString() }
|
||||||
|
: category
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'DELETE_CATEGORY':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
categories: state.categories.filter(category => category.id !== action.payload),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_SEARCH_KEYWORD':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
searchKeyword: action.payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_CATEGORY_FILTER':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
categoryFilter: action.payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_DIALOG_STATE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
dialogState: action.payload,
|
||||||
|
formData: action.payload.editing
|
||||||
|
? {
|
||||||
|
code: action.payload.editing.code,
|
||||||
|
name: action.payload.editing.name,
|
||||||
|
category: action.payload.editing.category,
|
||||||
|
value: action.payload.editing.value,
|
||||||
|
label: action.payload.editing.label,
|
||||||
|
sortOrder: action.payload.editing.sortOrder,
|
||||||
|
description: action.payload.editing.description || '',
|
||||||
|
isActive: action.payload.editing.isActive,
|
||||||
|
}
|
||||||
|
: initialFormData,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_FORM_DATA':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
formData: {
|
||||||
|
...state.formData,
|
||||||
|
...action.payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// 分类字典类型定义
|
||||||
|
|
||||||
|
export interface CategoryDictionary {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
category: string; // 字典分类
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
sortOrder: number;
|
||||||
|
description?: string;
|
||||||
|
isSystem: boolean; // 是否系统内置
|
||||||
|
isActive: boolean;
|
||||||
|
extendData?: Record<string, any>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DictionaryCategory =
|
||||||
|
| 'gender'
|
||||||
|
| 'status'
|
||||||
|
| 'unit'
|
||||||
|
| 'weather'
|
||||||
|
| 'soil_type'
|
||||||
|
| 'irrigation_method'
|
||||||
|
| 'fertilizer_type'
|
||||||
|
| 'pesticide_type'
|
||||||
|
| 'task_status'
|
||||||
|
| 'task_priority'
|
||||||
|
| 'approval_status'
|
||||||
|
| 'operation_type'
|
||||||
|
| 'other';
|
||||||
|
|
||||||
|
// 分类表单数据
|
||||||
|
export interface CategoryFormData {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
sortOrder: number;
|
||||||
|
description: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分类操作类型
|
||||||
|
export type CategoryAction =
|
||||||
|
| { type: 'SET_CATEGORIES'; payload: CategoryDictionary[] }
|
||||||
|
| { type: 'ADD_CATEGORY'; payload: CategoryDictionary }
|
||||||
|
| { type: 'UPDATE_CATEGORY'; payload: { id: string; updates: Partial<CategoryDictionary> } }
|
||||||
|
| { type: 'DELETE_CATEGORY'; payload: string }
|
||||||
|
| { type: 'SET_SEARCH_KEYWORD'; payload: string }
|
||||||
|
| { type: 'SET_CATEGORY_FILTER'; payload: string }
|
||||||
|
| { type: 'SET_DIALOG_STATE'; payload: { open: boolean; editing?: CategoryDictionary | null } }
|
||||||
|
| { type: 'SET_FORM_DATA'; payload: Partial<CategoryFormData> };
|
||||||
|
|
||||||
|
// 分类状态
|
||||||
|
export interface CategoryState {
|
||||||
|
categories: CategoryDictionary[];
|
||||||
|
searchKeyword: string;
|
||||||
|
categoryFilter: string;
|
||||||
|
dialogState: {
|
||||||
|
open: boolean;
|
||||||
|
editing?: CategoryDictionary | null;
|
||||||
|
};
|
||||||
|
formData: CategoryFormData;
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-input-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Reference in New Issue
Block a user