子仓库提交
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
interface CategoryFiltersProps {
|
||||
searchKeyword: string;
|
||||
typeFilter: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onTypeFilterChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function CategoryFilters({
|
||||
searchKeyword,
|
||||
typeFilter,
|
||||
onSearchChange,
|
||||
onTypeFilterChange,
|
||||
}: CategoryFiltersProps) {
|
||||
return (
|
||||
<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={typeFilter} onValueChange={onTypeFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="分类类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="industry">行业类型</SelectItem>
|
||||
<SelectItem value="equipment">设备类型</SelectItem>
|
||||
<SelectItem value="crop">作物类型</SelectItem>
|
||||
<SelectItem value="operation">作业类型</SelectItem>
|
||||
<SelectItem value="other">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
'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 { CategoryDictionary, CategoryFormData } from '../types';
|
||||
|
||||
interface CategoryFormDialogProps {
|
||||
open: boolean;
|
||||
editing?: CategoryDictionary;
|
||||
parent?: CategoryDictionary | null;
|
||||
formData: CategoryFormData;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onFormDataChange: (data: Partial<CategoryFormData>) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export function CategoryFormDialog({
|
||||
open,
|
||||
editing,
|
||||
parent,
|
||||
formData,
|
||||
onOpenChange,
|
||||
onFormDataChange,
|
||||
onSave,
|
||||
}: CategoryFormDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? '编辑分类' : '新增分类'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{editing ? '编辑分类信息' : '添加新分类'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{parent && (
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<Label className="text-sm text-blue-900 dark:text-blue-100">上级分类</Label>
|
||||
<p className="mt-1 dark:text-gray-100">{parent.name}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="code">分类编码 *</Label>
|
||||
<Input
|
||||
id="code"
|
||||
value={formData.code}
|
||||
onChange={(e) => onFormDataChange({ code: e.target.value })}
|
||||
placeholder="IND001"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="name">分类名称 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => onFormDataChange({ name: e.target.value })}
|
||||
placeholder="请输入名称"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="type">分类类型</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(value) => onFormDataChange({ type: value })}
|
||||
disabled={!!parent}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="industry">行业类型</SelectItem>
|
||||
<SelectItem value="equipment">设备类型</SelectItem>
|
||||
<SelectItem value="crop">作物类型</SelectItem>
|
||||
<SelectItem value="operation">作业类型</SelectItem>
|
||||
<SelectItem value="other">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => onFormDataChange({ description: e.target.value })}
|
||||
placeholder="请输入描述"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="sortOrder">排序</Label>
|
||||
<Input
|
||||
id="sortOrder"
|
||||
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 htmlFor="isActive">是否启用</Label>
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => onFormDataChange({ isActive: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={onSave}>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { FolderTree } from 'lucide-react';
|
||||
|
||||
export function CategoryInstructions() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
|
||||
<h4 className="text-blue-900 dark:text-blue-100 mb-2">
|
||||
<FolderTree 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>• 点击文件夹图标可展开/收起子分类</li>
|
||||
<li>• 鼠标悬停在分类上可显示操作按钮</li>
|
||||
<li>• 删除前需先删除所有子分类</li>
|
||||
<li>• 分类编码建议使用层级编码规则(如 IND001-01)</li>
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'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 {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
File,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { CategoryDictionary } from '../types';
|
||||
|
||||
interface CategoryTreeProps {
|
||||
categories: CategoryDictionary[];
|
||||
expandedIds: Set<string>;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onAdd: (parent?: CategoryDictionary) => void;
|
||||
onEdit: (category: CategoryDictionary) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function CategoryTree({
|
||||
categories,
|
||||
expandedIds,
|
||||
onToggleExpand,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CategoryTreeProps) {
|
||||
const renderTree = (nodes: CategoryDictionary[], level: number = 0) => {
|
||||
return nodes.map(node => (
|
||||
<div key={node.id} style={{ marginLeft: `${level * 24}px` }}>
|
||||
<div className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg group">
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{node.children && node.children.length > 0 ? (
|
||||
<button
|
||||
onClick={() => onToggleExpand(node.id)}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
{expandedIds.has(node.id) ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6" />
|
||||
)}
|
||||
{node.children && node.children.length > 0 ? (
|
||||
<Folder className="w-4 h-4 text-yellow-600 dark:text-yellow-500" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="dark:text-gray-100">{node.name}</span>
|
||||
<Badge variant="outline" className="text-xs">{node.code}</Badge>
|
||||
{!node.isActive && (
|
||||
<Badge variant="outline" className="text-xs text-red-600 dark:text-red-400">已停用</Badge>
|
||||
)}
|
||||
</div>
|
||||
{node.description && (
|
||||
<p className="text-xs text-muted-foreground dark:text-gray-400">{node.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onAdd(node)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(node)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(node.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{expandedIds.has(node.id) && node.children && renderTree(node.children, level + 1)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="min-h-[400px]">
|
||||
{categories.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
暂无分类数据
|
||||
</div>
|
||||
) : (
|
||||
renderTree(categories)
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
306
src/app/(app)/central-config/system/category/page.tsx
Normal file
306
src/app/(app)/central-config/system/category/page.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
'use client';
|
||||
|
||||
import React, { useReducer, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { CategoryDictionary, CategoryAction } from './types';
|
||||
import { categoryReducer, initialState } from './reducer';
|
||||
import { CategoryFilters } from './components/CategoryFilters';
|
||||
import { CategoryTree } from './components/CategoryTree';
|
||||
import { CategoryFormDialog } from './components/CategoryFormDialog';
|
||||
import { CategoryInstructions } from './components/CategoryInstructions';
|
||||
|
||||
export default function CategoryDictionaryPage() {
|
||||
const [state, dispatch] = useReducer(categoryReducer, initialState);
|
||||
|
||||
// 模拟数据加载
|
||||
useEffect(() => {
|
||||
const mockData: CategoryDictionary[] = [
|
||||
{
|
||||
id: 'cat-1',
|
||||
code: 'IND001',
|
||||
name: '种植业',
|
||||
type: 'industry',
|
||||
level: 1,
|
||||
sortOrder: 1,
|
||||
description: '农作物种植相关行业',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-2',
|
||||
code: 'IND001-01',
|
||||
name: '粮食作物',
|
||||
type: 'industry',
|
||||
parentId: 'cat-1',
|
||||
level: 2,
|
||||
sortOrder: 1,
|
||||
description: '小麦、水稻、玉米等粮食作物',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-3',
|
||||
code: 'IND001-02',
|
||||
name: '经济作物',
|
||||
type: 'industry',
|
||||
parentId: 'cat-1',
|
||||
level: 2,
|
||||
sortOrder: 2,
|
||||
description: '棉花、油料、糖料等经济作物',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-4',
|
||||
code: 'IND002',
|
||||
name: '畜牧业',
|
||||
type: 'industry',
|
||||
level: 1,
|
||||
sortOrder: 2,
|
||||
description: '牲畜饲养相关行业',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-5',
|
||||
code: 'EQP001',
|
||||
name: '动力机械',
|
||||
type: 'equipment',
|
||||
level: 1,
|
||||
sortOrder: 1,
|
||||
description: '拖拉机等动力设备',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-6',
|
||||
code: 'EQP001-01',
|
||||
name: '轮式拖拉机',
|
||||
type: 'equipment',
|
||||
parentId: 'cat-5',
|
||||
level: 2,
|
||||
sortOrder: 1,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-7',
|
||||
code: 'EQP001-02',
|
||||
name: '履带式拖拉机',
|
||||
type: 'equipment',
|
||||
parentId: 'cat-5',
|
||||
level: 2,
|
||||
sortOrder: 2,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-8',
|
||||
code: 'EQP002',
|
||||
name: '收获机械',
|
||||
type: 'equipment',
|
||||
level: 1,
|
||||
sortOrder: 2,
|
||||
description: '收割机、采摘机等',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
];
|
||||
|
||||
// 尝试从 localStorage 加载数据
|
||||
const storedData = localStorage.getItem('smart_agriculture_category_dictionary');
|
||||
if (storedData) {
|
||||
try {
|
||||
const data = JSON.parse(storedData);
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: data });
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored data:', error);
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: mockData });
|
||||
}
|
||||
} else {
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: mockData });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 保存数据到 localStorage
|
||||
const saveCategories = (categories: CategoryDictionary[]) => {
|
||||
localStorage.setItem('smart_agriculture_category_dictionary', JSON.stringify(categories));
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: categories });
|
||||
};
|
||||
|
||||
// 构建树形结构
|
||||
const buildTree = (items: CategoryDictionary[]): CategoryDictionary[] => {
|
||||
const map = new Map<string, CategoryDictionary>();
|
||||
const roots: CategoryDictionary[] = [];
|
||||
|
||||
// 创建映射
|
||||
items.forEach(item => {
|
||||
map.set(item.id, { ...item, children: [] });
|
||||
});
|
||||
|
||||
// 构建树
|
||||
items.forEach(item => {
|
||||
const node = map.get(item.id)!;
|
||||
if (item.parentId) {
|
||||
const parent = map.get(item.parentId);
|
||||
if (parent) {
|
||||
parent.children = parent.children || [];
|
||||
parent.children.push(node);
|
||||
}
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
return roots;
|
||||
};
|
||||
|
||||
// 过滤分类数据
|
||||
const filteredCategories = state.categories.filter(cat => {
|
||||
const matchKeyword = !state.searchKeyword ||
|
||||
cat.name.includes(state.searchKeyword) ||
|
||||
cat.code.includes(state.searchKeyword);
|
||||
const matchType = state.typeFilter === 'all' || cat.type === state.typeFilter;
|
||||
return matchKeyword && matchType;
|
||||
});
|
||||
|
||||
const treeData = buildTree(filteredCategories);
|
||||
|
||||
// 处理新增
|
||||
const handleAdd = (parent?: CategoryDictionary) => {
|
||||
dispatch({
|
||||
type: 'SET_DIALOG_STATE',
|
||||
payload: {
|
||||
open: true,
|
||||
editing: undefined,
|
||||
parent: parent || null,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = (category: CategoryDictionary) => {
|
||||
dispatch({
|
||||
type: 'SET_DIALOG_STATE',
|
||||
payload: {
|
||||
open: true,
|
||||
editing: category,
|
||||
parent: undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 处理删除
|
||||
const handleDelete = (id: string) => {
|
||||
// 检查是否有子分类
|
||||
const hasChildren = state.categories.some(cat => cat.parentId === id);
|
||||
if (hasChildren) {
|
||||
toast.error('请先删除子分类');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = state.categories.filter(cat => cat.id !== id);
|
||||
saveCategories(updated);
|
||||
toast.success('删除成功');
|
||||
};
|
||||
|
||||
// 处理保存
|
||||
const handleSave = () => {
|
||||
if (!state.formData.code.trim() || !state.formData.name.trim()) {
|
||||
toast.error('请填写编码和名称');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.dialogState.editing) {
|
||||
// 编辑
|
||||
dispatch({
|
||||
type: 'UPDATE_CATEGORY',
|
||||
payload: {
|
||||
id: state.dialogState.editing.id,
|
||||
updates: state.formData,
|
||||
},
|
||||
});
|
||||
saveCategories(state.categories);
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
// 新增
|
||||
const newCategory: CategoryDictionary = {
|
||||
id: `cat-${Date.now()}`,
|
||||
...state.formData,
|
||||
parentId: state.dialogState.parent?.id,
|
||||
level: state.dialogState.parent ? state.dialogState.parent.level + 1 : 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
dispatch({ type: 'ADD_CATEGORY', payload: newCategory });
|
||||
saveCategories([...state.categories, newCategory]);
|
||||
toast.success('添加成功');
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'SET_DIALOG_STATE',
|
||||
payload: { open: false, editing: undefined, parent: undefined },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-green-800 dark:text-green-600">分类字典</h2>
|
||||
<p className="text-muted-foreground dark:text-gray-400">树形结构管理系统常用分类数据</p>
|
||||
</div>
|
||||
<Button onClick={() => handleAdd()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增分类
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<CategoryFilters
|
||||
searchKeyword={state.searchKeyword}
|
||||
typeFilter={state.typeFilter}
|
||||
onSearchChange={(value) => dispatch({ type: 'SET_SEARCH_KEYWORD', payload: value })}
|
||||
onTypeFilterChange={(value) => dispatch({ type: 'SET_TYPE_FILTER', payload: value })}
|
||||
/>
|
||||
|
||||
{/* 分类树 */}
|
||||
<CategoryTree
|
||||
categories={treeData}
|
||||
expandedIds={state.expandedIds}
|
||||
onToggleExpand={(id) => dispatch({ type: 'TOGGLE_EXPAND', payload: id })}
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* 编辑对话框 */}
|
||||
<CategoryFormDialog
|
||||
open={state.dialogState.open}
|
||||
editing={state.dialogState.editing}
|
||||
parent={state.dialogState.parent}
|
||||
formData={state.formData}
|
||||
onOpenChange={(open) => dispatch({
|
||||
type: 'SET_DIALOG_STATE',
|
||||
payload: { open, editing: undefined, parent: undefined },
|
||||
})}
|
||||
onFormDataChange={(data) => dispatch({ type: 'SET_FORM_DATA', payload: data })}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<CategoryInstructions />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
src/app/(app)/central-config/system/category/reducer.ts
Normal file
94
src/app/(app)/central-config/system/category/reducer.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { CategoryDictionary, CategoryAction, CategoryState, CategoryFormData } from './types';
|
||||
|
||||
const initialFormData: CategoryFormData = {
|
||||
code: '',
|
||||
name: '',
|
||||
type: 'industry',
|
||||
description: '',
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
export const initialState: CategoryState = {
|
||||
categories: [],
|
||||
searchKeyword: '',
|
||||
typeFilter: 'all',
|
||||
expandedIds: new Set(),
|
||||
dialogState: {
|
||||
open: false,
|
||||
editing: undefined,
|
||||
parent: undefined,
|
||||
},
|
||||
formData: initialFormData,
|
||||
};
|
||||
|
||||
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(cat =>
|
||||
cat.id === action.payload.id
|
||||
? { ...cat, ...action.payload.updates, updatedAt: new Date().toISOString() }
|
||||
: cat
|
||||
),
|
||||
};
|
||||
|
||||
case 'DELETE_CATEGORY':
|
||||
return {
|
||||
...state,
|
||||
categories: state.categories.filter(cat => cat.id !== action.payload),
|
||||
};
|
||||
|
||||
case 'SET_SEARCH_KEYWORD':
|
||||
return { ...state, searchKeyword: action.payload };
|
||||
|
||||
case 'SET_TYPE_FILTER':
|
||||
return { ...state, typeFilter: action.payload };
|
||||
|
||||
case 'TOGGLE_EXPAND':
|
||||
const newExpanded = new Set(state.expandedIds);
|
||||
if (newExpanded.has(action.payload)) {
|
||||
newExpanded.delete(action.payload);
|
||||
} else {
|
||||
newExpanded.add(action.payload);
|
||||
}
|
||||
return { ...state, expandedIds: newExpanded };
|
||||
|
||||
case 'SET_DIALOG_STATE':
|
||||
return {
|
||||
...state,
|
||||
dialogState: action.payload,
|
||||
formData: action.payload.editing
|
||||
? {
|
||||
code: action.payload.editing.code,
|
||||
name: action.payload.editing.name,
|
||||
type: action.payload.editing.type,
|
||||
description: action.payload.editing.description || '',
|
||||
sortOrder: action.payload.editing.sortOrder,
|
||||
isActive: action.payload.editing.isActive,
|
||||
}
|
||||
: action.payload.parent
|
||||
? {
|
||||
...initialFormData,
|
||||
type: action.payload.parent.type,
|
||||
}
|
||||
: initialFormData,
|
||||
};
|
||||
|
||||
case 'SET_FORM_DATA':
|
||||
return {
|
||||
...state,
|
||||
formData: { ...state.formData, ...action.payload },
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
53
src/app/(app)/central-config/system/category/types.ts
Normal file
53
src/app/(app)/central-config/system/category/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// 分类字典类型定义
|
||||
export interface CategoryDictionary {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
type: string; // 分类类型:industry, equipment, crop等
|
||||
parentId?: string;
|
||||
level: number;
|
||||
sortOrder: number;
|
||||
description?: string;
|
||||
isActive: boolean;
|
||||
children?: CategoryDictionary[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type CategoryType = 'industry' | 'equipment' | 'crop' | 'operation' | 'other';
|
||||
|
||||
// 分类表单数据
|
||||
export interface CategoryFormData {
|
||||
code: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
sortOrder: number;
|
||||
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_TYPE_FILTER'; payload: string }
|
||||
| { type: 'TOGGLE_EXPAND'; payload: string }
|
||||
| { type: 'SET_DIALOG_STATE'; payload: { open: boolean; editing?: CategoryDictionary; parent?: CategoryDictionary | null } }
|
||||
| { type: 'SET_FORM_DATA'; payload: Partial<CategoryFormData> };
|
||||
|
||||
// 分类状态
|
||||
export interface CategoryState {
|
||||
categories: CategoryDictionary[];
|
||||
searchKeyword: string;
|
||||
typeFilter: string;
|
||||
expandedIds: Set<string>;
|
||||
dialogState: {
|
||||
open: boolean;
|
||||
editing?: CategoryDictionary;
|
||||
parent?: CategoryDictionary | null;
|
||||
};
|
||||
formData: CategoryFormData;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
385
src/app/(app)/central-config/system/dictionary/page.tsx
Normal file
385
src/app/(app)/central-config/system/dictionary/page.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
'use client';
|
||||
|
||||
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() {
|
||||
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 (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* 字典列表 */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
109
src/app/(app)/central-config/system/dictionary/reducer.ts
Normal file
109
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;
|
||||
}
|
||||
}
|
||||
67
src/app/(app)/central-config/system/dictionary/types.ts
Normal file
67
src/app/(app)/central-config/system/dictionary/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
26
src/app/(app)/central-config/system/page.tsx
Normal file
26
src/app/(app)/central-config/system/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function SystemPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">系统参数</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Link href="/central-config/system/settings" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">系统设置</h3>
|
||||
<p className="text-gray-600 text-sm">配置系统基本参数</p>
|
||||
</Link>
|
||||
<Link href="/central-config/system/category" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">分类字典</h3>
|
||||
<p className="text-gray-600 text-sm">管理分类字典</p>
|
||||
</Link>
|
||||
<Link href="/central-config/system/dictionary" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">数据字典</h3>
|
||||
<p className="text-gray-600 text-sm">管理数据字典</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface CopyrightInfoCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function CopyrightInfoCard({ settings, onSettingsChange }: CopyrightInfoCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">版权信息</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>ICP备案号</Label>
|
||||
<Input
|
||||
value={settings.icp || ''}
|
||||
onChange={(e) => onSettingsChange({ icp: e.target.value })}
|
||||
placeholder="京ICP备12345678号"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>版权声明</Label>
|
||||
<Input
|
||||
value={settings.copyright || ''}
|
||||
onChange={(e) => onSettingsChange({ copyright: e.target.value })}
|
||||
placeholder="© 2024 公司名称 版权所有"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface FeatureToggleCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function FeatureToggleCard({ settings, onSettingsChange }: FeatureToggleCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">功能开关</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>允许用户注册</Label>
|
||||
<p className="text-sm text-muted-foreground">开启后允许新用户自主注册账号</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.enableRegistration}
|
||||
onCheckedChange={(checked) => onSettingsChange({ enableRegistration: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>允许访客访问</Label>
|
||||
<p className="text-sm text-muted-foreground">开启后允许未登录用户访问部分公开内容</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.enableGuestAccess}
|
||||
onCheckedChange={(checked) => onSettingsChange({ enableGuestAccess: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface PasswordPolicyCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function PasswordPolicyCard({ settings, onSettingsChange }: PasswordPolicyCardProps) {
|
||||
const updatePasswordPolicy = (updates: Partial<SystemSettings['passwordPolicy']>) => {
|
||||
onSettingsChange({
|
||||
passwordPolicy: { ...settings.passwordPolicy, ...updates }
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">密码策略</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>最小密码长度</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.passwordPolicy.minLength}
|
||||
onChange={(e) => updatePasswordPolicy({ minLength: parseInt(e.target.value) || 8 })}
|
||||
min={6}
|
||||
max={32}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label>密码复杂度要求</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">要求包含大写字母</span>
|
||||
<Switch
|
||||
checked={settings.passwordPolicy.requireUppercase}
|
||||
onCheckedChange={(checked) => updatePasswordPolicy({ requireUppercase: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">要求包含小写字母</span>
|
||||
<Switch
|
||||
checked={settings.passwordPolicy.requireLowercase}
|
||||
onCheckedChange={(checked) => updatePasswordPolicy({ requireLowercase: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">要求包含数字</span>
|
||||
<Switch
|
||||
checked={settings.passwordPolicy.requireNumbers}
|
||||
onCheckedChange={(checked) => updatePasswordPolicy({ requireNumbers: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">要求包含特殊字符</span>
|
||||
<Switch
|
||||
checked={settings.passwordPolicy.requireSpecialChars}
|
||||
onCheckedChange={(checked) => updatePasswordPolicy({ requireSpecialChars: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface PlatformInfoCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function PlatformInfoCard({ settings, onSettingsChange }: PlatformInfoCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">平台信息</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>平台名称 *</Label>
|
||||
<Input
|
||||
value={settings.platformName}
|
||||
onChange={(e) => onSettingsChange({ platformName: e.target.value })}
|
||||
placeholder="请输入平台名称"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>公司名称</Label>
|
||||
<Input
|
||||
value={settings.companyName || ''}
|
||||
onChange={(e) => onSettingsChange({ companyName: e.target.value })}
|
||||
placeholder="请输入公司名称"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>联系邮箱</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={settings.contactEmail || ''}
|
||||
onChange={(e) => onSettingsChange({ contactEmail: e.target.value })}
|
||||
placeholder="support@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>联系电话</Label>
|
||||
<Input
|
||||
value={settings.contactPhone || ''}
|
||||
onChange={(e) => onSettingsChange({ contactPhone: e.target.value })}
|
||||
placeholder="400-888-8888"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label>公司地址</Label>
|
||||
<Input
|
||||
value={settings.address || ''}
|
||||
onChange={(e) => onSettingsChange({ address: e.target.value })}
|
||||
placeholder="请输入公司地址"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface RegionalSettingsCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function RegionalSettingsCard({ settings, onSettingsChange }: RegionalSettingsCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">区域与语言</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>语言</Label>
|
||||
<Select
|
||||
value={settings.language}
|
||||
onValueChange={(value) => onSettingsChange({ language: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zh-CN">简体中文</SelectItem>
|
||||
<SelectItem value="zh-TW">繁體中文</SelectItem>
|
||||
<SelectItem value="en-US">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>时区</Label>
|
||||
<Select
|
||||
value={settings.timezone}
|
||||
onValueChange={(value) => onSettingsChange({ timezone: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Asia/Shanghai">中国标准时间 (UTC+8)</SelectItem>
|
||||
<SelectItem value="Asia/Tokyo">日本标准时间 (UTC+9)</SelectItem>
|
||||
<SelectItem value="America/New_York">美国东部时间 (UTC-5)</SelectItem>
|
||||
<SelectItem value="Europe/London">格林威治标准时间 (UTC+0)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>日期格式</Label>
|
||||
<Select
|
||||
value={settings.dateFormat}
|
||||
onValueChange={(value) => onSettingsChange({ dateFormat: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="YYYY-MM-DD">2024-10-14</SelectItem>
|
||||
<SelectItem value="DD/MM/YYYY">14/10/2024</SelectItem>
|
||||
<SelectItem value="MM/DD/YYYY">10/14/2024</SelectItem>
|
||||
<SelectItem value="YYYY年MM月DD日">2024年10月14日</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface SessionManagementCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function SessionManagementCard({ settings, onSettingsChange }: SessionManagementCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">会话管理</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>会话超时时间(分钟)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.sessionTimeout}
|
||||
onChange={(e) => onSettingsChange({ sessionTimeout: parseInt(e.target.value) || 30 })}
|
||||
min={5}
|
||||
max={1440}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
用户无操作超过此时间后将自动退出登录
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>最大登录尝试次数</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.maxLoginAttempts}
|
||||
onChange={(e) => onSettingsChange({ maxLoginAttempts: parseInt(e.target.value) || 5 })}
|
||||
min={3}
|
||||
max={10}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
连续登录失败达到此次数后账号将被临时锁定
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Settings } from 'lucide-react'
|
||||
|
||||
export function SettingsInfoCard() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="text-blue-900 mb-2">
|
||||
<Settings className="w-4 h-4 inline mr-2" />
|
||||
设置说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800">
|
||||
<li>• 平台名称和Logo将显示在系统导航栏和登录页面</li>
|
||||
<li>• 系统公告会在登录后首页显著位置展示</li>
|
||||
<li>• 密码策略设置将在用户创建或修改密码时生效</li>
|
||||
<li>• 会话超时设置可提高系统安全性,防止账号被盗用</li>
|
||||
<li>• 修改设置后需要点击"保存设置"按钮才会生效</li>
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface SystemAnnouncementCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function SystemAnnouncementCard({ settings, onSettingsChange }: SystemAnnouncementCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">系统公告</h3>
|
||||
<Textarea
|
||||
value={settings.systemAnnouncement || ''}
|
||||
onChange={(e) => onSettingsChange({ systemAnnouncement: e.target.value })}
|
||||
placeholder="输入系统公告内容,将显示在登录页面"
|
||||
rows={4}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { PlatformInfoCard } from './PlatformInfoCard'
|
||||
export { SystemAnnouncementCard } from './SystemAnnouncementCard'
|
||||
export { CopyrightInfoCard } from './CopyrightInfoCard'
|
||||
export { FeatureToggleCard } from './FeatureToggleCard'
|
||||
export { SessionManagementCard } from './SessionManagementCard'
|
||||
export { PasswordPolicyCard } from './PasswordPolicyCard'
|
||||
export { RegionalSettingsCard } from './RegionalSettingsCard'
|
||||
export { SettingsInfoCard } from './SettingsInfoCard'
|
||||
168
src/app/(app)/central-config/system/settings/page.tsx
Normal file
168
src/app/(app)/central-config/system/settings/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
import { Save, RefreshCw, Info, Shield, Globe } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// Import modular components
|
||||
import {
|
||||
PlatformInfoCard,
|
||||
SystemAnnouncementCard,
|
||||
CopyrightInfoCard,
|
||||
FeatureToggleCard,
|
||||
SessionManagementCard,
|
||||
PasswordPolicyCard,
|
||||
RegionalSettingsCard,
|
||||
SettingsInfoCard
|
||||
} from './components'
|
||||
|
||||
export default function SystemSettingsPage() {
|
||||
const [settings, setSettings] = useState<SystemSettings>({
|
||||
platformName: '智慧农业生产管理系统',
|
||||
platformLogo: '',
|
||||
systemAnnouncement: '欢迎使用智慧农业生产管理系统!',
|
||||
contactEmail: 'support@smart-agriculture.com',
|
||||
contactPhone: '400-888-8888',
|
||||
address: '北京市海淀区中关村大街1号',
|
||||
companyName: '智慧农业科技有限公司',
|
||||
icp: '京ICP备12345678号',
|
||||
copyright: '© 2024 智慧农业科技有限公司 版权所有',
|
||||
enableRegistration: true,
|
||||
enableGuestAccess: false,
|
||||
sessionTimeout: 30,
|
||||
maxLoginAttempts: 5,
|
||||
passwordPolicy: {
|
||||
minLength: 8,
|
||||
requireUppercase: true,
|
||||
requireLowercase: true,
|
||||
requireNumbers: true,
|
||||
requireSpecialChars: false,
|
||||
},
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
timezone: 'Asia/Shanghai',
|
||||
language: 'zh-CN',
|
||||
})
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [])
|
||||
|
||||
const loadSettings = () => {
|
||||
const data = localStorage.getItem('smart_agriculture_system_settings')
|
||||
if (data) {
|
||||
setSettings(JSON.parse(data))
|
||||
} else {
|
||||
saveSettings(settings)
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = (newSettings: SystemSettings) => {
|
||||
localStorage.setItem('smart_agriculture_system_settings', JSON.stringify(newSettings))
|
||||
setSettings(newSettings)
|
||||
setHasChanges(false)
|
||||
toast.success('系统设置已保存')
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
saveSettings(settings)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
loadSettings()
|
||||
setHasChanges(false)
|
||||
toast.info('已恢复到上次保存的设置')
|
||||
}
|
||||
|
||||
const updateSettings = (updates: Partial<SystemSettings>) => {
|
||||
setSettings({ ...settings, ...updates })
|
||||
setHasChanges(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800">系统设置</h2>
|
||||
<p className="text-muted-foreground">配置系统全局基础参数和个性化设置</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{hasChanges && (
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
重置
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSave} disabled={!hasChanges}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="basic" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="basic">
|
||||
<Info className="w-4 h-4 mr-2" />
|
||||
基本设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security">
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
安全设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="regional">
|
||||
<Globe className="w-4 h-4 mr-2" />
|
||||
区域设置
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 基本设置 */}
|
||||
<TabsContent value="basic" className="space-y-4">
|
||||
<PlatformInfoCard
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
/>
|
||||
<SystemAnnouncementCard
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
/>
|
||||
<CopyrightInfoCard
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
/>
|
||||
<FeatureToggleCard
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 安全设置 */}
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
<SessionManagementCard
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
/>
|
||||
<PasswordPolicyCard
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 区域设置 */}
|
||||
<TabsContent value="regional" className="space-y-4">
|
||||
<RegionalSettingsCard
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 设置预览 */}
|
||||
<SettingsInfoCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user