子仓库提交

This commit is contained in:
2025-11-10 09:19:56 +08:00
parent 62f92213f7
commit 5feb24e4e2
733 changed files with 141413 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
"use client"
import { ReactNode } from 'react'
// import {SideBarOld} from '@/components/layouts/SideBar/SideBarOld'
export default function CentralConfigLayout({
children,
}: {
children: ReactNode
}) {
return <>{children}</>
}

View File

@@ -0,0 +1,64 @@
'use client';
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 MessageLogFilterProps {
searchKeyword: string;
typeFilter: string;
statusFilter: string;
onSearchChange: (value: string) => void;
onTypeChange: (value: string) => void;
onStatusChange: (value: string) => void;
}
export function MessageLogFilter({
searchKeyword,
typeFilter,
statusFilter,
onSearchChange,
onTypeChange,
onStatusChange,
}: MessageLogFilterProps) {
return (
<Card className="p-4">
<div className="grid grid-cols-1 md:grid-cols-3 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={onTypeChange}>
<SelectTrigger>
<SelectValue placeholder="消息类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="sms"></SelectItem>
<SelectItem value="email"></SelectItem>
<SelectItem value="internal"></SelectItem>
<SelectItem value="push"></SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger>
<SelectValue placeholder="发送状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="sent"></SelectItem>
<SelectItem value="read"></SelectItem>
<SelectItem value="failed"></SelectItem>
<SelectItem value="pending"></SelectItem>
</SelectContent>
</Select>
</div>
</Card>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import { Card } from '@/components/ui/card';
import { MessageLog } from '@/types/message';
interface MessageLogStatsProps {
logs: MessageLog[];
}
export function MessageLogStats({ logs }: MessageLogStatsProps) {
const stats = [
{
label: '总消息数',
value: logs.length,
color: 'text-blue-600',
},
{
label: '已发送',
value: logs.filter(l => l.status === 'sent' || l.status === 'read').length,
color: 'text-green-600',
},
{
label: '已读',
value: logs.filter(l => l.status === 'read').length,
color: 'text-purple-600',
},
{
label: '发送失败',
value: logs.filter(l => l.status === 'failed').length,
color: 'text-red-600',
},
];
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{stats.map((stat, index) => (
<Card key={index} className="p-4">
<div className="text-sm text-muted-foreground">{stat.label}</div>
<div className={`mt-2 text-2xl font-bold ${stat.color}`}>{stat.value}</div>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { MessageLog } from '@/types/message';
import {
Mail,
MessageSquare,
Smartphone,
Bell,
CheckCircle,
XCircle,
Clock
} from 'lucide-react';
interface MessageLogTableProps {
logs: MessageLog[];
}
export function MessageLogTable({ logs }: MessageLogTableProps) {
const getTypeIcon = (type: string) => {
switch (type) {
case 'sms': return <Smartphone className="w-4 h-4" />;
case 'email': return <Mail className="w-4 h-4" />;
case 'internal': return <MessageSquare className="w-4 h-4" />;
case 'push': return <Bell className="w-4 h-4" />;
default: return <MessageSquare className="w-4 h-4" />;
}
};
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
sms: '短信',
email: '邮件',
internal: '站内信',
push: '推送',
};
return labels[type] || type;
};
const getTypeBadge = (type: string) => {
const colors: Record<string, string> = {
sms: 'bg-blue-100 text-blue-700',
email: 'bg-purple-100 text-purple-700',
internal: 'bg-green-100 text-green-700',
push: 'bg-orange-100 text-orange-700',
};
return colors[type] || 'bg-gray-100 text-gray-700';
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'sent':
return { icon: <CheckCircle className="w-3 h-3" />, label: '已发送', className: 'bg-green-100 text-green-700' };
case 'read':
return { icon: <CheckCircle className="w-3 h-3" />, label: '已读', className: 'bg-blue-100 text-blue-700' };
case 'failed':
return { icon: <XCircle className="w-3 h-3" />, label: '失败', className: 'bg-red-100 text-red-700' };
case 'pending':
return { icon: <Clock className="w-3 h-3" />, label: '待发送', className: 'bg-yellow-100 text-yellow-700' };
default:
return { icon: <Clock className="w-3 h-3" />, label: status, className: 'bg-gray-100 text-gray-700' };
}
};
const formatDateTime = (dateTime: string | undefined) => {
if (!dateTime) return '-';
return dateTime;
};
return (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>/</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
</TableCell>
</TableRow>
) : (
logs.map((log) => {
const statusBadge = getStatusBadge(log.status);
// 调试信息
console.log('Log data:', log);
console.log('Type:', log.type, 'Badge color:', getTypeBadge(log.type));
return (
<TableRow key={log.id}>
<TableCell className="text-sm text-muted-foreground">
{formatDateTime(log.sentTime)}
</TableCell>
<TableCell>
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getTypeBadge(log.type)}`}>
{getTypeIcon(log.type)}
<span>{getTypeLabel(log.type)}</span>
</div>
</TableCell>
<TableCell>
<div>
<div>{log.recipientName || '-'}</div>
<div className="text-xs text-muted-foreground">{log.recipient}</div>
</div>
</TableCell>
<TableCell className="max-w-xs">
{log.subject && (
<div className="mb-1 font-medium">{log.subject}</div>
)}
<div className="text-sm text-muted-foreground truncate">
{log.content}
</div>
</TableCell>
<TableCell>
<div>
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${statusBadge.className}`}>
{statusBadge.icon}
<span>{statusBadge.label}</span>
</div>
{log.status === 'read' && log.readTime && (
<p className="text-xs text-muted-foreground mt-1">
{formatDateTime(log.readTime)}
</p>
)}
{log.status === 'failed' && log.failReason && (
<p className="text-xs text-red-600 mt-1">{log.failReason}</p>
)}
</div>
</TableCell>
<TableCell>
<div className="inline-flex items-center px-2 py-1 rounded-full text-xs border border-gray-300 bg-gray-50">
{log.retryCount}
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</Card>
);
}

View File

@@ -0,0 +1,115 @@
import { MessageLog } from '@/types/message';
export const mockMessageLogs: MessageLog[] = [
{
id: 'msg-1',
templateId: 'tpl-1',
templateName: '任务分配通知',
type: 'internal',
recipient: 'user-2',
recipientName: '张三',
subject: '新任务分配',
content: '您好张三您有新的作业任务小麦播种作业计划执行时间2024-10-15 08:00。请及时查看并准备。',
status: 'sent',
sentTime: '2024-10-14 09:30:00',
readTime: '2024-10-14 10:15:00',
retryCount: 0,
variables: {
username: '张三',
taskName: '小麦播种作业',
executeTime: '2024-10-15 08:00',
},
},
{
id: 'msg-2',
templateId: 'tpl-2',
templateName: '设备预警通知',
type: 'sms',
recipient: '13800138000',
recipientName: '李四',
content: '【智慧农业】设备预警:约翰迪尔拖拉机检测到异常,发动机温度过高,请及时处理。',
status: 'sent',
sentTime: '2024-10-14 09:30:00',
retryCount: 0,
},
{
id: 'msg-3',
templateId: 'tpl-3',
templateName: '保养提醒',
type: 'email',
recipient: 'wangwu@example.com',
recipientName: '王五',
subject: '设备保养提醒',
content: '尊敬的用户:\n\n您的设备约翰迪尔拖拉机编号JD-001已使用500小时建议进行保养维护...',
status: 'sent',
sentTime: '2024-10-14 09:30:00',
retryCount: 0,
},
{
id: 'msg-4',
templateId: 'tpl-4',
templateName: '任务完成通知',
type: 'push',
recipient: 'user-2',
recipientName: '张三',
subject: '任务完成',
content: '作业任务小麦播种作业已完成作业面积50亩耗时3小时。',
status: 'read',
sentTime: '2024-10-14 09:30:00',
readTime: '2024-10-14 10:15:00',
retryCount: 0,
},
{
id: 'msg-5',
templateId: 'tpl-5',
templateName: '验证码',
type: 'sms',
recipient: '13900139000',
recipientName: '赵六',
content: '【智慧农业】验证码123456有效期5分钟。',
status: 'failed',
sentTime: '2024-10-14 09:30:00',
failReason: '手机号码格式错误',
retryCount: 2,
},
{
id: 'msg-6',
templateId: 'tpl-6',
templateName: '系统维护通知',
type: 'internal',
recipient: 'user-3',
recipientName: '钱七',
subject: '系统维护通知',
content: '系统将于今晚22:00-24:00进行维护期间可能影响部分功能使用。',
status: 'pending',
sentTime: '2024-10-15 14:20:00',
retryCount: 0,
},
{
id: 'msg-7',
templateId: 'tpl-7',
templateName: '天气预警',
type: 'push',
recipient: 'user-4',
recipientName: '孙八',
subject: '天气预警',
content: '未来24小时将有暴雨请注意防范做好农田排水工作。',
status: 'sent',
sentTime: '2024-10-15 14:20:00',
retryCount: 0,
},
{
id: 'msg-8',
templateId: 'tpl-8',
templateName: '作业报告',
type: 'email',
recipient: 'manager@example.com',
recipientName: '管理员',
subject: '每日作业报告',
content: '今日完成播种作业100亩施肥作业50亩灌溉作业200亩。',
status: 'read',
sentTime: '2024-10-15 14:20:00',
readTime: '2024-10-15 15:30:00',
retryCount: 0,
}
];

View File

@@ -0,0 +1,139 @@
'use client';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Download, MessageSquare, RefreshCw } from 'lucide-react';
import { MessageLogStats } from './components/MessageLogStats';
import { MessageLogFilter } from './components/MessageLogFilter';
import { MessageLogTable } from './components/MessageLogTable';
import { MessageLog } from '@/types/message';
import { mockMessageLogs } from './mock/mockData';
export default function MessageLogPage() {
const [logs, setLogs] = useState<MessageLog[]>([]);
const [filteredLogs, setFilteredLogs] = useState<MessageLog[]>([]);
const [searchKeyword, setSearchKeyword] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [loading, setLoading] = useState(false);
useEffect(() => {
loadLogs();
}, []);
useEffect(() => {
applyFilters();
}, [logs, searchKeyword, typeFilter, statusFilter]);
const loadLogs = async () => {
setLoading(true);
try {
// 模拟API延迟
await new Promise(resolve => setTimeout(resolve, 500));
setLogs(mockMessageLogs);
} catch (error) {
console.error('Failed to load message logs:', error);
toast.error('加载消息日志失败');
} finally {
setLoading(false);
}
};
const applyFilters = () => {
let filtered = logs;
// 调试信息
console.log('All logs:', logs);
console.log('Filters:', { searchKeyword, typeFilter, statusFilter });
if (searchKeyword) {
filtered = filtered.filter(log =>
(log.recipientName && log.recipientName.includes(searchKeyword)) ||
log.recipient.includes(searchKeyword) ||
log.content.includes(searchKeyword)
);
}
if (typeFilter !== 'all') {
filtered = filtered.filter(log => log.type === typeFilter);
}
if (statusFilter !== 'all') {
filtered = filtered.filter(log => log.status === statusFilter);
}
filtered.sort((a, b) => b.sentTime.localeCompare(a.sentTime));
setFilteredLogs(filtered);
};
const handleExport = async () => {
try {
const dataStr = JSON.stringify(filteredLogs, 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 = `message_logs_${new Date().getTime()}.json`;
link.click();
toast.success('导出成功');
} catch (error) {
console.error('Failed to export logs:', error);
toast.error('导出失败');
}
};
const handleRefresh = () => {
loadLogs();
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800 text-2xl font-bold"></h2>
<p className="text-muted-foreground"></p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleRefresh} disabled={loading}>
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleExport} disabled={loading}>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
<MessageLogStats logs={filteredLogs} />
<MessageLogFilter
searchKeyword={searchKeyword}
typeFilter={typeFilter}
statusFilter={statusFilter}
onSearchChange={setSearchKeyword}
onTypeChange={setTypeFilter}
onStatusChange={setStatusFilter}
/>
<MessageLogTable logs={filteredLogs} />
<Card className="p-4 bg-blue-50 border-blue-200">
<h4 className="text-blue-900 mb-2 font-semibold">
<MessageSquare className="w-4 h-4 inline mr-2" />
</h4>
<ul className="space-y-1 text-sm text-blue-800">
<li> </li>
<li> 便</li>
<li> 便</li>
<li> </li>
<li> </li>
</ul>
</Card>
</div>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import React from 'react';
import Link from 'next/link';
export default function MessagePage() {
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/message/send" 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/message/template" 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/message/log" 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>
);
}

View File

@@ -0,0 +1,17 @@
import { Card } from '@/components/ui/card';
export function MessageInstructions() {
return (
<Card className="p-4 bg-blue-50 border-blue-200">
<h4 className="text-blue-900 mb-2"></h4>
<ul className="space-y-1 text-sm text-blue-800">
<li> </li>
<li> </li>
<li> </li>
<li> 使</li>
<li> </li>
<li> </li>
</ul>
</Card>
);
}

View File

@@ -0,0 +1,122 @@
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { MessageSendRecord } from '@/types/message';
interface MessagePreviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
record: MessageSendRecord | null;
getTypeIcon: (type: string) => JSX.Element;
getTypeLabel: (type: string) => string;
getTypeBadge: (type: string) => string;
getStatusBadge: (status: string) => JSX.Element;
}
export function MessagePreviewDialog({
open,
onOpenChange,
record,
getTypeIcon,
getTypeLabel,
getTypeBadge,
getStatusBadge
}: MessagePreviewDialogProps) {
if (!record) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription className="sr-only">
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label></Label>
<div className="field-value-inline">{record.templateName}</div>
</div>
<div>
<Label></Label>
<div className="mt-2">
<Badge className={getTypeBadge(record.type)}>
<span className="flex items-center gap-1">
{getTypeIcon(record.type)}
{getTypeLabel(record.type)}
</span>
</Badge>
</div>
</div>
<div>
<Label></Label>
<div className="field-value-inline">
{record.sendType === 'immediate' ? '实时发送' : '定时发送'}
</div>
</div>
<div>
<Label></Label>
<div className="mt-2">
{getStatusBadge(record.status)}
</div>
</div>
{record.scheduledTime && (
<div>
<Label></Label>
<div className="field-value-inline">
{format(new Date(record.scheduledTime), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
</div>
</div>
)}
<div>
<Label></Label>
<div className="field-value-inline">
{format(new Date(record.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
</div>
</div>
</div>
{record.subject && (
<div>
<Label></Label>
<div className="field-value-inline">{record.subject}</div>
</div>
)}
<div>
<Label> {record.recipientCount} </Label>
<Card className="p-3 bg-gray-50 mt-2">
<div className="flex flex-wrap gap-2">
{record.recipients.map((recipient, index) => (
<Badge key={index} variant="outline">
{recipient}
</Badge>
))}
</div>
</Card>
</div>
<div>
<Label></Label>
<Card className="p-4 bg-blue-50 border-blue-200 mt-2">
<pre className="text-sm whitespace-pre-wrap">
{record.content}
</pre>
</Card>
</div>
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,154 @@
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
Send,
Clock,
Users,
Eye,
Trash2,
CheckCircle2,
XCircle,
Timer
} from 'lucide-react';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { MessageSendRecord } from '@/types/message';
interface MessageSendTableProps {
sendRecords: MessageSendRecord[];
onPreview: (record: MessageSendRecord) => void;
onCancel: (id: string) => void;
onDelete: (id: string) => void;
getTypeIcon: (type: string) => JSX.Element;
getTypeLabel: (type: string) => string;
getTypeBadge: (type: string) => string;
getStatusBadge: (status: string) => JSX.Element;
}
export function MessageSendTable({
sendRecords,
onPreview,
onCancel,
onDelete,
getTypeIcon,
getTypeLabel,
getTypeBadge,
getStatusBadge
}: MessageSendTableProps) {
return (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sendRecords.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
</TableCell>
</TableRow>
) : (
sendRecords.map((record) => (
<TableRow key={record.id}>
<TableCell>
<div>{record.templateName}</div>
{record.subject && (
<p className="text-xs text-muted-foreground">{record.subject}</p>
)}
</TableCell>
<TableCell>
<Badge className={getTypeBadge(record.type)}>
<span className="flex items-center gap-1">
{getTypeIcon(record.type)}
{getTypeLabel(record.type)}
</span>
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="w-4 h-4 text-muted-foreground" />
<span>{record.recipientCount}</span>
</div>
</TableCell>
<TableCell>
{record.sendType === 'immediate' ? (
<Badge variant="outline">
<Send className="w-3 h-3 mr-1" />
</Badge>
) : (
<div>
<Badge variant="outline">
<Clock className="w-3 h-3 mr-1" />
</Badge>
{record.scheduledTime && (
<p className="text-xs text-muted-foreground mt-1">
{format(new Date(record.scheduledTime), 'MM-dd HH:mm', { locale: zhCN })}
</p>
)}
</div>
)}
</TableCell>
<TableCell>
{getStatusBadge(record.status)}
{record.status === 'sent' && (
<p className="text-xs text-muted-foreground mt-1">
{record.sentCount}/{record.recipientCount}
</p>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{format(new Date(record.createdAt), 'MM-dd HH:mm', { locale: zhCN })}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onPreview(record)}
title="查看详情"
>
<Eye className="w-4 h-4" />
</Button>
{record.status === 'pending' && (
<Button
variant="ghost"
size="sm"
onClick={() => onCancel(record.id)}
title="取消发送"
>
<XCircle className="w-4 h-4 text-orange-600" />
</Button>
)}
{(record.status === 'sent' || record.status === 'cancelled') && (
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(record.id)}
title="删除记录"
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
);
}

View File

@@ -0,0 +1,42 @@
import { Card } from '@/components/ui/card';
import { MessageSendRecord } from '@/types/message';
interface MessageStatsCardsProps {
sendRecords: MessageSendRecord[];
}
export function MessageStatsCards({ sendRecords }: MessageStatsCardsProps) {
const stats = [
{
label: '总发送数',
value: sendRecords.length,
color: 'text-blue-600',
},
{
label: '已发送',
value: sendRecords.filter(r => r.status === 'sent').length,
color: 'text-green-600',
},
{
label: '待发送',
value: sendRecords.filter(r => r.status === 'pending').length,
color: 'text-yellow-600',
},
{
label: '已取消',
value: sendRecords.filter(r => r.status === 'cancelled').length,
color: 'text-gray-600',
},
];
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{stats.map((stat, index) => (
<Card key={index} className="p-4">
<div className="text-sm text-muted-foreground">{stat.label}</div>
<div className={`mt-2 text-2xl font-bold ${stat.color}`}>{stat.value}</div>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,258 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } 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 { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Send, Clock, CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { MessageTemplate } from '@/types/message';
import { MessageSendFormData } from '../types';
interface SendMessageDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
templates: MessageTemplate[];
formData: MessageSendFormData;
onFormDataChange: (data: MessageSendFormData) => void;
onSend: () => void;
getTypeIcon: (type: string) => JSX.Element;
getTypeLabel: (type: string) => string;
}
export function SendMessageDialog({
open,
onOpenChange,
templates,
formData,
onFormDataChange,
onSend,
getTypeIcon,
getTypeLabel
}: SendMessageDialogProps) {
const replaceVariables = (content: string, variables: Record<string, string>): string => {
let result = content;
Object.entries(variables).forEach(([key, value]) => {
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value || `{{${key}}}`);
});
return result;
};
const handleTemplateChange = (templateId: string) => {
const template = templates.find(t => t.id === templateId);
if (template) {
// 初始化变量
const vars: Record<string, string> = {};
template.variables.forEach(v => {
vars[v] = '';
});
onFormDataChange({
...formData,
templateId,
type: template.type,
subject: template.subject || '',
content: template.content,
variables: vars,
});
}
};
const selectedTemplate = templates.find(t => t.id === formData.templateId);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
<div className="flex items-center gap-2">
<Send className="w-5 h-5 text-green-600" />
</div>
</DialogTitle>
<DialogDescription className="sr-only">
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 选择模版 */}
<div>
<Label> *</Label>
<Select value={formData.templateId} onValueChange={handleTemplateChange}>
<SelectTrigger>
<SelectValue placeholder="请选择消息模版" />
</SelectTrigger>
<SelectContent>
{templates.filter(t => t.isActive).map(template => (
<SelectItem key={template.id} value={template.id}>
<div className="flex items-center gap-2">
{getTypeIcon(template.type)}
<span>{template.name}</span>
<Badge variant="outline" className="text-xs">
{getTypeLabel(template.type)}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 发送方式 */}
<div>
<Label> *</Label>
<Select
value={formData.sendType}
onValueChange={(value: 'immediate' | 'scheduled') => onFormDataChange({ ...formData, sendType: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="immediate">
<div className="flex items-center gap-2">
<Send className="w-4 h-4" />
</div>
</SelectItem>
<SelectItem value="scheduled">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 定时发送设置 */}
{formData.sendType === 'scheduled' && (
<div className="grid grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start">
<CalendarIcon className="w-4 h-4 mr-2" />
{formData.scheduledDate ? (
format(formData.scheduledDate, 'yyyy年MM月dd日', { locale: zhCN })
) : (
'选择日期'
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={formData.scheduledDate}
onSelect={(date) => onFormDataChange({ ...formData, scheduledDate: date })}
locale={zhCN}
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
/>
</PopoverContent>
</Popover>
</div>
<div>
<Label> *</Label>
<Input
type="time"
value={formData.scheduledTime}
onChange={(e) => onFormDataChange({ ...formData, scheduledTime: e.target.value })}
/>
</div>
</div>
)}
{/* 接收人 */}
<div>
<Label> *</Label>
<Textarea
value={formData.recipients}
onChange={(e) => onFormDataChange({ ...formData, recipients: e.target.value })}
placeholder={
formData.type === 'sms' ? '输入手机号,多个用逗号或换行分隔' :
formData.type === 'email' ? '输入邮箱地址,多个用逗号或换行分隔' :
formData.type === 'push' ? '输入设备ID或用户ID多个用逗号或换行分隔' :
'输入用户名,多个用逗号或换行分隔'
}
rows={3}
/>
<p className="text-xs text-muted-foreground mt-1">
使
</p>
</div>
{/* 消息主题(邮件和推送) */}
{(formData.type === 'email' || formData.type === 'push') && (
<div>
<Label></Label>
<Input
value={formData.subject}
onChange={(e) => onFormDataChange({ ...formData, subject: e.target.value })}
placeholder="输入消息主题"
/>
</div>
)}
{/* 变量填写 */}
{selectedTemplate && selectedTemplate.variables.length > 0 && (
<div>
<Label> *</Label>
<Card className="p-4 bg-gray-50">
<div className="grid grid-cols-2 gap-4">
{selectedTemplate.variables.map(variable => (
<div key={variable}>
<Label htmlFor={`var-${variable}`} className="text-xs">
{variable}
</Label>
<Input
id={`var-${variable}`}
value={formData.variables[variable] || ''}
onChange={(e) => onFormDataChange({
...formData,
variables: {
...formData.variables,
[variable]: e.target.value,
},
})}
placeholder={`输入 ${variable}`}
className="mt-1"
/>
</div>
))}
</div>
</Card>
</div>
)}
{/* 消息内容预览 */}
{formData.content && (
<div>
<Label></Label>
<Card className="p-4 bg-blue-50 border-blue-200">
<pre className="text-sm whitespace-pre-wrap">
{replaceVariables(formData.content, formData.variables)}
</pre>
</Card>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={onSend} className="bg-green-600 hover:bg-green-700">
<Send className="w-4 h-4 mr-2" />
{formData.sendType === 'immediate' ? '立即发送' : '创建定时任务'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,5 @@
export { MessageStatsCards } from './MessageStatsCards';
export { MessageSendTable } from './MessageSendTable';
export { SendMessageDialog } from './SendMessageDialog';
export { MessagePreviewDialog } from './MessagePreviewDialog';
export { MessageInstructions } from './MessageInstructions';

View File

@@ -0,0 +1,493 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Send, Mail, MessageSquare, Bell, Smartphone, CheckCircle2, XCircle, Timer } from 'lucide-react';
import { toast } from 'sonner';
import { MessageTemplate, MessageSendRecord } from '@/types/message';
import { MessageSendFormData } from './types';
import {
MessageStatsCards,
MessageSendTable,
SendMessageDialog,
MessagePreviewDialog,
MessageInstructions
} from './components';
// API服务函数
const messageApi = {
// 获取消息模板
getTemplates: async (): Promise<MessageTemplate[]> => {
try {
const response = await fetch('/api/message/templates');
if (!response.ok) {
throw new Error('Failed to fetch message templates');
}
return await response.json();
} catch (error) {
console.warn('Failed to fetch message templates, using mock data:', error);
return getMockTemplates();
}
},
// 获取发送记录
getSendRecords: async (): Promise<MessageSendRecord[]> => {
try {
const response = await fetch('/api/message/send-records');
if (!response.ok) {
throw new Error('Failed to fetch send records');
}
return await response.json();
} catch (error) {
console.warn('Failed to fetch send records, using mock data:', error);
return getMockSendRecords();
}
},
// 发送消息
sendMessage: async (data: MessageSendFormData): Promise<MessageSendRecord> => {
try {
const response = await fetch('/api/message/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to send message');
}
return await response.json();
} catch (error) {
console.warn('Failed to send message, using mock response:', error);
return createMockSendRecord(data);
}
},
// 取消定时消息
cancelMessage: async (id: string): Promise<void> => {
try {
const response = await fetch(`/api/message/send/${id}/cancel`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to cancel message');
}
} catch (error) {
console.warn('Failed to cancel message, updating local state:', error);
// 模拟取消操作
}
},
// 删除发送记录
deleteMessage: async (id: string): Promise<void> => {
try {
const response = await fetch(`/api/message/send/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete message');
}
} catch (error) {
console.warn('Failed to delete message, updating local state:', error);
// 模拟删除操作
}
}
};
// 模拟数据生成函数
function getMockTemplates(): MessageTemplate[] {
return [
{
id: 'tpl-1',
code: 'TASK_ASSIGNMENT',
name: '任务分配通知',
type: 'internal',
subject: '新任务分配',
content: '您好,{{username}}!您有新的作业任务:{{taskName}},计划执行时间:{{executeTime}}。请及时查看并准备。',
variables: ['username', 'taskName', 'executeTime'],
isActive: true,
description: '向农机操作员分配新任务时发送',
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
createdBy: 'admin',
},
{
id: 'tpl-2',
code: 'EQUIPMENT_WARNING',
name: '设备预警通知',
type: 'sms',
content: '【智慧农业】设备预警:{{equipmentName}}检测到{{warningType}},请及时处理。',
variables: ['equipmentName', 'warningType'],
isActive: true,
description: '设备出现异常时发送预警',
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
createdBy: 'admin',
},
{
id: 'tpl-3',
code: 'MAINTENANCE_REMINDER',
name: '保养提醒',
type: 'email',
subject: '设备保养提醒',
content: '尊敬的用户:\n\n您的设备{{equipmentName}}(编号:{{equipmentNo}})已使用{{usageHours}}小时,建议进行保养维护。\n\n保养周期每{{maintenanceInterval}}小时\n上次保养时间{{lastMaintenanceTime}}\n\n请及时安排保养确保设备正常运行。',
variables: ['equipmentName', 'equipmentNo', 'usageHours', 'maintenanceInterval', 'lastMaintenanceTime'],
isActive: true,
description: '设备需要保养时发送提醒',
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
createdBy: 'admin',
},
];
}
function getMockSendRecords(): MessageSendRecord[] {
return [
{
id: 'send-1',
templateId: 'tpl-1',
templateName: '任务分配通知',
type: 'internal',
recipients: ['张三', '李四', '王五'],
recipientCount: 3,
subject: '新任务分配',
content: '您好张三您有新的作业任务冬小麦播种计划执行时间2024-10-16 08:00。请及时查看并准备。',
sendType: 'immediate',
status: 'sent',
sentCount: 3,
sentAt: '2024-10-15T14:30:00',
createdAt: '2024-10-15T14:30:00',
createdBy: 'admin',
},
{
id: 'send-2',
templateId: 'tpl-2',
templateName: '设备预警通知',
type: 'sms',
recipients: ['13800138001', '13900139002'],
recipientCount: 2,
content: '【智慧农业】设备预警拖拉机01检测到异常油温过高请及时处理。',
sendType: 'immediate',
status: 'sent',
sentCount: 2,
sentAt: '2024-10-15T10:15:00',
createdAt: '2024-10-15T10:15:00',
createdBy: 'admin',
},
{
id: 'send-3',
templateId: 'tpl-3',
templateName: '保养提醒',
type: 'email',
recipients: ['zhangsan@example.com', 'lisi@example.com'],
recipientCount: 2,
subject: '设备保养提醒',
content: '尊敬的用户:\n\n您的设备拖拉机01编号TR001已使用500小时建议进行保养维护。\n\n保养周期每500小时\n上次保养时间2024-09-01\n\n请及时安排保养确保设备正常运行。',
sendType: 'scheduled',
scheduledTime: '2024-10-16T09:00:00',
status: 'pending',
createdAt: '2024-10-15T15:00:00',
createdBy: 'admin',
},
];
}
function createMockSendRecord(data: MessageSendFormData): MessageSendRecord {
const now = new Date().toISOString();
const scheduledDateTime = data.sendType === 'scheduled' && data.scheduledDate
? new Date(data.scheduledDate.getFullYear(), data.scheduledDate.getMonth(), data.scheduledDate.getDate(),
parseInt(data.scheduledTime.split(':')[0]), parseInt(data.scheduledTime.split(':')[1])).toISOString()
: undefined;
// 解析接收人
const recipients = data.recipients.split(/[,;\n]/).map(r => r.trim()).filter(r => r);
// 替换变量生成最终内容
const replaceVariables = (content: string, variables: Record<string, string>): string => {
let result = content;
Object.entries(variables).forEach(([key, value]) => {
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value || `{{${key}}}`);
});
return result;
};
const finalContent = replaceVariables(data.content, data.variables);
const finalSubject = data.subject ? replaceVariables(data.subject, data.variables) : undefined;
return {
id: `send-${Date.now()}`,
templateId: data.templateId,
templateName: getMockTemplates().find(t => t.id === data.templateId)?.name || '',
type: data.type,
recipients,
recipientCount: recipients.length,
subject: finalSubject,
content: finalContent,
sendType: data.sendType,
scheduledTime: scheduledDateTime,
status: data.sendType === 'immediate' ? 'sent' : 'pending',
sentCount: data.sendType === 'immediate' ? recipients.length : undefined,
sentAt: data.sendType === 'immediate' ? now : undefined,
createdAt: now,
createdBy: 'admin',
};
}
export default function MessageSendPage() {
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
const [sendRecords, setSendRecords] = useState<MessageSendRecord[]>([]);
const [showSendDialog, setShowSendDialog] = useState(false);
const [showPreviewDialog, setShowPreviewDialog] = useState(false);
const [previewRecord, setPreviewRecord] = useState<MessageSendRecord | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<MessageSendFormData>({
templateId: '',
type: 'internal',
recipientType: 'manual',
recipients: '',
subject: '',
content: '',
sendType: 'immediate',
scheduledDate: undefined,
scheduledTime: '09:00',
variables: {},
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setError(null);
setLoading(true);
const [templatesData, recordsData] = await Promise.all([
messageApi.getTemplates(),
messageApi.getSendRecords()
]);
setTemplates(templatesData);
setSendRecords(recordsData);
} catch (err) {
setError(err instanceof Error ? err.message : '加载数据失败');
console.error('Failed to load data:', err);
} finally {
setLoading(false);
}
};
const handleOpenSendDialog = () => {
setFormData({
templateId: '',
type: 'internal',
recipientType: 'manual',
recipients: '',
subject: '',
content: '',
sendType: 'immediate',
scheduledDate: undefined,
scheduledTime: '09:00',
variables: {},
});
setShowSendDialog(true);
};
const handleSend = async () => {
try {
// 验证
if (!formData.templateId) {
toast.error('请选择消息模版');
return;
}
if (!formData.recipients.trim()) {
toast.error('请输入接收人');
return;
}
// 检查变量是否填写
const template = templates.find(t => t.id === formData.templateId);
if (template) {
const emptyVars = template.variables.filter(v => !formData.variables[v]?.trim());
if (emptyVars.length > 0) {
toast.error(`请填写变量:${emptyVars.join(', ')}`);
return;
}
}
if (formData.sendType === 'scheduled' && !formData.scheduledDate) {
toast.error('请选择定时发送日期');
return;
}
// 发送消息
const newRecord = await messageApi.sendMessage(formData);
setSendRecords([newRecord, ...sendRecords]);
if (formData.sendType === 'immediate') {
toast.success(`消息发送成功!已发送 ${newRecord.recipientCount} 条消息`);
} else {
toast.success(`定时消息已创建!将于 ${new Date(newRecord.scheduledTime!).toLocaleString('zh-CN')} 发送`);
}
setShowSendDialog(false);
} catch (err) {
toast.error('发送失败:' + (err instanceof Error ? err.message : '未知错误'));
}
};
const handlePreview = (record: MessageSendRecord) => {
setPreviewRecord(record);
setShowPreviewDialog(true);
};
const handleCancel = async (id: string) => {
if (!confirm('确定要取消该定时消息吗?')) return;
try {
await messageApi.cancelMessage(id);
setSendRecords(sendRecords.map(r =>
r.id === id ? { ...r, status: 'cancelled' as const } : r
));
toast.success('已取消定时消息');
} catch (err) {
toast.error('取消失败:' + (err instanceof Error ? err.message : '未知错误'));
}
};
const handleDelete = async (id: string) => {
if (!confirm('确定要删除该发送记录吗?')) return;
try {
await messageApi.deleteMessage(id);
setSendRecords(sendRecords.filter(r => r.id !== id));
toast.success('删除成功');
} catch (err) {
toast.error('删除失败:' + (err instanceof Error ? err.message : '未知错误'));
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'sms': return <Smartphone className="w-4 h-4" />;
case 'email': return <Mail className="w-4 h-4" />;
case 'internal': return <MessageSquare className="w-4 h-4" />;
case 'push': return <Bell className="w-4 h-4" />;
default: return <MessageSquare className="w-4 h-4" />;
}
};
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
sms: '短信',
email: '邮件',
internal: '站内信',
push: '推送',
};
return labels[type] || type;
};
const getTypeBadge = (type: string) => {
const colors: Record<string, string> = {
sms: 'bg-blue-100 text-blue-700',
email: 'bg-purple-100 text-purple-700',
internal: 'bg-green-100 text-green-700',
push: 'bg-orange-100 text-orange-700',
};
return colors[type] || 'bg-gray-100 text-gray-700';
};
const getStatusBadge = (status: string) => {
const config: Record<string, { label: string; className: string; icon: any }> = {
pending: { label: '待发送', className: 'bg-yellow-100 text-yellow-700', icon: Timer },
sending: { label: '发送中', className: 'bg-blue-100 text-blue-700', icon: Send },
sent: { label: '已发送', className: 'bg-green-100 text-green-700', icon: CheckCircle2 },
failed: { label: '发送失败', className: 'bg-red-100 text-red-700', icon: XCircle },
cancelled: { label: '已取消', className: 'bg-gray-100 text-gray-700', icon: XCircle },
};
const { label, className, icon: Icon } = config[status] || config.pending;
return (
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${className}`}>
<Icon className="w-3 h-3" />
{label}
</div>
);
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="w-8 h-8 border-4 border-green-600 border-t-transparent rounded-full animate-spin mx-auto mb-2"></div>
<p className="text-muted-foreground">...</p>
</div>
</div>
);
}
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>
<Button onClick={handleOpenSendDialog} className="bg-green-600 hover:bg-green-700">
<Send className="w-4 h-4 mr-2" />
</Button>
</div>
{error && (
<div className="p-4 border border-yellow-200 bg-yellow-50 rounded-md">
<p className="text-yellow-800 text-sm">
: {error} ()
</p>
</div>
)}
{/* 统计卡片 */}
<MessageStatsCards sendRecords={sendRecords} />
{/* 发送记录列表 */}
<MessageSendTable
sendRecords={sendRecords}
onPreview={handlePreview}
onCancel={handleCancel}
onDelete={handleDelete}
getTypeIcon={getTypeIcon}
getTypeLabel={getTypeLabel}
getTypeBadge={getTypeBadge}
getStatusBadge={getStatusBadge}
/>
{/* 发送消息对话框 */}
<SendMessageDialog
open={showSendDialog}
onOpenChange={setShowSendDialog}
templates={templates}
formData={formData}
onFormDataChange={setFormData}
onSend={handleSend}
getTypeIcon={getTypeIcon}
getTypeLabel={getTypeLabel}
/>
{/* 详情预览对话框 */}
<MessagePreviewDialog
open={showPreviewDialog}
onOpenChange={setShowPreviewDialog}
record={previewRecord}
getTypeIcon={getTypeIcon}
getTypeLabel={getTypeLabel}
getTypeBadge={getTypeBadge}
getStatusBadge={getStatusBadge}
/>
{/* 使用说明 */}
<MessageInstructions />
</div>
);
}

View File

@@ -0,0 +1,12 @@
export interface MessageSendFormData {
templateId: string;
type: 'sms' | 'email' | 'internal' | 'push';
recipientType: 'manual' | 'role' | 'all';
recipients: string;
subject: string;
content: string;
sendType: 'immediate' | 'scheduled';
scheduledDate?: Date;
scheduledTime: string;
variables: Record<string, string>;
}

View File

@@ -0,0 +1,179 @@
'use client';
import { useState, useEffect } from 'react';
import { MessageTemplate } from '../types';
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';
interface MessageTemplateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editingTemplate: MessageTemplate | null;
onSave: (formData: FormData) => void;
}
interface FormData {
code: string;
name: string;
type: 'sms' | 'email' | 'internal' | 'push';
subject: string;
content: string;
variables: string[];
description: string;
isActive: boolean;
}
export function MessageTemplateDialog({
open,
onOpenChange,
editingTemplate,
onSave
}: MessageTemplateDialogProps) {
const [formData, setFormData] = useState<FormData>({
code: '',
name: '',
type: 'internal',
subject: '',
content: '',
variables: [],
description: '',
isActive: true,
});
// 使用useEffect来管理表单数据的状态
useEffect(() => {
if (editingTemplate) {
setFormData({
code: editingTemplate.code,
name: editingTemplate.name,
type: editingTemplate.type,
subject: editingTemplate.subject || '',
content: editingTemplate.content,
variables: editingTemplate.variables,
description: editingTemplate.description || '',
isActive: editingTemplate.isActive,
});
} else if (open) {
// 新增模式时重置表单
setFormData({
code: '',
name: '',
type: 'internal',
subject: '',
content: '',
variables: [],
description: '',
isActive: true,
});
}
}, [editingTemplate, open]);
const handleSave = () => {
onSave(formData);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingTemplate ? '编辑模版' : '新增模版'}
</DialogTitle>
<DialogDescription className="sr-only">
{editingTemplate ? '编辑消息模版' : '添加新消息模版'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
placeholder="TASK_ASSIGNED"
/>
</div>
<div>
<Label> *</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="任务分配通知"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Select
value={formData.type}
onValueChange={(value: any) => setFormData({ ...formData, type: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sms"></SelectItem>
<SelectItem value="email"></SelectItem>
<SelectItem value="internal"></SelectItem>
<SelectItem value="push"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between pt-6">
<Label></Label>
<Switch
checked={formData.isActive}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
/>
</div>
</div>
{(formData.type === 'email' || formData.type === 'push') && (
<div>
<Label></Label>
<Input
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
placeholder="输入消息主题"
/>
</div>
)}
<div>
<Label> *</Label>
<Textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
placeholder="输入消息内容,使用 {{变量名}} 表示变量"
rows={6}
/>
<p className="text-xs text-muted-foreground mt-1">
使 {'{{'} {'}'} {'{{username}}'}{'{{taskName}}'}
</p>
</div>
<div>
<Label></Label>
<Textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="模板用途说明"
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import { Button } from '@/components/ui/button';
import { Plus, Download } from 'lucide-react';
interface MessageTemplateHeaderProps {
onExport: () => void;
onAdd: () => void;
}
export function MessageTemplateHeader({ onExport, onAdd }: MessageTemplateHeaderProps) {
return (
<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">
<Button variant="outline" onClick={onExport}>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button onClick={onAdd}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { Card } from '@/components/ui/card';
import { MessageSquare } from 'lucide-react';
export function MessageTemplateInfo() {
return (
<Card className="p-4 bg-blue-50 border-blue-200">
<h4 className="text-blue-900 mb-2">
<MessageSquare className="w-4 h-4 inline mr-2" />
</h4>
<ul className="space-y-1 text-sm text-blue-800">
<li> </li>
<li> 使 {'{{变量名}}'} </li>
<li> </li>
<li> 便</li>
<li> 使</li>
</ul>
</Card>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { MessageTemplate } from '../types';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Edit, Trash2, Send, Mail, MessageSquare, Smartphone, Bell } from 'lucide-react';
interface MessageTemplateListProps {
templates: MessageTemplate[];
onEdit: (template: MessageTemplate) => void;
onDelete: (id: string) => void;
onTest: (template: MessageTemplate) => void;
}
export function MessageTemplateList({ templates, onEdit, onDelete, onTest }: MessageTemplateListProps) {
const getTypeIcon = (type: string) => {
switch (type) {
case 'sms': return <Smartphone className="w-4 h-4" />;
case 'email': return <Mail className="w-4 h-4" />;
case 'internal': return <MessageSquare className="w-4 h-4" />;
case 'push': return <Bell className="w-4 h-4" />;
default: return <MessageSquare className="w-4 h-4" />;
}
};
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
sms: '短信',
email: '邮件',
internal: '站内信',
push: '推送',
};
return labels[type] || type;
};
const getTypeBadge = (type: string) => {
const colors: Record<string, string> = {
sms: 'bg-blue-100 text-blue-700',
email: 'bg-purple-100 text-purple-700',
internal: 'bg-green-100 text-green-700',
push: 'bg-orange-100 text-orange-700',
};
return colors[type] || 'bg-gray-100 text-gray-700';
};
return (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
</TableCell>
</TableRow>
) : (
templates.map((template) => (
<TableRow key={template.id}>
<TableCell>
<code className="text-xs px-2 py-1 rounded">
{template.code}
</code>
</TableCell>
<TableCell>
<div>
<div>{template.name}</div>
{template.description && (
<p className="text-xs text-muted-foreground">{template.description}</p>
)}
</div>
</TableCell>
<TableCell>
<Badge className={getTypeBadge(template.type)}>
<span className="flex items-center gap-1">
{getTypeIcon(template.type)}
{getTypeLabel(template.type)}
</span>
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{template.variables.map((variable, index) => (
<Badge key={index} variant="outline" className="text-xs">
{variable}
</Badge>
))}
</div>
</TableCell>
<TableCell>
{template.isActive ? (
<Badge className="bg-green-100 text-green-700"></Badge>
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => onTest(template)}>
<Send className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onEdit(template)}>
<Edit className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onDelete(template.id)}>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card } from '@/components/ui/card';
import { Search } from 'lucide-react';
interface MessageTemplateSearchProps {
searchKeyword: string;
onSearchChange: (value: string) => void;
typeFilter: string;
onTypeFilterChange: (value: string) => void;
}
export function MessageTemplateSearch({
searchKeyword,
onSearchChange,
typeFilter,
onTypeFilterChange
}: MessageTemplateSearchProps) {
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="sms"></SelectItem>
<SelectItem value="email"></SelectItem>
<SelectItem value="internal"></SelectItem>
<SelectItem value="push"></SelectItem>
</SelectContent>
</Select>
</div>
</Card>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { useState, useEffect } from 'react';
import { MessageTemplate } from '../types';
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 { Send } from 'lucide-react';
interface MessageTemplateTestDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
testTemplate: MessageTemplate | null;
onSendTest: (testData: TestData) => void;
}
interface TestData {
recipient: string;
variables: Record<string, string>;
}
export function MessageTemplateTestDialog({
open,
onOpenChange,
testTemplate,
onSendTest
}: MessageTemplateTestDialogProps) {
const [testData, setTestData] = useState<TestData>({
recipient: '',
variables: {},
});
// 使用useEffect来管理测试数据的状态
useEffect(() => {
if (testTemplate && open) {
const varsObj: Record<string, string> = {};
testTemplate.variables.forEach(v => {
varsObj[v] = '';
});
setTestData({
recipient: '',
variables: varsObj,
});
}
}, [testTemplate, open]);
const handleSendTest = () => {
onSendTest(testData);
};
const handleVariableChange = (variable: string, value: string) => {
setTestData({
...testData,
variables: { ...testData.variables, [variable]: value }
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<div className="flex items-center gap-2">
<Send className="w-5 h-5 text-green-600" />
</div>
</DialogTitle>
<DialogDescription className="sr-only">
</DialogDescription>
</DialogHeader>
{testTemplate && (
<div className="space-y-4">
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-900">
{testTemplate.name}
</p>
</div>
<div>
<Label> *</Label>
<Input
value={testData.recipient}
onChange={(e) => setTestData({ ...testData, recipient: e.target.value })}
placeholder={
testTemplate.type === 'sms' ? '手机号' :
testTemplate.type === 'email' ? '邮箱地址' :
'用户ID'
}
/>
</div>
{testTemplate.variables.length > 0 && (
<div className="space-y-3">
<Label></Label>
{testTemplate.variables.map(variable => (
<div key={variable}>
<Label className="text-sm text-muted-foreground">{variable}</Label>
<Input
value={testData.variables[variable] || ''}
onChange={(e) => handleVariableChange(variable, e.target.value)}
placeholder={`输入 ${variable} 的值`}
/>
</div>
))}
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSendTest}>
<Send className="w-4 h-4 mr-2" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,6 @@
export { MessageTemplateList } from './MessageTemplateList';
export { MessageTemplateSearch } from './MessageTemplateSearch';
export { MessageTemplateHeader } from './MessageTemplateHeader';
export { MessageTemplateDialog } from './MessageTemplateDialog';
export { MessageTemplateTestDialog } from './MessageTemplateTestDialog';
export { MessageTemplateInfo } from './MessageTemplateInfo';

View File

@@ -0,0 +1,269 @@
'use client';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { MessageTemplate as MessageTemplateType } from './types';
import {
MessageTemplateList,
MessageTemplateSearch,
MessageTemplateHeader,
MessageTemplateDialog,
MessageTemplateTestDialog,
MessageTemplateInfo
} from './components';
export default function MessageTemplatePage() {
const [templates, setTemplates] = useState<MessageTemplateType[]>([]);
const [searchKeyword, setSearchKeyword] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [showDialog, setShowDialog] = useState(false);
const [showTestDialog, setShowTestDialog] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<MessageTemplateType | null>(null);
const [testTemplate, setTestTemplate] = useState<MessageTemplateType | null>(null);
useEffect(() => {
loadTemplates();
}, []);
const loadTemplates = () => {
// 清除旧的localStorage数据确保使用最新的mock数据
localStorage.removeItem('smart_agriculture_message_templates');
const data = localStorage.getItem('smart_agriculture_message_templates');
if (data) {
try {
const parsedData = JSON.parse(data);
console.log('Loaded templates from localStorage:', parsedData);
// 确保数据是数组格式
if (Array.isArray(parsedData)) {
setTemplates(parsedData);
} else {
console.error('Loaded data is not an array:', parsedData);
setTemplates([]);
}
} catch (error) {
console.error('Error parsing templates from localStorage:', error);
setTemplates([]);
}
} else {
const mockTemplates: MessageTemplateType[] = [
{
id: 'tpl-1',
code: 'TASK_ASSIGNED',
name: '任务分配通知',
type: 'internal',
subject: '新任务分配',
content: '您好,{{username}}!您有新的作业任务:{{taskName}},计划执行时间:{{executeTime}}。请及时查看并准备。',
variables: ['username', 'taskName', 'executeTime'],
isActive: true,
description: '当任务分配给驾驶员时发送',
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
createdBy: 'admin',
},
{
id: 'tpl-2',
code: 'EQUIPMENT_WARNING',
name: '设备预警通知',
type: 'sms',
content: '【智慧农业】设备预警:{{equipmentName}}检测到异常,{{warningType}},请及时处理。',
variables: ['equipmentName', 'warningType'],
isActive: true,
description: '设备出现异常时发送短信通知',
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
createdBy: 'admin',
},
{
id: 'tpl-3',
code: 'MAINTENANCE_REMINDER',
name: '保养提醒',
type: 'email',
subject: '设备保养提醒',
content: '尊敬的用户:\n\n您的设备{{equipmentName}}(编号:{{equipmentCode}})已使用{{hours}}小时,建议进行保养维护。\n\n保养周期{{maintenanceCycle}}\n上次保养时间{{lastMaintenanceTime}}\n\n请及时安排保养确保设备正常运行。\n\n智慧农业管理系统',
variables: ['equipmentName', 'equipmentCode', 'hours', 'maintenanceCycle', 'lastMaintenanceTime'],
isActive: true,
description: '设备到达保养周期时发送邮件提醒',
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
createdBy: 'admin',
},
{
id: 'tpl-4',
code: 'TASK_COMPLETED',
name: '任务完成通知',
type: 'push',
subject: '任务完成',
content: '作业任务{{taskName}}已完成,作业面积:{{area}}亩,耗时:{{duration}}。',
variables: ['taskName', 'area', 'duration'],
isActive: true,
description: '任务完成后推送通知',
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
createdBy: 'admin',
},
];
console.log('Created mock templates:', mockTemplates);
localStorage.setItem('smart_agriculture_message_templates', JSON.stringify(mockTemplates));
setTemplates(mockTemplates);
}
};
const saveTemplates = (newTemplates: MessageTemplateType[]) => {
localStorage.setItem('smart_agriculture_message_templates', JSON.stringify(newTemplates));
setTemplates(newTemplates);
};
const filteredTemplates = templates.filter(tpl => {
const matchKeyword = !searchKeyword ||
tpl.name.includes(searchKeyword) ||
tpl.code.includes(searchKeyword) ||
tpl.content.includes(searchKeyword);
const matchType = typeFilter === 'all' || tpl.type === typeFilter;
return matchKeyword && matchType;
});
// 调试日志
console.log('Original templates:', templates);
console.log('Filtered templates:', filteredTemplates);
console.log('Search keyword:', searchKeyword);
console.log('Type filter:', typeFilter);
const handleAdd = () => {
setEditingTemplate(null);
setShowDialog(true);
};
const handleEdit = (template: MessageTemplateType) => {
setEditingTemplate(template);
setShowDialog(true);
};
const handleDelete = (id: string) => {
const updated = templates.filter(t => t.id !== id);
saveTemplates(updated);
toast.success('删除成功');
};
const handleSave = (formData: any) => {
if (!formData.code.trim() || !formData.name.trim() || !formData.content.trim()) {
toast.error('请填写必填项');
return;
}
const now = new Date().toISOString();
// 提取变量
const variableRegex = /\{\{(\w+)\}\}/g;
const matches = formData.content.matchAll(variableRegex);
const extractedVars = Array.from(new Set(Array.from(matches, m => m[1])));
if (editingTemplate) {
const updated = templates.map(t =>
t.id === editingTemplate.id
? {
...t,
...formData,
variables: extractedVars,
updatedAt: now,
}
: t
);
saveTemplates(updated);
toast.success('更新成功');
} else {
const newTemplate: MessageTemplateType = {
id: `tpl-${Date.now()}`,
...formData,
variables: extractedVars,
createdAt: now,
updatedAt: now,
createdBy: 'admin',
};
saveTemplates([...templates, newTemplate]);
toast.success('添加成功');
}
setShowDialog(false);
};
const handleTest = (template: MessageTemplateType) => {
setTestTemplate(template);
setShowTestDialog(true);
};
const handleSendTest = (testData: any) => {
if (!testData.recipient.trim()) {
toast.error('请输入接收人');
return;
}
// 检查变量是否都填写了
const emptyVars = Object.entries(testData.variables).filter(([k, v]) => !v.trim());
if (emptyVars.length > 0) {
toast.error('请填写变量:' + emptyVars.map(([k]) => k).join(', '));
return;
}
toast.success('测试消息发送成功');
setShowTestDialog(false);
};
const handleExport = () => {
const dataStr = JSON.stringify(filteredTemplates, 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 = `message_templates_${new Date().getTime()}.json`;
link.click();
toast.success('导出成功');
};
return (
<div className="space-y-6">
{/* 页面头部 */}
<MessageTemplateHeader
onExport={handleExport}
onAdd={handleAdd}
/>
{/* 搜索和筛选 */}
<MessageTemplateSearch
searchKeyword={searchKeyword}
onSearchChange={setSearchKeyword}
typeFilter={typeFilter}
onTypeFilterChange={setTypeFilter}
/>
{/* 模版列表 */}
<MessageTemplateList
templates={filteredTemplates}
onEdit={handleEdit}
onDelete={handleDelete}
onTest={handleTest}
/>
{/* 编辑对话框 */}
<MessageTemplateDialog
open={showDialog}
onOpenChange={setShowDialog}
editingTemplate={editingTemplate}
onSave={handleSave}
/>
{/* 测试对话框 */}
<MessageTemplateTestDialog
open={showTestDialog}
onOpenChange={setShowTestDialog}
testTemplate={testTemplate}
onSendTest={handleSendTest}
/>
{/* 使用说明 */}
<MessageTemplateInfo />
</div>
);
}

View File

@@ -0,0 +1,14 @@
export interface MessageTemplate {
id: string;
code: string;
name: string;
type: 'sms' | 'email' | 'internal' | 'push';
subject: string;
content: string;
variables: string[];
description: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
createdBy: string;
}

View File

@@ -0,0 +1 @@
export * from './loginLogApi'

View File

@@ -0,0 +1,196 @@
/**
* filekorolheader: 登录日志API - 登录日志相关接口调用
* 功能:获取登录日志列表、统计、导出等功能
* 路径:/central-config/monitor/login-log/components/loginLogApi
* 规范遵循crop-x/docs/开发项目规范.md使用SDK生成的API接口
*/
import {
listLoginLogsApiV1LogsLoginLoginLogsGet,
getLoginStatisticsApiV1LogsLoginLoginLogsStatisticsGet,
exportLoginLogsApiV1LogsLoginLoginLogsExportGet
} from '@/lib/api/sdk.gen';
// 登录日志接口
export interface LoginLog {
id: string;
created_at: string;
updated_at: string;
username: string;
user_id: string | null;
status: 'success' | 'failed';
method: string;
ip_address: string;
user_agent: string;
location: string | null;
failure_reason: string | null;
attempt_count: number;
is_suspicious: boolean;
}
// 分页参数接口
export interface LoginLogsQueryParams {
page?: number;
size?: number;
username?: string;
status?: string;
start_time?: string;
end_time?: string;
ip_address?: string;
sort_order?: 'asc' | 'desc';
order_by?: string;
}
// 分页状态接口
export interface PaginationState {
page: number;
size: number;
total: number;
totalPages?: number;
hasNext?: boolean;
hasPrev?: boolean;
}
// 统计数据接口
export interface LoginStatistics {
total_logins: number;
successful_logins: number;
failed_logins: number;
unique_users: number;
success_rate: number;
suspicious_logins: number;
}
/**
* 获取登录日志列表
*/
export const fetchLoginLogs = async (params: LoginLogsQueryParams = {}) => {
try {
// Get token from localStorage
const storedUser = localStorage.getItem('user');
let headers = {};
if (storedUser) {
const userData = JSON.parse(storedUser);
if (userData.token) {
headers = {
'Authorization': `Bearer ${userData.token}`
};
}
}
const response = await listLoginLogsApiV1LogsLoginLoginLogsGet({
headers,
query: {
page: params.page || 1,
size: params.size || 10,
username: params.username,
status: params.status,
start_time: params.start_time,
end_time: params.end_time,
ip_address: params.ip_address,
sort_order: params.sort_order || 'desc',
order_by: params.order_by || 'created_at',
}
});
return {
data: response.data?.data || [],
page: response.data?.page || 1,
size: response.data?.size || 10,
total: response.data?.total || 0,
totalPages: response.data?.total_pages || 0,
hasNext: response.data?.has_next || false,
hasPrev: response.data?.has_prev || false,
};
} catch (error) {
console.error('Failed to fetch login logs:', error);
throw error;
}
};
/**
* 获取登录统计信息
*/
export const fetchLoginStatistics = async () => {
try {
// Get token from localStorage
const storedUser = localStorage.getItem('user');
let headers = {};
if (storedUser) {
const userData = JSON.parse(storedUser);
if (userData.token) {
headers = {
'Authorization': `Bearer ${userData.token}`
};
}
}
const response = await getLoginStatisticsApiV1LogsLoginLoginLogsStatisticsGet({
headers
});
return response.data;
} catch (error) {
console.error('Failed to fetch login statistics:', error);
throw error;
}
};
/**
* 导出登录日志
*/
export const exportLoginLogs = async (params: LoginLogsQueryParams = {}) => {
try {
// Get token from localStorage
const storedUser = localStorage.getItem('user');
let headers = {};
if (storedUser) {
const userData = JSON.parse(storedUser);
if (userData.token) {
headers = {
'Authorization': `Bearer ${userData.token}`
};
}
}
const response = await exportLoginLogsApiV1LogsLoginLoginLogsExportGet({
headers,
query: {
start_time: params.start_time,
end_time: params.end_time,
user_id: params.username, // Map username to user_id for export
}
});
return response.data;
} catch (error) {
console.error('Failed to export login logs:', error);
throw error;
}
};
/**
* 转换登录日志数据 - 适配组件使用
*/
export const transformLoginLogData = (log: any): LoginLog => ({
id: log.id,
created_at: log.created_at,
updated_at: log.updated_at,
username: log.username,
user_id: log.user_id,
status: log.status,
method: log.method,
ip_address: log.ip_address,
user_agent: log.user_agent,
location: log.location,
failure_reason: log.failure_reason,
attempt_count: log.attempt_count,
is_suspicious: log.is_suspicious,
});
/**
* 批量转换登录日志数据
*/
export const transformLoginLogsList = (logs: any[]): LoginLog[] => {
return logs.map(transformLoginLogData);
};

View File

@@ -0,0 +1,404 @@
/**
* filekorolheader: 登录日志页面 - 用户登录行为监控页面
* 功能:登录日志查询、统计、导出、筛选
* 路径:/central-config/monitor/login-log
* 规范遵循crop-x/docs/开发项目规范.md使用SearchFormPagination重构事件驱动模式
*/
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Download, Eye, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
import { SearchFormPagination, type SearchFieldConfig, type TableColumnConfig } from '@/components/common/searchFormPagination';
import {
fetchLoginLogs,
transformLoginLogsList,
LoginLog,
PaginationState,
LoginLogsQueryParams,
fetchLoginStatistics,
exportLoginLogs
} from './components/loginLogApi';
export default function LoginLogPage() {
const [logs, setLogs] = useState<LoginLog[]>([]);
const [loading, setLoading] = useState(false);
const [statistics, setStatistics] = useState<any>(null);
const [pagination, setPagination] = useState<PaginationState>({
page: 1,
size: 10,
total: 0,
totalPages: 0,
hasNext: false,
hasPrev: false,
});
const [searchFilters, setSearchFilters] = useState<Record<string, string>>({
search: '',
status: 'all'
});
const isFirstLoad = useRef(true);
// 搜索字段配置
const searchFields: SearchFieldConfig[] = [
{
key: 'search',
label: '搜索',
type: 'text',
placeholder: '搜索用户名、IP地址...',
},
{
key: 'status',
label: '登录状态',
type: 'select',
defaultValue: 'all',
options: [
{ value: 'all', label: '全部状态' },
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
],
},
{
key: 'dateRange',
label: '时间范围',
type: 'select',
defaultValue: 'all',
options: [
{ value: 'all', label: '全部时间' },
{ value: 'today', label: '今天' },
{ value: 'week', label: '最近7天' },
{ value: 'month', label: '最近30天' },
],
},
];
// 表格列配置
const columns: TableColumnConfig[] = [
{
key: 'created_at',
label: '登录时间',
render: (value: string) => (
<div className="text-sm text-muted-foreground">
{value ? new Date(value).toLocaleString('zh-CN') : '-'}
</div>
),
},
{
key: 'username',
label: '用户名',
render: (value: string) => (
<div className="font-medium text-foreground">{value || '-'}</div>
),
},
{
key: 'ip_address',
label: 'IP地址',
render: (value: string) => (
<div className="font-mono text-sm">{value || '-'}</div>
),
},
{
key: 'user_agent',
label: '设备信息',
render: (value: string) => (
<div className="text-sm text-muted-foreground max-w-[200px] truncate" title={value}>
{value || '-'}
</div>
),
},
{
key: 'location',
label: '登录地点',
render: (value: string) => (
<div className="text-sm text-muted-foreground">{value || '-'}</div>
),
},
{
key: 'status',
label: '状态',
render: (value: string) => {
if (value === 'success') {
return (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<CheckCircle className="w-3 h-3 mr-1" />
</Badge>
);
} else {
return (
<Badge className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
<XCircle className="w-3 h-3 mr-1" />
</Badge>
);
}
},
},
{
key: 'is_suspicious',
label: '安全状态',
render: (value: boolean) => {
if (value) {
return (
<Badge className="bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
<AlertTriangle className="w-3 h-3 mr-1" />
</Badge>
);
} else {
return (
<Badge className="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<CheckCircle className="w-3 h-3 mr-1" />
</Badge>
);
}
},
},
{
key: 'failure_reason',
label: '失败原因',
render: (value: string) => (
<div className="text-sm text-muted-foreground max-w-[150px] truncate" title={value}>
{value || '-'}
</div>
),
},
{
key: 'actions',
label: '操作',
render: (_: any, row: LoginLog) => (
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleViewDetail(row)}
className="h-8 px-2"
title="查看详情"
>
<Eye className="w-3 h-3 mr-1" />
</Button>
</div>
),
},
];
// 加载登录日志数据 - 事件驱动模式
const loadLoginLogs = useCallback(async (params?: {
filters?: Record<string, string>;
pagination?: { page: number; size: number };
resetPage?: boolean;
}) => {
try {
setLoading(true);
const queryParams: LoginLogsQueryParams = {
page: params?.resetPage ? 1 : (params?.pagination?.page || pagination.page),
size: params?.pagination?.size || pagination.size,
sort_order: 'desc',
order_by: 'created_at',
};
// 处理搜索条件
const searchKeyword = params?.filters?.search ?? searchFilters.search;
if (searchKeyword) {
queryParams.username = searchKeyword;
queryParams.ip_address = searchKeyword;
}
// 处理状态筛选
const status = params?.filters?.status ?? searchFilters.status;
if (status !== 'all') {
queryParams.status = status;
}
// 处理时间范围
const dateRange = params?.filters?.dateRange ?? searchFilters.dateRange;
if (dateRange !== 'all') {
const now = new Date();
let startTime: string;
switch (dateRange) {
case 'today':
startTime = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0).toISOString();
break;
case 'week':
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
break;
case 'month':
startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
break;
}
if (startTime) {
queryParams.start_time = startTime;
queryParams.end_time = now.toISOString();
}
}
const response = await fetchLoginLogs(queryParams);
const transformedLogs = transformLoginLogsList(response.data);
setLogs(transformedLogs);
setPagination({
page: response.page,
size: response.size,
total: response.total,
totalPages: response.total === 0
? 0
: Math.floor(response.total / response.size) + 1,
hasNext: response.hasNext,
hasPrev: response.hasPrev,
});
} catch (error) {
console.error('Failed to load login logs:', error);
const errorMessage = error instanceof Error ? error.message : '加载登录日志失败';
toast.error(errorMessage);
} finally {
setLoading(false);
}
}, [pagination.page, pagination.size, searchFilters]);
// 事件处理器 - 事件驱动模式
const handleSearch = useCallback((filters: Record<string, string>) => {
setSearchFilters(filters);
loadLoginLogs({
filters,
pagination: { page: 1, size: pagination.size }
});
}, [loadLoginLogs, pagination.size]);
const handlePageChange = useCallback((page: number) => {
setPagination(prev => ({ ...prev, page }));
loadLoginLogs({
filters: searchFilters,
pagination: { page, size: pagination.size }
});
}, [loadLoginLogs, searchFilters, pagination.size]);
const handleSizeChange = useCallback((size: number) => {
setPagination(prev => ({ ...prev, size, page: 1 }));
loadLoginLogs({
filters: searchFilters,
pagination: { page: 1, size }
});
}, [loadLoginLogs, searchFilters]);
// 查看详情
const handleViewDetail = (log: LoginLog) => {
toast.info(`查看登录日志详情: ${log.username} - ${log.ip_address}`);
};
// 导出日志
const handleExport = async () => {
try {
setLoading(true);
const exportData = await exportLoginLogs({
username: searchFilters.search,
status: searchFilters.status === 'all' ? undefined : searchFilters.status,
start_time: searchFilters.dateRange === 'all' ? undefined :
searchFilters.dateRange === 'today' ? new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate(), 0, 0, 0).toISOString() :
searchFilters.dateRange === 'week' ? new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() :
searchFilters.dateRange === 'month' ? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() : undefined,
end_time: new Date().toISOString(),
});
// 创建下载链接
const dataStr = JSON.stringify(exportData, 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 = `login_logs_${new Date().getTime()}.json`;
link.click();
URL.revokeObjectURL(url);
toast.success('导出成功');
} catch (error) {
console.error('Failed to export login logs:', error);
const errorMessage = error instanceof Error ? error.message : '导出失败';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
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>
<Button onClick={handleExport} disabled={loading}>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
{/* 统计卡片 */}
{statistics && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="p-6 bg-card hover:bg-muted transition-colors border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold text-blue-600">{statistics.total_logins}</div>
</div>
<div className="text-xs text-muted-foreground">
</div>
</div>
<div className="p-6 bg-card hover:bg-muted transition-colors border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold text-green-600">{statistics.successful_logins}</div>
</div>
<div className="text-xs text-green-600">
</div>
</div>
<div className="p-6 bg-card hover:bg-muted transition-colors border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold text-red-600">{statistics.failed_logins}</div>
</div>
<div className="text-xs text-red-600">
</div>
</div>
<div className="p-6 bg-card hover:bg-muted transition-colors border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold text-purple-600">{Math.round(statistics.success_rate * 100)}%</div>
</div>
<div className="text-xs text-purple-600">
</div>
</div>
</div>
)}
{/* 搜索、表格和分页 */}
<SearchFormPagination
formTitle="登录日志列表"
searchFields={searchFields}
columns={columns}
data={logs}
loading={loading}
error={null}
pagination={pagination}
onPageChange={handlePageChange}
onSizeChange={handleSizeChange}
onSearch={handleSearch}
emptyText="暂无登录日志数据"
showSizeSelector={true}
showPageInfo={true}
sizeOptions={[10, 20, 50, 100]}
/>
</div>
);
}

View File

@@ -0,0 +1,191 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { NetworkLog } from '@/types/monitor'
import { Globe } from 'lucide-react'
interface NetworkLogDetailDialogProps {
log: NetworkLog | null
isOpen: boolean
onClose: () => void
isLoading?: boolean
}
export function NetworkLogDetailDialog({
log,
isOpen,
onClose,
isLoading = false
}: NetworkLogDetailDialogProps) {
const getMethodBadge = (method: string) => {
const colors: Record<string, string> = {
GET: 'bg-blue-100 text-blue-700',
POST: 'bg-green-100 text-green-700',
PUT: 'bg-yellow-100 text-yellow-700',
DELETE: 'bg-red-100 text-red-700',
PATCH: 'bg-purple-100 text-purple-700',
}
return colors[method] || 'bg-gray-100 text-gray-700'
}
const getStatusBadge = (status: number) => {
if (status >= 200 && status < 300) {
return 'bg-green-100 text-green-700'
} else if (status >= 400 && status < 500) {
return 'bg-yellow-100 text-yellow-700'
} else if (status >= 500) {
return 'bg-red-100 text-red-700'
}
return 'bg-gray-100 text-gray-700'
}
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-green-600" />
</div>
</DialogTitle>
</DialogHeader>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="bg-gray-200 h-4 w-24 rounded mb-2"></div>
<div className="bg-gray-200 h-6 w-40 rounded"></div>
</div>
))}
</div>
) : log ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1">{new Date(log.timestamp).toLocaleString('zh-CN')}</p>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1">
<Badge className={getMethodBadge(log.method)}>
{log.method}
</Badge>
</p>
</div>
<div className="col-span-2">
<p className="text-sm text-muted-foreground">URL</p>
<p className="mt-1">
<code className="text-xs bg-gray-100 px-2 py-1 rounded block break-all">
{log.url}
</code>
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">IP</p>
<p className="mt-1">
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{log.clientIp}
</code>
</p>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1">{log.username || '未登录'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1">
<Badge className={getStatusBadge(log.responseStatus)}>
{log.responseStatus}
</Badge>
</p>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1">{log.responseTime}ms</p>
</div>
{log.responseSize && (
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1">{formatBytes(log.responseSize)}</p>
</div>
)}
</div>
{log.requestParams && (
<div>
<p className="text-sm text-muted-foreground"></p>
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto">
{log.requestParams}
</pre>
</div>
)}
{log.requestHeaders && (
<div>
<p className="text-sm text-muted-foreground"></p>
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto">
{(() => {
try {
return JSON.stringify(JSON.parse(log.requestHeaders), null, 2)
} catch {
return log.requestHeaders
}
})()}
</pre>
</div>
)}
{log.requestBody && (
<div>
<p className="text-sm text-muted-foreground"></p>
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto">
{log.requestBody}
</pre>
</div>
)}
{log.responseBody && (
<div>
<p className="text-sm text-muted-foreground"></p>
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto max-h-40 overflow-y-auto">
{(() => {
try {
return JSON.stringify(JSON.parse(log.responseBody), null, 2)
} catch {
return log.responseBody
}
})()}
</pre>
</div>
)}
{log.userAgent && (
<div>
<p className="text-sm text-muted-foreground">User Agent</p>
<p className="mt-1 text-xs text-muted-foreground break-all">
{log.userAgent}
</p>
</div>
)}
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,62 @@
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 NetworkLogFiltersProps {
searchKeyword: string
onSearchChange: (value: string) => void
methodFilter: string
onMethodFilterChange: (value: string) => void
statusFilter: string
onStatusFilterChange: (value: string) => void
}
export function NetworkLogFilters({
searchKeyword,
onSearchChange,
methodFilter,
onMethodFilterChange,
statusFilter,
onStatusFilterChange
}: NetworkLogFiltersProps) {
return (
<Card className="p-4">
<div className="grid grid-cols-1 md:grid-cols-3 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="搜索URL、用户名..."
value={searchKeyword}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<Select value={methodFilter} onValueChange={onMethodFilterChange}>
<SelectTrigger>
<SelectValue placeholder="请求方法" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
<SelectTrigger>
<SelectValue placeholder="响应状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="2xx">2xx </SelectItem>
<SelectItem value="4xx">4xx </SelectItem>
<SelectItem value="5xx">5xx </SelectItem>
</SelectContent>
</Select>
</div>
</Card>
)
}

View File

@@ -0,0 +1,20 @@
import { Card } from '@/components/ui/card'
import { Globe } from 'lucide-react'
export function NetworkLogInfo() {
return (
<Card className="p-4 bg-blue-50 border-blue-200">
<h4 className="text-blue-900 mb-2">
<Globe className="w-4 h-4 inline mr-2" />
</h4>
<ul className="space-y-1 text-sm text-blue-800">
<li> HTTP请求的详细信息</li>
<li> 1</li>
<li> 4xx状态码通常表示客户端错误5xx表示服务器错误</li>
<li> </li>
<li> </li>
</ul>
</Card>
)
}

View File

@@ -0,0 +1,338 @@
import { NetworkLog } from '@/types/monitor'
import { ApiResponse, PaginatedResponse, PaginationParams } from '@/types'
export interface NetworkLogFilters {
searchKeyword?: string
method?: string
status?: string
startDate?: string
endDate?: string
minResponseTime?: number
maxResponseTime?: number
}
export interface NetworkLogListParams extends PaginationParams {
filters?: NetworkLogFilters
}
export class NetworkLogService {
private static baseUrl = '/api/monitor/network-logs'
/**
* 获取网络日志列表
*/
static async getNetworkLogs(params: NetworkLogListParams = {}): Promise<PaginatedResponse<NetworkLog>> {
try {
const queryParams = new URLSearchParams()
// 添加分页参数
if (params.page) queryParams.append('page', params.page.toString())
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString())
// 添加筛选参数
if (params.filters) {
if (params.filters.searchKeyword) queryParams.append('searchKeyword', params.filters.searchKeyword)
if (params.filters.method && params.filters.method !== 'all') queryParams.append('method', params.filters.method)
if (params.filters.status && params.filters.status !== 'all') queryParams.append('status', params.filters.status)
if (params.filters.startDate) queryParams.append('startDate', params.filters.startDate)
if (params.filters.endDate) queryParams.append('endDate', params.filters.endDate)
if (params.filters.minResponseTime) queryParams.append('minResponseTime', params.filters.minResponseTime.toString())
if (params.filters.maxResponseTime) queryParams.append('maxResponseTime', params.filters.maxResponseTime.toString())
}
const response = await fetch(`${this.baseUrl}?${queryParams}`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
return data
} catch (error) {
console.error('Failed to fetch network logs:', error)
// 降级处理返回mock数据
return this.getMockData(params)
}
}
/**
* 获取网络日志详情
*/
static async getNetworkLogDetail(id: string): Promise<ApiResponse<NetworkLog>> {
try {
const response = await fetch(`${this.baseUrl}/${id}`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
return data
} catch (error) {
console.error('Failed to fetch network log detail:', error)
throw error
}
}
/**
* 导出网络日志
*/
static async exportNetworkLogs(filters?: NetworkLogFilters): Promise<Blob> {
try {
const queryParams = new URLSearchParams()
if (filters) {
if (filters.searchKeyword) queryParams.append('searchKeyword', filters.searchKeyword)
if (filters.method && filters.method !== 'all') queryParams.append('method', filters.method)
if (filters.status && filters.status !== 'all') queryParams.append('status', filters.status)
if (filters.startDate) queryParams.append('startDate', filters.startDate)
if (filters.endDate) queryParams.append('endDate', filters.endDate)
if (filters.minResponseTime) queryParams.append('minResponseTime', filters.minResponseTime.toString())
if (filters.maxResponseTime) queryParams.append('maxResponseTime', filters.maxResponseTime.toString())
}
const response = await fetch(`${this.baseUrl}/export?${queryParams}`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return response.blob()
} catch (error) {
console.error('Failed to export network logs:', error)
throw error
}
}
/**
* 获取网络日志统计信息
*/
static async getNetworkLogStats(filters?: NetworkLogFilters): Promise<ApiResponse<{
total: number
success: number
clientError: number
serverError: number
averageResponseTime: number
totalResponseSize: number
methodStats: Array<{ method: string, count: number }>
statusStats: Array<{ status: number, count: number }>
topSlowRequests: Array<{ url: string, responseTime: number, count: number }>
}>> {
try {
const queryParams = new URLSearchParams()
if (filters) {
if (filters.method && filters.method !== 'all') queryParams.append('method', filters.method)
if (filters.status && filters.status !== 'all') queryParams.append('status', filters.status)
if (filters.startDate) queryParams.append('startDate', filters.startDate)
if (filters.endDate) queryParams.append('endDate', filters.endDate)
}
const response = await fetch(`${this.baseUrl}/stats?${queryParams}`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
return data
} catch (error) {
console.error('Failed to fetch network log stats:', error)
// 降级处理返回mock统计数据
return this.getMockStats()
}
}
/**
* Mock数据 - 用于降级处理
*/
private static getMockData(params: NetworkLogListParams): PaginatedResponse<NetworkLog> {
const mockLogs: NetworkLog[] = [
{
id: 'net-1',
timestamp: '2024-10-21T09:35:00',
method: 'POST',
url: '/api/users',
requestParams: 'username=zhangsan&name=张三',
responseStatus: 200,
responseTime: 150,
responseSize: 256,
clientIp: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/118.0.0.0',
userId: 'user-1',
username: 'admin',
},
{
id: 'net-2',
timestamp: '2024-10-21T10:20:00',
method: 'GET',
url: '/api/machinery/list',
requestParams: 'page=1&size=10',
responseStatus: 200,
responseTime: 89,
responseSize: 4096,
clientIp: '192.168.1.101',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17.0',
userId: 'user-2',
username: 'zhangsan',
},
{
id: 'net-3',
timestamp: '2024-10-21T11:25:00',
method: 'DELETE',
url: '/api/roles/456',
responseStatus: 400,
responseTime: 120,
responseSize: 128,
clientIp: '192.168.1.102',
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/118.0.0.0',
userId: 'user-3',
username: 'lisi',
},
{
id: 'net-4',
timestamp: '2024-10-21T14:50:00',
method: 'PUT',
url: '/api/system/settings',
requestParams: 'sessionTimeout=30',
responseStatus: 200,
responseTime: 95,
responseSize: 512,
clientIp: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/118.0.0.0',
userId: 'user-1',
username: 'admin',
},
{
id: 'net-5',
timestamp: '2024-10-21T15:35:00',
method: 'POST',
url: '/api/tasks',
responseStatus: 201,
responseTime: 180,
responseSize: 1024,
clientIp: '192.168.1.101',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17.0',
userId: 'user-2',
username: 'zhangsan',
},
{
id: 'net-6',
timestamp: '2024-10-21T16:15:00',
method: 'GET',
url: '/api/users/export',
requestParams: 'format=excel',
responseStatus: 200,
responseTime: 1250,
responseSize: 102400,
clientIp: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/118.0.0.0',
userId: 'user-1',
username: 'admin',
},
]
// 应用筛选器
let filteredLogs = mockLogs.filter(log => {
if (params.filters?.searchKeyword) {
const keyword = params.filters.searchKeyword.toLowerCase()
if (!log.url.toLowerCase().includes(keyword) &&
!(log.username && log.username.toLowerCase().includes(keyword))) {
return false
}
}
if (params.filters?.method && params.filters.method !== 'all') {
if (log.method !== params.filters.method) return false
}
if (params.filters?.status && params.filters.status !== 'all') {
const status = log.responseStatus
switch (params.filters.status) {
case '2xx':
if (status < 200 || status >= 300) return false
break
case '4xx':
if (status < 400 || status >= 500) return false
break
case '5xx':
if (status < 500) return false
break
}
}
if (params.filters?.minResponseTime && log.responseTime < params.filters.minResponseTime) {
return false
}
if (params.filters?.maxResponseTime && log.responseTime > params.filters.maxResponseTime) {
return false
}
return true
})
// 应用分页
const page = params.page || 1
const pageSize = params.pageSize || 10
const startIndex = (page - 1) * pageSize
const endIndex = startIndex + pageSize
const paginatedLogs = filteredLogs.slice(startIndex, endIndex)
return {
code: 200,
message: 'success',
success: true,
data: paginatedLogs,
pagination: {
page,
pageSize,
total: filteredLogs.length,
totalPages: Math.ceil(filteredLogs.length / pageSize)
}
}
}
private static getMockStats(): ApiResponse<{
total: number
success: number
clientError: number
serverError: number
averageResponseTime: number
totalResponseSize: number
methodStats: Array<{ method: string, count: number }>
statusStats: Array<{ status: number, count: number }>
topSlowRequests: Array<{ url: string, responseTime: number, count: number }>
}> {
return {
code: 200,
message: 'success',
success: true,
data: {
total: 6,
success: 4,
clientError: 1,
serverError: 1,
averageResponseTime: 314,
totalResponseSize: 108416,
methodStats: [
{ method: 'GET', count: 2 },
{ method: 'POST', count: 2 },
{ method: 'PUT', count: 1 },
{ method: 'DELETE', count: 1 }
],
statusStats: [
{ status: 200, count: 3 },
{ status: 201, count: 1 },
{ status: 400, count: 1 },
{ status: 500, count: 1 }
],
topSlowRequests: [
{ url: '/api/users/export', responseTime: 1250, count: 1 },
{ url: '/api/tasks', responseTime: 180, count: 1 }
]
}
}
}
}

View File

@@ -0,0 +1,51 @@
import { Card } from '@/components/ui/card'
import { NetworkLog } from '@/types/monitor'
interface NetworkLogStatsProps {
logs: NetworkLog[]
isLoading?: boolean
}
export function NetworkLogStats({ logs, isLoading = false }: NetworkLogStatsProps) {
const stats = [
{
label: '总请求数',
value: logs.length,
color: 'text-blue-600',
},
{
label: '成功请求',
value: logs.filter(l => l.responseStatus >= 200 && l.responseStatus < 300).length,
color: 'text-green-600',
},
{
label: '失败请求',
value: logs.filter(l => l.responseStatus >= 400).length,
color: 'text-red-600',
},
{
label: '平均响应时间',
value: logs.length > 0
? Math.round(logs.reduce((sum, l) => sum + l.responseTime, 0) / logs.length) + 'ms'
: '0ms',
color: 'text-purple-600',
},
]
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{stats.map((stat, index) => (
<Card key={index} className="p-4">
<div className="text-sm text-muted-foreground">{stat.label}</div>
<div className={`mt-2 text-2xl font-semibold ${stat.color}`}>
{isLoading ? (
<div className="animate-pulse bg-gray-200 h-8 w-16 rounded"></div>
) : (
stat.value
)}
</div>
</Card>
))}
</div>
)
}

View File

@@ -0,0 +1,133 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Card } from '@/components/ui/card'
import { NetworkLog } from '@/types/monitor'
import { Eye, Clock } from 'lucide-react'
interface NetworkLogTableProps {
logs: NetworkLog[]
isLoading?: boolean
onViewDetail: (log: NetworkLog) => void
}
export function NetworkLogTable({ logs, isLoading = false, onViewDetail }: NetworkLogTableProps) {
const getMethodBadge = (method: string) => {
const colors: Record<string, string> = {
GET: 'bg-blue-100 text-blue-700',
POST: 'bg-green-100 text-green-700',
PUT: 'bg-yellow-100 text-yellow-700',
DELETE: 'bg-red-100 text-red-700',
PATCH: 'bg-purple-100 text-purple-700',
}
return colors[method] || 'bg-gray-100 text-gray-700'
}
const getStatusBadge = (status: number) => {
if (status >= 200 && status < 300) {
return 'bg-green-100 text-green-700'
} else if (status >= 400 && status < 500) {
return 'bg-yellow-100 text-yellow-700'
} else if (status >= 500) {
return 'bg-red-100 text-red-700'
}
return 'bg-gray-100 text-gray-700'
}
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
}
if (isLoading) {
return (
<Card>
<div className="p-8 space-y-4">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="flex space-x-4">
<div className="bg-gray-200 h-4 w-20 rounded"></div>
<div className="bg-gray-200 h-4 w-16 rounded"></div>
<div className="bg-gray-200 h-4 w-32 rounded"></div>
<div className="bg-gray-200 h-4 w-20 rounded"></div>
<div className="bg-gray-200 h-4 w-16 rounded"></div>
<div className="bg-gray-200 h-4 w-20 rounded"></div>
<div className="bg-gray-200 h-4 w-16 rounded"></div>
<div className="bg-gray-200 h-8 w-8 rounded"></div>
</div>
</div>
))}
</div>
</Card>
)
}
return (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>URL</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
</TableCell>
</TableRow>
) : (
logs.map((log) => (
<TableRow key={log.id}>
<TableCell className="text-sm text-muted-foreground">
{new Date(log.timestamp).toLocaleTimeString('zh-CN')}
</TableCell>
<TableCell>
<Badge className={getMethodBadge(log.method)}>
{log.method}
</Badge>
</TableCell>
<TableCell className="max-w-xs truncate text-sm" title={log.url}>
{log.url}
</TableCell>
<TableCell className="text-sm">{log.username || '-'}</TableCell>
<TableCell>
<Badge className={getStatusBadge(log.responseStatus)}>
{log.responseStatus}
</Badge>
</TableCell>
<TableCell className="text-sm">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3 text-gray-400" />
{log.responseTime}ms
</div>
</TableCell>
<TableCell className="text-sm">
{log.responseSize ? formatBytes(log.responseSize) : '-'}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => onViewDetail(log)}
>
<Eye className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
)
}

View File

@@ -0,0 +1,8 @@
export { NetworkLogService } from './NetworkLogService'
export { NetworkLogStats } from './NetworkLogStats'
export { NetworkLogFilters } from './NetworkLogFilters'
export { NetworkLogTable } from './NetworkLogTable'
export { NetworkLogDetailDialog } from './NetworkLogDetailDialog'
export { NetworkLogInfo } from './NetworkLogInfo'
export type { NetworkLogFilters as NetworkLogFiltersType, NetworkLogListParams } from './NetworkLogService'

View File

@@ -0,0 +1,139 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { NetworkLog } from '@/types/monitor'
import { Download } from 'lucide-react'
import { toast } from 'sonner'
// Import modular components
import {
NetworkLogService,
NetworkLogStats,
NetworkLogFilters,
NetworkLogTable,
NetworkLogDetailDialog,
NetworkLogInfo
} from './components'
export default function NetworkLogPage() {
const [logs, setLogs] = useState<NetworkLog[]>([])
const [isLoading, setIsLoading] = useState(false)
const [searchKeyword, setSearchKeyword] = useState('')
const [methodFilter, setMethodFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [showDetailDialog, setShowDetailDialog] = useState(false)
const [selectedLog, setSelectedLog] = useState<NetworkLog | null>(null)
const [isDetailLoading, setIsDetailLoading] = useState(false)
useEffect(() => {
loadLogs()
}, [searchKeyword, methodFilter, statusFilter])
const loadLogs = async () => {
setIsLoading(true)
try {
const response = await NetworkLogService.getNetworkLogs({
page: 1,
pageSize: 100,
filters: {
searchKeyword,
method: methodFilter,
status: statusFilter
}
})
if (response.success) {
setLogs(response.data)
} else {
throw new Error(response.message || '加载网络日志失败')
}
} catch (error) {
console.error('Failed to load network logs:', error)
toast.error('加载网络日志失败,请稍后重试')
} finally {
setIsLoading(false)
}
}
const handleViewDetail = async (log: NetworkLog) => {
setSelectedLog(log)
setShowDetailDialog(true)
setIsDetailLoading(true)
try {
const response = await NetworkLogService.getNetworkLogDetail(log.id)
if (response.success) {
setSelectedLog(response.data)
}
} catch (error) {
console.error('Failed to fetch log detail:', error)
} finally {
setIsDetailLoading(false)
}
}
const handleExport = async () => {
try {
const blob = await NetworkLogService.exportNetworkLogs({
searchKeyword,
method: methodFilter,
status: statusFilter
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `network_logs_${new Date().getTime()}.json`
link.click()
URL.revokeObjectURL(url)
toast.success('导出成功')
} catch (error) {
console.error('Failed to export logs:', error)
toast.error('导出失败,请稍后重试')
}
}
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>
<Button onClick={handleExport} disabled={isLoading || logs.length === 0}>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
<NetworkLogStats logs={logs} isLoading={isLoading} />
<NetworkLogFilters
searchKeyword={searchKeyword}
onSearchChange={setSearchKeyword}
methodFilter={methodFilter}
onMethodFilterChange={setMethodFilter}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
/>
<NetworkLogTable
logs={logs}
isLoading={isLoading}
onViewDetail={handleViewDetail}
/>
<NetworkLogDetailDialog
log={selectedLog}
isOpen={showDetailDialog}
onClose={() => setShowDetailDialog(false)}
isLoading={isDetailLoading}
/>
<NetworkLogInfo />
</div>
)
}

View File

@@ -0,0 +1 @@
export * from './operationLogApi'

View File

@@ -0,0 +1,212 @@
/**
* filekorolheader: 操作日志API - 操作日志相关接口调用
* 功能:获取操作日志列表、统计、导出等功能
* 路径:/central-config/monitor/operation-log/components/operationLogApi
* 规范遵循crop-x/docs/开发项目规范.md使用SDK生成的API接口
*/
import {
listOperationLogsApiV1LogsOperationOperationLogsGet,
getOperationStatisticsApiV1LogsOperationOperationLogsStatisticsGet,
exportOperationLogsApiV1LogsOperationOperationLogsExportGet
} from '@/lib/api/sdk.gen';
// 操作日志接口
export interface OperationLog {
id: string;
created_at: string;
updated_at: string;
username: string;
user_id: string | null;
operation_type: string;
module: string;
action: string;
request_method: string;
request_url: string;
request_headers: any | null;
request_body: any | null;
request_params: any | null;
response_status: number;
response_body: any | null;
error_message: string | null;
processing_time: number;
}
// 分页参数接口
export interface OperationLogsQueryParams {
page?: number;
size?: number;
username?: string;
module?: string;
action?: string;
operation_type?: string;
response_status?: number;
start_time?: string;
end_time?: string;
sort_order?: 'asc' | 'desc';
order_by?: string;
}
// 分页状态接口
export interface PaginationState {
page: number;
size: number;
total: number;
totalPages?: number;
hasNext?: boolean;
hasPrev?: boolean;
}
// 统计数据接口
export interface OperationLogStatistics {
total_operations: number;
successful_operations: number;
failed_operations: number;
unique_users: number;
success_rate: number;
average_processing_time: number;
}
/**
* 获取操作日志列表
*/
export const fetchOperationLogs = async (params: OperationLogsQueryParams = {}) => {
try {
// Get token from localStorage
const storedUser = localStorage.getItem('user');
let headers = {};
if (storedUser) {
const userData = JSON.parse(storedUser);
if (userData.token) {
headers = {
'Authorization': `Bearer ${userData.token}`
};
}
}
const response = await listOperationLogsApiV1LogsOperationOperationLogsGet({
headers,
query: {
page: params.page || 1,
size: params.size || 10,
username: params.username,
module: params.module,
action: params.action,
operation_type: params.operation_type,
response_status: params.response_status,
start_time: params.start_time,
end_time: params.end_time,
sort_order: params.sort_order || 'desc',
order_by: params.order_by || 'created_at',
}
});
return {
data: response.data?.data || [],
page: response.data?.page || 1,
size: response.data?.size || 10,
total: response.data?.total || 0,
totalPages: response.data?.total_pages || 0,
hasNext: response.data?.has_next || false,
hasPrev: response.data?.has_prev || false,
};
} catch (error) {
console.error('Failed to fetch operation logs:', error);
throw error;
}
};
/**
* 获取操作统计信息
*/
export const fetchOperationStatistics = async () => {
try {
// Get token from localStorage
const storedUser = localStorage.getItem('user');
let headers = {};
if (storedUser) {
const userData = JSON.parse(storedUser);
if (userData.token) {
headers = {
'Authorization': `Bearer ${userData.token}`
};
}
}
const response = await getOperationStatisticsApiV1LogsOperationOperationLogsStatisticsGet({
headers
});
return response.data;
} catch (error) {
console.error('Failed to fetch operation statistics:', error);
throw error;
}
};
/**
* 导出操作日志
*/
export const exportOperationLogs = async (params: OperationLogsQueryParams = {}) => {
try {
// Get token from localStorage
const storedUser = localStorage.getItem('user');
let headers = {};
if (storedUser) {
const userData = JSON.parse(storedUser);
if (userData.token) {
headers = {
'Authorization': `Bearer ${userData.token}`
};
}
}
const response = await exportOperationLogsApiV1LogsOperationOperationLogsExportGet({
headers,
query: {
username: params.username,
module: params.module,
action: params.action,
operation_type: params.operation_type,
response_status: params.response_status,
start_time: params.start_time,
end_time: params.end_time,
}
});
return response.data;
} catch (error) {
console.error('Failed to export operation logs:', error);
throw error;
}
};
/**
* 转换操作日志数据 - 适配组件使用
*/
export const transformOperationLogData = (log: any): OperationLog => ({
id: log.id,
created_at: log.created_at,
updated_at: log.updated_at,
username: log.username,
user_id: log.user_id,
operation_type: log.operation_type,
module: log.module,
action: log.action,
request_method: log.request_method,
request_url: log.request_url,
request_headers: log.request_headers,
request_body: log.request_body,
request_params: log.request_params,
response_status: log.response_status,
response_body: log.response_body,
error_message: log.error_message,
processing_time: log.processing_time,
});
/**
* 批量转换操作日志数据
*/
export const transformOperationLogsList = (logs: any[]): OperationLog[] => {
return logs.map(transformOperationLogData);
};

View File

@@ -0,0 +1,378 @@
/**
* filekorolheader: 操作日志页面 - 用户操作行为监控页面
* 功能:操作日志查询、统计、导出、筛选
* 路径:/central-config/monitor/operation-log
* 规范遵循crop-x/docs/开发项目规范.md使用SearchFormPagination重构事件驱动模式
*/
'use client';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Download, Eye, CheckCircle, XCircle, Clock, AlertTriangle } from 'lucide-react';
import { SearchFormPagination, type SearchFieldConfig, type TableColumnConfig } from '@/components/common/searchFormPagination';
import {
fetchOperationLogs,
transformOperationLogsList,
OperationLog,
PaginationState,
OperationLogsQueryParams,
exportOperationLogs
} from './components';
export default function OperationLogPage() {
const [logs, setLogs] = useState<OperationLog[]>([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState<PaginationState>({
page: 1,
size: 10,
total: 0,
totalPages: 0,
hasNext: false,
hasPrev: false,
});
const [searchFilters, setSearchFilters] = useState<Record<string, string>>({
search: '',
module: 'all',
status: 'all'
});
const isFirstLoad = useRef(true);
// 搜索字段配置
const searchFields: SearchFieldConfig[] = [
{
key: 'search',
label: '搜索',
type: 'text',
placeholder: '搜索操作人、模块、操作...',
},
{
key: 'module',
label: '模块',
type: 'select',
defaultValue: 'all',
options: [
{ value: 'all', label: '全部模块' },
{ value: '用户管理', label: '用户管理' },
{ value: '企业管理', label: '企业管理' },
{ value: '系统配置', label: '系统配置' },
{ value: '数据管理', label: '数据管理' },
],
},
{
key: 'status',
label: '状态',
type: 'select',
defaultValue: 'all',
options: [
{ value: 'all', label: '全部状态' },
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
],
},
];
// 表格列配置
const columns: TableColumnConfig[] = [
{
key: 'created_at',
label: '操作时间',
render: (value: string) => (
<div className="text-sm text-muted-foreground">
{value ? new Date(value).toLocaleString('zh-CN') : '-'}
</div>
),
},
{
key: 'username',
label: '操作人',
render: (value: string) => (
<div className="font-medium text-foreground">{value || '-'}</div>
),
},
{
key: 'module',
label: '模块',
render: (value: string) => (
<Badge variant="outline" className="bg-blue-50 dark:bg-blue-950 text-blue-600 dark:text-blue-400">
{value || '-'}
</Badge>
),
},
{
key: 'action',
label: '操作',
render: (value: string) => (
<div className="text-sm font-medium">{value || '-'}</div>
),
},
{
key: 'processing_time',
label: '耗时',
render: (value: number) => (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="w-3 h-3" />
{value ? `${(value * 1000).toFixed(0)}ms` : '-'}
</div>
),
},
{
key: 'response_status',
label: '状态',
render: (value: number) => {
if (value >= 200 && value < 300) {
return (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<CheckCircle className="w-3 h-3 mr-1" />
</Badge>
);
} else {
return (
<Badge className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
<XCircle className="w-3 h-3 mr-1" />
</Badge>
);
}
},
},
{
key: 'actions',
label: '操作',
render: (_: any, row: OperationLog) => (
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleViewDetail(row)}
className="h-8 px-2"
title="查看详情"
>
<Eye className="w-3 h-3 mr-1" />
</Button>
</div>
),
},
];
// 加载操作日志数据 - 事件驱动模式
const loadOperationLogs = useCallback(async (params?: {
filters?: Record<string, string>;
pagination?: { page: number; size: number };
resetPage?: boolean;
}) => {
try {
setLoading(true);
const queryParams: OperationLogsQueryParams = {
page: params?.resetPage ? 1 : (params?.pagination?.page || pagination.page),
size: params?.pagination?.size || pagination.size,
sort_order: 'desc',
order_by: 'created_at',
};
// 处理搜索条件
const searchKeyword = params?.filters?.search ?? searchFilters.search;
if (searchKeyword) {
queryParams.username = searchKeyword;
queryParams.module = searchKeyword;
queryParams.action = searchKeyword;
}
// 处理模块筛选
const module = params?.filters?.module ?? searchFilters.module;
if (module !== 'all') {
queryParams.module = module;
}
// 处理状态筛选
const status = params?.filters?.status ?? searchFilters.status;
if (status !== 'all') {
queryParams.response_status = status === 'success' ? 200 : 400;
}
const response = await fetchOperationLogs(queryParams);
const transformedLogs = transformOperationLogsList(response.data);
setLogs(transformedLogs);
setPagination({
page: response.page,
size: response.size,
total: response.total,
totalPages: response.totalPages,
hasNext: response.hasNext,
hasPrev: response.hasPrev,
});
} catch (error) {
console.error('Failed to load operation logs:', error);
const errorMessage = error instanceof Error ? error.message : '加载操作日志失败';
toast.error(errorMessage);
} finally {
setLoading(false);
}
}, [pagination.page, pagination.size, searchFilters]);
// 初始化数据 - 只在组件挂载时执行一次
useEffect(() => {
if (isFirstLoad.current) {
isFirstLoad.current = false;
loadOperationLogs({ resetPage: true });
}
}, [loadOperationLogs]);
// 事件处理器 - 事件驱动模式
const handleSearch = useCallback((filters: Record<string, string>) => {
setSearchFilters(filters);
loadOperationLogs({
filters,
pagination: { page: 1, size: pagination.size }
});
}, [loadOperationLogs, pagination.size]);
const handlePageChange = useCallback((page: number) => {
setPagination(prev => ({ ...prev, page }));
loadOperationLogs({
filters: searchFilters,
pagination: { page, size: pagination.size }
});
}, [loadOperationLogs, searchFilters, pagination.size]);
const handleSizeChange = useCallback((size: number) => {
setPagination(prev => ({ ...prev, size, page: 1 }));
loadOperationLogs({
filters: searchFilters,
pagination: { page: 1, size }
});
}, [loadOperationLogs, searchFilters]);
// 计算统计数据
const stats = useMemo(() => {
const totalOperations = logs.length;
const successfulOperations = logs.filter(log => log.response_status >= 200 && log.response_status < 300).length;
const failedOperations = totalOperations - successfulOperations;
const averageProcessingTime = logs.length > 0
? logs.reduce((sum, log) => sum + log.processing_time, 0) / logs.length
: 0;
return {
totalOperations,
successfulOperations,
failedOperations,
averageProcessingTime
};
}, [logs]);
// 查看详情
const handleViewDetail = (log: OperationLog) => {
toast.info(`查看操作日志详情: ${log.username} - ${log.action}`);
};
// 导出日志
const handleExport = async () => {
try {
setLoading(true);
const exportData = await exportOperationLogs({
username: searchFilters.search,
module: searchFilters.module === 'all' ? undefined : searchFilters.module,
response_status: searchFilters.status === 'all' ? undefined :
searchFilters.status === 'success' ? 200 : 400,
});
// 创建下载链接
const dataStr = JSON.stringify(exportData, 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 = `operation_logs_${new Date().getTime()}.json`;
link.click();
URL.revokeObjectURL(url);
toast.success('导出成功');
} catch (error) {
console.error('Failed to export operation logs:', error);
const errorMessage = error instanceof Error ? error.message : '导出失败';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
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>
<Button onClick={handleExport} disabled={loading}>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="p-6 bg-card hover:bg-muted transition-colors border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold text-blue-600">{stats.totalOperations}</div>
</div>
<div className="text-xs text-muted-foreground">
</div>
</div>
<div className="p-6 bg-card hover:bg-muted transition-colors border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold text-green-600">{stats.successfulOperations}</div>
</div>
<div className="text-xs text-green-600">
</div>
</div>
<div className="p-6 bg-card hover:bg-muted transition-colors border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold text-red-600">{stats.failedOperations}</div>
</div>
<div className="text-xs text-red-600">
</div>
</div>
<div className="p-6 bg-card hover:bg-muted transition-colors border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold text-orange-600">{Math.round(stats.averageProcessingTime * 1000)}ms</div>
</div>
<div className="text-xs text-orange-600">
</div>
</div>
</div>
{/* 搜索、表格和分页 */}
<SearchFormPagination
formTitle="操作日志列表"
searchFields={searchFields}
columns={columns}
data={logs}
loading={loading}
error={null}
pagination={pagination}
onPageChange={handlePageChange}
onSizeChange={handleSizeChange}
onSearch={handleSearch}
emptyText="暂无操作日志数据"
showSizeSelector={true}
showPageInfo={true}
sizeOptions={[10, 20, 50, 100]}
/>
</div>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import React from 'react';
import Link from 'next/link';
export default function MonitorPage() {
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-4 gap-4">
<Link href="/central-config/monitor/login-log" 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/monitor/operation-log" 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/monitor/performance" 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/monitor/network-log" 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>
);
}

View File

@@ -0,0 +1,40 @@
import { Card } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Cpu } from 'lucide-react';
import { SystemPerformance } from '@/types/monitor';
interface CpuMetricCardProps {
performance: SystemPerformance;
getUsageColor: (usage: number) => string;
getUsageStatus: (usage: number) => string;
}
export function CpuMetricCard({ performance, getUsageColor, getUsageStatus }: CpuMetricCardProps) {
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Cpu className="w-5 h-5 text-blue-600" />
<h3>CPU使用率</h3>
</div>
<Badge className={getUsageColor(performance.cpu.usage)}>
{getUsageStatus(performance.cpu.usage)}
</Badge>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">使</span>
<span className={`${getUsageColor(performance.cpu.usage)}`}>
{performance.cpu.usage.toFixed(1)}%
</span>
</div>
<Progress value={performance.cpu.usage} className="h-2" />
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"></span>
<span>{performance.cpu.cores} </span>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,51 @@
import { Card } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { HardDrive } from 'lucide-react';
import { SystemPerformance } from '@/types/monitor';
interface DiskMetricCardProps {
performance: SystemPerformance;
getUsageColor: (usage: number) => string;
getUsageStatus: (usage: number) => string;
formatBytes: (bytes: number) => string;
}
export function DiskMetricCard({ performance, getUsageColor, getUsageStatus, formatBytes }: DiskMetricCardProps) {
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<HardDrive className="w-5 h-5 text-purple-600" />
<h3>使</h3>
</div>
<Badge className={getUsageColor(performance.disk.usage)}>
{getUsageStatus(performance.disk.usage)}
</Badge>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">使</span>
<span className={getUsageColor(performance.disk.usage)}>
{performance.disk.usage.toFixed(1)}%
</span>
</div>
<Progress value={performance.disk.usage} className="h-2" />
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-muted-foreground"></span>
<p>{formatBytes(performance.disk.used)}</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<p>{formatBytes(performance.disk.free)}</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<p>{formatBytes(performance.disk.total)}</p>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,53 @@
import { Card } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Server } from 'lucide-react';
import { SystemPerformance } from '@/types/monitor';
interface JvmInfoCardProps {
performance: SystemPerformance;
formatBytes: (bytes: number) => string;
}
export function JvmInfoCard({ performance, formatBytes }: JvmInfoCardProps) {
if (!performance.jvm) {
return null;
}
return (
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<Server className="w-5 h-5 text-orange-600" />
<h3>JVM信息</h3>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">使</p>
<p className="mt-1">{performance.jvm.heapUsage.toFixed(1)}%</p>
<Progress value={performance.jvm.heapUsage} className="h-1 mt-2" />
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1">
{formatBytes(performance.jvm.heapUsed)} / {formatBytes(performance.jvm.heapMax)}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1">{formatBytes(performance.jvm.nonHeapUsed)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">线</p>
<p className="mt-1">{performance.jvm.threadCount}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">GC次数</p>
<p className="mt-1">{performance.jvm.gcCount}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">GC耗时</p>
<p className="mt-1">{performance.jvm.gcTime}ms</p>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,47 @@
import { Card } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { MemoryStick } from 'lucide-react';
import { SystemPerformance } from '@/types/monitor';
interface MemoryMetricCardProps {
performance: SystemPerformance;
getUsageColor: (usage: number) => string;
getUsageStatus: (usage: number) => string;
formatBytes: (bytes: number) => string;
}
export function MemoryMetricCard({ performance, getUsageColor, getUsageStatus, formatBytes }: MemoryMetricCardProps) {
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<MemoryStick className="w-5 h-5 text-green-600" />
<h3>使</h3>
</div>
<Badge className={getUsageColor(performance.memory.usage)}>
{getUsageStatus(performance.memory.usage)}
</Badge>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">使</span>
<span className={getUsageColor(performance.memory.usage)}>
{performance.memory.usage.toFixed(1)}%
</span>
</div>
<Progress value={performance.memory.usage} className="h-2" />
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-muted-foreground"></span>
<p>{formatBytes(performance.memory.used)}</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<p>{formatBytes(performance.memory.total)}</p>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,20 @@
import { Card } from '@/components/ui/card';
import { Activity } from 'lucide-react';
export function PerformanceInstructions() {
return (
<Card className="p-4 bg-blue-50 border-blue-200">
<h4 className="text-blue-900 mb-2">
<Activity className="w-4 h-4 inline mr-2" />
</h4>
<ul className="space-y-1 text-sm text-blue-800">
<li> 5</li>
<li> 使60%80%</li>
<li> 20</li>
<li> 使</li>
<li> JVM和Tomcat信息仅在Java环境下可用</li>
</ul>
</Card>
);
}

View File

@@ -0,0 +1,42 @@
import { Card } from '@/components/ui/card';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { SystemPerformance } from '@/types/monitor';
interface PerformanceTrendChartProps {
history: SystemPerformance[];
}
export function PerformanceTrendChart({ history }: PerformanceTrendChartProps) {
if (history.length <= 1) {
return null;
}
const chartData = history.map((item) => ({
time: new Date(item.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}),
CPU: item.cpu.usage.toFixed(1),
内存: item.memory.usage.toFixed(1),
磁盘: item.disk.usage.toFixed(1),
}));
return (
<Card className="p-6">
<h3 className="mb-4"></h3>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="CPU" stroke="#3b82f6" strokeWidth={2} />
<Line type="monotone" dataKey="内存" stroke="#10b981" strokeWidth={2} />
<Line type="monotone" dataKey="磁盘" stroke="#a855f7" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</Card>
);
}

View File

@@ -0,0 +1,48 @@
import { Card } from '@/components/ui/card';
import { Activity } from 'lucide-react';
import { SystemPerformance } from '@/types/monitor';
interface TomcatInfoCardProps {
performance: SystemPerformance;
}
export function TomcatInfoCard({ performance }: TomcatInfoCardProps) {
if (!performance.tomcat) {
return null;
}
return (
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<Activity className="w-5 h-5 text-red-600" />
<h3>Tomcat信息</h3>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div>
<p className="text-sm text-muted-foreground">线</p>
<p className="mt-1">
{performance.tomcat.threadCount} / {performance.tomcat.maxThreads}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1">{performance.tomcat.connectionCount}</p>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1">{performance.tomcat.requestCount.toLocaleString()}</p>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1 text-red-600">{performance.tomcat.errorCount}</p>
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1">
{((performance.tomcat.errorCount / performance.tomcat.requestCount) * 100).toFixed(2)}%
</p>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,7 @@
export { CpuMetricCard } from './CpuMetricCard';
export { MemoryMetricCard } from './MemoryMetricCard';
export { DiskMetricCard } from './DiskMetricCard';
export { JvmInfoCard } from './JvmInfoCard';
export { TomcatInfoCard } from './TomcatInfoCard';
export { PerformanceTrendChart } from './PerformanceTrendChart';
export { PerformanceInstructions } from './PerformanceInstructions';

View File

@@ -0,0 +1,274 @@
'use client';
import { useState, useEffect } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { RefreshCw } from 'lucide-react';
import { SystemPerformance } from '@/types/monitor';
import {
CpuMetricCard,
MemoryMetricCard,
DiskMetricCard,
JvmInfoCard,
TomcatInfoCard,
PerformanceTrendChart,
PerformanceInstructions
} from './components';
// API服务函数
const performanceApi = {
// 获取当前性能数据
getCurrentPerformance: async (): Promise<SystemPerformance> => {
try {
const response = await fetch('/api/monitor/performance/current');
if (!response.ok) {
throw new Error('Failed to fetch performance data');
}
return await response.json();
} catch (error) {
console.warn('Failed to fetch performance data, using mock data:', error);
// 如果API调用失败返回模拟数据
return getMockPerformanceData();
}
},
// 获取历史性能数据
getPerformanceHistory: async (limit: number = 20): Promise<SystemPerformance[]> => {
try {
const response = await fetch(`/api/monitor/performance/history?limit=${limit}`);
if (!response.ok) {
throw new Error('Failed to fetch performance history');
}
return await response.json();
} catch (error) {
console.warn('Failed to fetch performance history, using mock data:', error);
// 如果API调用失败返回模拟历史数据
return Array.from({ length: Math.min(5, limit) }, (_, i) => {
const mockData = getMockPerformanceData();
mockData.timestamp = new Date(Date.now() - (4 - i) * 5000).toISOString();
return mockData;
});
}
}
};
// 模拟数据生成函数
function getMockPerformanceData(): SystemPerformance {
const mockData: SystemPerformance = {
timestamp: new Date().toISOString(),
cpu: {
usage: Math.random() * 60 + 20, // 20-80%
cores: 8,
},
memory: {
total: 16384, // 16GB
used: Math.random() * 8192 + 4096, // 4-12GB
free: 0,
usage: 0,
},
disk: {
total: 512, // 512GB
used: Math.random() * 102 + 204, // 204-306GB
free: 0,
usage: 0,
},
jvm: {
heapUsed: Math.random() * 1024 + 512, // 512-1536MB
heapMax: 2048, // 2GB
heapUsage: 0,
nonHeapUsed: Math.random() * 100 + 50,
threadCount: Math.floor(Math.random() * 50 + 100),
gcCount: Math.floor(Math.random() * 10 + 50),
gcTime: Math.floor(Math.random() * 200 + 100),
},
tomcat: {
threadCount: Math.floor(Math.random() * 50 + 50),
maxThreads: 200,
connectionCount: Math.floor(Math.random() * 100 + 50),
requestCount: Math.floor(Math.random() * 10000 + 50000),
errorCount: Math.floor(Math.random() * 10),
},
};
// 计算百分比
mockData.memory.free = mockData.memory.total - mockData.memory.used;
mockData.memory.usage = (mockData.memory.used / mockData.memory.total) * 100;
mockData.disk.free = mockData.disk.total - mockData.disk.used;
mockData.disk.usage = (mockData.disk.used / mockData.disk.total) * 100;
if (mockData.jvm) {
mockData.jvm.heapUsage = (mockData.jvm.heapUsed / mockData.jvm.heapMax) * 100;
}
return mockData;
}
export default function PerformanceMonitorPage() {
const [performance, setPerformance] = useState<SystemPerformance | null>(null);
const [history, setHistory] = useState<SystemPerformance[]>([]);
const [autoRefresh, setAutoRefresh] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadPerformance();
const interval = setInterval(() => {
if (autoRefresh) {
loadPerformance();
}
}, 5000); // 每5秒刷新一次
return () => clearInterval(interval);
}, [autoRefresh]);
const loadPerformance = async () => {
try {
setError(null);
setLoading(true);
// 获取当前数据
const currentData = await performanceApi.getCurrentPerformance();
setPerformance(currentData);
// 保存历史数据最多保留20条
setHistory(prev => {
const newHistory = [...prev, currentData].slice(-20);
return newHistory;
});
} catch (err) {
setError(err instanceof Error ? err.message : '加载性能数据失败');
console.error('Failed to load performance data:', err);
} finally {
setLoading(false);
}
};
const getUsageColor = (usage: number) => {
if (usage < 60) return 'text-green-600';
if (usage < 80) return 'text-yellow-600';
return 'text-red-600';
};
const getUsageStatus = (usage: number) => {
if (usage < 60) return '正常';
if (usage < 80) return '警告';
return '危险';
};
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes.toFixed(2)} MB`;
return `${(bytes / 1024).toFixed(2)} GB`;
};
if (loading && !performance) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<RefreshCw className="w-8 h-8 animate-spin text-green-600 mx-auto mb-2" />
<p className="text-muted-foreground">...</p>
</div>
</div>
);
}
if (error && !performance) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<p className="text-red-600 mb-4">: {error}</p>
<Button onClick={loadPerformance} variant="outline">
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
);
}
if (!performance) {
return null;
}
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 items-center gap-2">
<Badge variant={autoRefresh ? 'default' : 'outline'}>
{autoRefresh ? '自动刷新' : '已暂停'}
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => setAutoRefresh(!autoRefresh)}
>
{autoRefresh ? '暂停' : '启动'}
</Button>
<Button
variant="outline"
size="sm"
onClick={loadPerformance}
disabled={loading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{error && (
<div className="p-4 border border-yellow-200 bg-yellow-50 rounded-md">
<p className="text-yellow-800 text-sm">
: {error} ()
</p>
</div>
)}
{/* CPU和内存卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<CpuMetricCard
performance={performance}
getUsageColor={getUsageColor}
getUsageStatus={getUsageStatus}
/>
<MemoryMetricCard
performance={performance}
getUsageColor={getUsageColor}
getUsageStatus={getUsageStatus}
formatBytes={formatBytes}
/>
</div>
{/* 磁盘卡片 */}
<DiskMetricCard
performance={performance}
getUsageColor={getUsageColor}
getUsageStatus={getUsageStatus}
formatBytes={formatBytes}
/>
{/* JVM信息卡片 */}
<JvmInfoCard
performance={performance}
formatBytes={formatBytes}
/>
{/* Tomcat信息卡片 */}
<TomcatInfoCard
performance={performance}
/>
{/* 性能趋势图 */}
<PerformanceTrendChart
history={history}
/>
{/* 使用说明 */}
<PerformanceInstructions />
</div>
);
}

View File

@@ -0,0 +1,13 @@
import Link from 'next/link'
import { Metadata } from 'next'
export const metadata: Metadata = {
title: '中心配置管理 - Crop-X 智慧农业管理系统',
description: '中心配置管理系统主页面',
}
export default function CentralConfigPage() {
return (
<div>使</div>
)
}

View File

@@ -0,0 +1,23 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface LoginHistoryProps {
userId: string;
}
export function LoginHistory({ userId }: LoginHistoryProps) {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-8 text-gray-500">
<p></p>
<p className="text-sm"></p>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import type { SecuritySettings } from '../types';
interface NotificationSettingsProps {
securitySettings: SecuritySettings | null;
onUpdate: (updates: Partial<SecuritySettings>) => void;
}
export function NotificationSettings({ securitySettings, onUpdate }: NotificationSettingsProps) {
const handleToggle = (field: keyof SecuritySettings) => {
onUpdate({ [field]: !securitySettings?.[field] });
};
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium"></p>
<p className="text-sm text-gray-600"></p>
</div>
<Button
variant={securitySettings?.emailNotification ? "default" : "outline"}
onClick={() => handleToggle('emailNotification')}
>
{securitySettings?.emailNotification ? '已启用' : '已禁用'}
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium"></p>
<p className="text-sm text-gray-600"></p>
</div>
<Button
variant={securitySettings?.smsNotification ? "default" : "outline"}
onClick={() => handleToggle('smsNotification')}
>
{securitySettings?.smsNotification ? '已启用' : '已禁用'}
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium"></p>
<p className="text-sm text-gray-600"></p>
</div>
<Button
variant={securitySettings?.loginAlert ? "default" : "outline"}
onClick={() => handleToggle('loginAlert')}
>
{securitySettings?.loginAlert ? '已启用' : '已禁用'}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { SecuritySettings } from '../types';
interface PasswordSecurityProps {
securitySettings: SecuritySettings | null;
onUpdate: (updates: Partial<SecuritySettings>) => void;
}
export function PasswordSecurity({ securitySettings, onUpdate }: PasswordSecurityProps) {
const handlePasswordChange = () => {
// TODO: 实现密码修改功能
onUpdate({ lastPasswordChange: new Date().toISOString() });
};
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label></Label>
<div className={`mt-1 p-2 rounded ${
securitySettings?.passwordStrength === 'strong' ? 'bg-green-100 text-green-800' :
securitySettings?.passwordStrength === 'medium' ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'
}`}>
{securitySettings?.passwordStrength === 'strong' ? '强' :
securitySettings?.passwordStrength === 'medium' ? '中等' : '弱'}
</div>
</div>
<div>
<Label></Label>
<p className="text-sm text-gray-600">
{securitySettings?.lastPasswordChange ?
new Date(securitySettings.lastPasswordChange).toLocaleString('zh-CN') :
'未记录'
}
</p>
</div>
<Button onClick={handlePasswordChange} className="w-full">
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,188 @@
'use client';
import { Shield, Key, Smartphone, AlertTriangle, CheckCircle, Clock } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button';
import type { SecuritySettings } from '../types';
interface SecurityOverviewProps {
securitySettings: SecuritySettings | null;
onTabChange: (tab: string) => void;
}
export function SecurityOverview({ securitySettings, onTabChange }: SecurityOverviewProps) {
const securityScore = calculateSecurityScore(securitySettings);
function calculateSecurityScore(settings: SecuritySettings | null): number {
if (!settings) return 0;
let score = 0;
// 密码强度 (30%)
if (settings.passwordStrength === 'strong') score += 30;
else if (settings.passwordStrength === 'medium') score += 20;
else score += 10;
// 双因素认证 (25%)
if (settings.twoFactorEnabled) score += 25;
// 安全问题设置 (20%)
if (settings.securityQuestions.length > 0) score += 20;
// 登录提醒 (15%)
if (settings.loginAlert) score += 15;
// 信任设备管理 (10%)
if (settings.trustedDevices.length <= 3) score += 10;
return score;
}
const getSecurityLevel = (score: number) => {
if (score >= 80) return { level: '高', color: 'text-green-600', bg: 'bg-green-50' };
if (score >= 60) return { level: '中', color: 'text-yellow-600', bg: 'bg-yellow-50' };
return { level: '低', color: 'text-red-600', bg: 'bg-red-50' };
};
const securityLevel = getSecurityLevel(securityScore);
const securityItems = [
{
title: '密码强度',
icon: Key,
status: securitySettings?.passwordStrength === 'strong' ? 'good' :
securitySettings?.passwordStrength === 'medium' ? 'warning' : 'danger',
description: securitySettings?.passwordStrength === 'strong' ? '强密码' :
securitySettings?.passwordStrength === 'medium' ? '中等强度' : '弱密码',
action: () => onTabChange('password')
},
{
title: '双因素认证',
icon: Smartphone,
status: securitySettings?.twoFactorEnabled ? 'good' : 'warning',
description: securitySettings?.twoFactorEnabled ? '已启用' : '未启用',
action: () => onTabChange('twoFactor')
},
{
title: '安全问题',
icon: Shield,
status: securitySettings?.securityQuestions.length > 0 ? 'good' : 'warning',
description: securitySettings?.securityQuestions.length > 0 ?
`已设置 ${securitySettings.securityQuestions.length} 个问题` : '未设置',
action: () => onTabChange('questions')
},
{
title: '登录提醒',
icon: Clock,
status: securitySettings?.loginAlert ? 'good' : 'warning',
description: securitySettings?.loginAlert ? '已启用' : '未启用',
action: () => onTabChange('notifications')
}
];
return (
<div className="space-y-6">
{/* 安全评分卡片 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Shield className="h-5 w-5" />
<span></span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center">
<div className={`inline-flex items-center justify-center w-24 h-24 rounded-full ${securityLevel.bg} mb-4`}>
<span className={`text-3xl font-bold ${securityLevel.color}`}>
{securityScore}
</span>
</div>
<h3 className={`text-lg font-semibold ${securityLevel.color}`}>
: {securityLevel.level}
</h3>
<Progress value={securityScore} className="mt-4" />
<p className="text-sm text-gray-500 mt-2">
</p>
</div>
</CardContent>
</Card>
{/* 安全项列表 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{securityItems.map((item, index) => {
const Icon = item.icon;
return (
<Card key={index}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<div className={`p-2 rounded-full ${
item.status === 'good' ? 'bg-green-100' :
item.status === 'warning' ? 'bg-yellow-100' : 'bg-red-100'
}`}>
<Icon className={`h-5 w-5 ${
item.status === 'good' ? 'text-green-600' :
item.status === 'warning' ? 'text-yellow-600' : 'text-red-600'
}`} />
</div>
<div>
<h4 className="font-semibold">{item.title}</h4>
<p className="text-sm text-gray-600">{item.description}</p>
</div>
</div>
<div className="flex items-center space-x-1">
{item.status === 'good' && <CheckCircle className="h-4 w-4 text-green-500" />}
{item.status === 'warning' && <AlertTriangle className="h-4 w-4 text-yellow-500" />}
{item.status === 'danger' && <AlertTriangle className="h-4 w-4 text-red-500" />}
{item.status !== 'good' && (
<Button variant="outline" size="sm" onClick={item.action}>
</Button>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* 最近活动 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-green-50 rounded">
<div className="flex items-center space-x-3">
<CheckCircle className="h-5 w-5 text-green-500" />
<div>
<p className="font-medium"></p>
<p className="text-sm text-gray-600">
{securitySettings?.lastLoginTime ?
new Date(securitySettings.lastLoginTime).toLocaleString('zh-CN') :
'未知时间'
}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-medium">{securitySettings?.lastLoginIp}</p>
<p className="text-xs text-gray-500"></p>
</div>
</div>
</div>
<div className="text-center mt-4">
<Button variant="outline" onClick={() => onTabChange('history')}>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { SecuritySettings, SecurityQuestion } from '../types';
interface SecurityQuestionsProps {
questions: SecurityQuestion[];
onUpdate: (updates: Partial<SecuritySettings>) => void;
}
export function SecurityQuestions({ questions, onUpdate }: SecurityQuestionsProps) {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{questions.map((question, index) => (
<div key={question.id} className="p-4 border rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="font-medium"> {index + 1}: {question.question}</p>
<p className="text-sm text-gray-600">
{question.isEnabled ? '已启用' : '已禁用'}
</p>
</div>
</div>
</div>
))}
{questions.length === 0 && (
<div className="text-center py-8 text-gray-500">
<p></p>
<p className="text-sm"></p>
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { SecuritySettings, TrustedDevice } from '../types';
interface TrustedDevicesProps {
devices: TrustedDevice[];
onUpdate: (updates: Partial<SecuritySettings>) => void;
}
export function TrustedDevices({ devices, onUpdate }: TrustedDevicesProps) {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{devices.map((device) => (
<div
key={device.id}
className={`p-4 border rounded-lg ${
device.isCurrent ? 'border-blue-500 bg-blue-50' : 'border-gray-200'
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center space-x-2">
<p className="font-medium">{device.deviceName}</p>
{device.isCurrent && (
<span className="text-xs bg-blue-500 text-white px-2 py-1 rounded">
</span>
)}
</div>
<p className="text-sm text-gray-600">
{device.browser} {device.os}
</p>
<p className="text-xs text-gray-500">
{device.ipAddress} {device.location}
</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-500">
: {new Date(device.lastActive).toLocaleString('zh-CN')}
</p>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import type { SecuritySettings } from '../types';
interface TwoFactorAuthProps {
securitySettings: SecuritySettings | null;
onUpdate: (updates: Partial<SecuritySettings>) => void;
}
export function TwoFactorAuth({ securitySettings, onUpdate }: TwoFactorAuthProps) {
const handleToggle = () => {
onUpdate({ twoFactorEnabled: !securitySettings?.twoFactorEnabled });
};
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">
{securitySettings?.twoFactorEnabled ? '已启用' : '未启用'}
</p>
<p className="text-sm text-gray-600">
</p>
</div>
<Button
variant={securitySettings?.twoFactorEnabled ? "destructive" : "default"}
onClick={handleToggle}
>
{securitySettings?.twoFactorEnabled ? '禁用' : '启用'}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,549 @@
'use client';
import { useReducer } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Shield, Lock, Key, CheckCircle, XCircle, Eye, EyeOff, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
// Types
interface PasswordForm {
oldPassword: string;
newPassword: string;
confirmPassword: string;
}
interface SecurityState {
user: {
username: string;
phone: string;
email: string;
lastLoginTime: string;
lastLoginDevice: string;
lastLoginIp: string;
};
showPasswordDialog: boolean;
showOldPassword: boolean;
showNewPassword: boolean;
showConfirmPassword: boolean;
passwordForm: PasswordForm;
passwordStrength: {
checks: {
length: boolean;
hasUpper: boolean;
hasLower: boolean;
hasNumber: boolean;
hasSpecial: boolean;
};
strength: 'weak' | 'medium' | 'strong';
passedCount: number;
};
}
type SecurityAction =
| { type: 'TOGGLE_PASSWORD_DIALOG'; payload: boolean }
| { type: 'TOGGLE_OLD_PASSWORD_VISIBILITY' }
| { type: 'TOGGLE_NEW_PASSWORD_VISIBILITY' }
| { type: 'TOGGLE_CONFIRM_PASSWORD_VISIBILITY' }
| { type: 'UPDATE_PASSWORD_FORM'; payload: Partial<PasswordForm> }
| { type: 'UPDATE_PASSWORD_STRENGTH'; payload: SecurityState['passwordStrength'] };
// Initial state
const initialState: SecurityState = {
user: {
username: 'admin',
phone: '13800138000',
email: 'admin@smart-agriculture.com',
lastLoginTime: '2024-10-14 09:30:00',
lastLoginDevice: 'Windows PC - Chrome 120.0',
lastLoginIp: '192.168.1.100'
},
showPasswordDialog: false,
showOldPassword: false,
showNewPassword: false,
showConfirmPassword: false,
passwordForm: {
oldPassword: '',
newPassword: '',
confirmPassword: ''
},
passwordStrength: {
checks: {
length: false,
hasUpper: false,
hasLower: false,
hasNumber: false,
hasSpecial: false
},
strength: 'weak',
passedCount: 0
}
};
// Reducer
function securityReducer(state: SecurityState, action: SecurityAction): SecurityState {
switch (action.type) {
case 'TOGGLE_PASSWORD_DIALOG':
return {
...state,
showPasswordDialog: action.payload,
...(action.payload === false ? {
passwordForm: {
oldPassword: '',
newPassword: '',
confirmPassword: ''
},
passwordStrength: {
checks: {
length: false,
hasUpper: false,
hasLower: false,
hasNumber: false,
hasSpecial: false
},
strength: 'weak' as const,
passedCount: 0
}
} : {})
};
case 'TOGGLE_OLD_PASSWORD_VISIBILITY':
return { ...state, showOldPassword: !state.showOldPassword };
case 'TOGGLE_NEW_PASSWORD_VISIBILITY':
return { ...state, showNewPassword: !state.showNewPassword };
case 'TOGGLE_CONFIRM_PASSWORD_VISIBILITY':
return { ...state, showConfirmPassword: !state.showConfirmPassword };
case 'UPDATE_PASSWORD_FORM':
return {
...state,
passwordForm: { ...state.passwordForm, ...action.payload }
};
case 'UPDATE_PASSWORD_STRENGTH':
return {
...state,
passwordStrength: action.payload
};
default:
return state;
}
}
// Password strength checker
const checkPasswordStrength = (password: string): SecurityState['passwordStrength'] => {
const checks = {
length: password.length >= 8,
hasUpper: /[A-Z]/.test(password),
hasLower: /[a-z]/.test(password),
hasNumber: /[0-9]/.test(password),
hasSpecial: /[!@#$%^&*(),.?":{}|<>]/.test(password),
};
const passedCount = Object.values(checks).filter(Boolean).length;
let strength: 'weak' | 'medium' | 'strong' = 'weak';
if (passedCount >= 4) strength = 'strong';
else if (passedCount >= 3) strength = 'medium';
return { checks, strength, passedCount };
};
// Utility functions
const getStrengthColor = (strength: 'weak' | 'medium' | 'strong') => {
switch (strength) {
case 'strong':
return 'text-green-600';
case 'medium':
return 'text-yellow-600';
case 'weak':
return 'text-red-600';
}
};
const getStrengthBg = (strength: 'weak' | 'medium' | 'strong') => {
switch (strength) {
case 'strong':
return 'bg-green-100';
case 'medium':
return 'bg-yellow-100';
case 'weak':
return 'bg-red-100';
}
};
const getStrengthText = (strength: 'weak' | 'medium' | 'strong') => {
switch (strength) {
case 'strong':
return '强';
case 'medium':
return '中';
case 'weak':
return '弱';
}
};
export default function AccountSecurity() {
const [state, dispatch] = useReducer(securityReducer, initialState);
const handleChangePassword = () => {
dispatch({ type: 'UPDATE_PASSWORD_FORM', payload: { oldPassword: '', newPassword: '', confirmPassword: '' } });
dispatch({ type: 'TOGGLE_PASSWORD_DIALOG', payload: true });
};
const handleConfirmChangePassword = () => {
const { passwordForm } = state;
// Validation
if (!passwordForm.oldPassword) {
toast.error('请输入原密码');
return;
}
if (!passwordForm.newPassword) {
toast.error('请输入新密码');
return;
}
if (passwordForm.newPassword.length < 8) {
toast.error('密码长度至少为8位');
return;
}
if (state.passwordStrength.strength === 'weak') {
toast.error('密码强度过弱,请使用更复杂的密码');
return;
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error('两次输入的密码不一致');
return;
}
if (passwordForm.oldPassword === passwordForm.newPassword) {
toast.error('新密码不能与原密码相同');
return;
}
// Mock password validation (assuming old password is "123456")
if (passwordForm.oldPassword !== '123456') {
toast.error('原密码错误');
return;
}
// Success
toast.success('密码修改成功,请使用新密码重新登录');
dispatch({ type: 'TOGGLE_PASSWORD_DIALOG', payload: false });
};
const updatePassword = (field: keyof PasswordForm, value: string) => {
const updatePayload = {
[field]: value || ''
};
dispatch({ type: 'UPDATE_PASSWORD_FORM', payload: updatePayload });
if (field === 'newPassword') {
const strength = checkPasswordStrength(value || '');
dispatch({ type: 'UPDATE_PASSWORD_STRENGTH', payload: strength });
}
};
return (
<div className="space-y-6">
{/* Page Header */}
<Card className="p-6 bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200">
<div className="flex items-start gap-3">
<Shield className="w-6 h-6 text-blue-600 flex-shrink-0 mt-1" />
<div className="flex-1">
<h2 className="text-green-800 mb-2"></h2>
<p className="text-sm text-muted-foreground mb-3">
</p>
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="bg-white">
<Lock className="w-3 h-3 mr-1" />
</Badge>
<Badge variant="outline" className="bg-white">
<Key className="w-3 h-3 mr-1" />
</Badge>
<Badge variant="outline" className="bg-white">
<Shield className="w-3 h-3 mr-1" />
</Badge>
</div>
</div>
</div>
</Card>
{/* Account Overview */}
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="grid grid-cols-2 gap-6">
<div className="p-4 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-2"></div>
<div className="font-medium">{state.user.username}</div>
</div>
<div className="p-4 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-2"></div>
<div className="font-medium">{state.user.phone}</div>
</div>
<div className="p-4 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-2"></div>
<div className="font-medium">{state.user.email}</div>
</div>
<div className="p-4 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-2"></div>
<div className="font-medium text-sm">{state.user.lastLoginTime}</div>
</div>
</div>
</Card>
{/* Password Management */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="mb-1"></h3>
<p className="text-sm text-muted-foreground"></p>
</div>
<Button onClick={handleChangePassword} className="gap-2">
<Key className="w-4 h-4" />
</Button>
</div>
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<Shield className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-blue-900 mb-2"></h4>
<ul className="text-sm text-blue-800 space-y-1">
<li> 8</li>
<li> </li>
<li> 使</li>
<li> 3</li>
<li> 使</li>
</ul>
</div>
</div>
</div>
</Card>
{/* Security Settings */}
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
<div>
<div className="font-medium"></div>
<div className="text-sm text-muted-foreground">{state.user.phone}</div>
</div>
</div>
<Badge className="bg-green-100 text-green-700"></Badge>
</div>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<Lock className="w-5 h-5 text-blue-600" />
</div>
<div>
<div className="font-medium"></div>
<div className="text-sm text-muted-foreground"></div>
</div>
</div>
<Badge className="bg-blue-100 text-blue-700"></Badge>
</div>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-yellow-600" />
</div>
<div>
<div className="font-medium"></div>
<div className="text-sm text-muted-foreground">{state.user.email}</div>
</div>
</div>
<Badge className="bg-green-100 text-green-700"></Badge>
</div>
</div>
</Card>
{/* Recent Login Records */}
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-green-600" />
<div>
<div className="text-sm font-medium"></div>
<div className="text-xs text-muted-foreground">
{state.user.lastLoginDevice} · {state.user.lastLoginIp}
</div>
</div>
</div>
<div className="text-sm text-muted-foreground">
{state.user.lastLoginTime}
</div>
</div>
</div>
</Card>
{/* Change Password Dialog */}
<Dialog open={state.showPasswordDialog} onOpenChange={(open) => dispatch({ type: 'TOGGLE_PASSWORD_DIALOG', payload: open })}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
<div className="flex items-center gap-2">
<Lock className="w-5 h-5 text-blue-600" />
</div>
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Old Password */}
<div>
<Label> *</Label>
<div className="relative mt-2">
<Input
type={state.showOldPassword ? 'text' : 'password'}
value={state.passwordForm.oldPassword}
onChange={(e) => updatePassword('oldPassword', e.target.value)}
placeholder="请输入原密码"
className="pr-10"
/>
<button
type="button"
onClick={() => dispatch({ type: 'TOGGLE_OLD_PASSWORD_VISIBILITY' })}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{state.showOldPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* New Password */}
<div>
<Label> *</Label>
<div className="relative mt-2">
<Input
type={state.showNewPassword ? 'text' : 'password'}
value={state.passwordForm.newPassword}
onChange={(e) => updatePassword('newPassword', e.target.value)}
placeholder="请输入新密码至少8位"
className="pr-10"
/>
<button
type="button"
onClick={() => dispatch({ type: 'TOGGLE_NEW_PASSWORD_VISIBILITY' })}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{state.showNewPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{/* Password Strength Indicator */}
{state.passwordForm.newPassword && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<Badge className={`${getStrengthBg(state.passwordStrength.strength)} ${getStrengthColor(state.passwordStrength.strength)}`}>
{getStrengthText(state.passwordStrength.strength)}
</Badge>
</div>
<div className="space-y-1.5">
<div className={`flex items-center gap-2 text-sm ${state.passwordStrength.checks.length ? 'text-green-600' : 'text-muted-foreground'}`}>
{state.passwordStrength.checks.length ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
<span>8</span>
</div>
<div className={`flex items-center gap-2 text-sm ${state.passwordStrength.checks.hasUpper ? 'text-green-600' : 'text-muted-foreground'}`}>
{state.passwordStrength.checks.hasUpper ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
<span></span>
</div>
<div className={`flex items-center gap-2 text-sm ${state.passwordStrength.checks.hasLower ? 'text-green-600' : 'text-muted-foreground'}`}>
{state.passwordStrength.checks.hasLower ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
<span></span>
</div>
<div className={`flex items-center gap-2 text-sm ${state.passwordStrength.checks.hasNumber ? 'text-green-600' : 'text-muted-foreground'}`}>
{state.passwordStrength.checks.hasNumber ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
<span></span>
</div>
<div className={`flex items-center gap-2 text-sm ${state.passwordStrength.checks.hasSpecial ? 'text-green-600' : 'text-muted-foreground'}`}>
{state.passwordStrength.checks.hasSpecial ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
<span></span>
</div>
</div>
</div>
)}
</div>
{/* Confirm Password */}
<div>
<Label> *</Label>
<div className="relative mt-2">
<Input
type={state.showConfirmPassword ? 'text' : 'password'}
value={state.passwordForm.confirmPassword}
onChange={(e) => updatePassword('confirmPassword', e.target.value)}
placeholder="请再次输入新密码"
className="pr-10"
/>
<button
type="button"
onClick={() => dispatch({ type: 'TOGGLE_CONFIRM_PASSWORD_VISIBILITY' })}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{state.showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{state.passwordForm.confirmPassword && state.passwordForm.newPassword !== state.passwordForm.confirmPassword && (
<p className="text-sm text-red-600 mt-2 flex items-center gap-1">
<XCircle className="w-4 h-4" />
</p>
)}
</div>
{/* Security Notice */}
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-yellow-800">
</p>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => dispatch({ type: 'TOGGLE_PASSWORD_DIALOG', payload: false })}>
</Button>
<Button onClick={handleConfirmChangePassword}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,58 @@
// 账户安全页面类型定义
export interface SecuritySettings {
id: string;
userId: string;
twoFactorEnabled: boolean;
emailNotification: boolean;
smsNotification: boolean;
loginAlert: boolean;
passwordStrength: 'weak' | 'medium' | 'strong';
lastPasswordChange: string;
loginAttempts: number;
lastLoginTime: string;
lastLoginIp: string;
trustedDevices: TrustedDevice[];
securityQuestions: SecurityQuestion[];
}
export interface TrustedDevice {
id: string;
deviceName: string;
deviceType: 'desktop' | 'mobile' | 'tablet';
browser: string;
os: string;
ipAddress: string;
location: string;
lastActive: string;
isCurrent: boolean;
}
export interface SecurityQuestion {
id: string;
question: string;
answer: string;
isEnabled: boolean;
}
export interface PasswordChangeForm {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
export interface SecurityQuestionForm {
question: string;
answer: string;
confirmPassword: string;
}
export interface LoginHistory {
id: string;
loginTime: string;
ipAddress: string;
location: string;
device: string;
status: 'success' | 'failed';
failureReason?: string;
}

View File

@@ -0,0 +1,22 @@
'use client';
import React from 'react';
import Link from 'next/link';
export default function PersonalCenterPage() {
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-4 gap-4">
<Link href="/central-config/personal-center/personal-info" 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/personal-center/account-security" 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>
);
}

View File

@@ -0,0 +1,207 @@
'use client';
import { useState } from 'react';
import { Save, X, User, Mail, Phone, MapPin, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { PersonalInfoForm as PersonalInfoFormType } from '../types';
interface PersonalInfoFormProps {
data: PersonalInfoFormType;
editing: boolean;
onSave: (data: PersonalInfoFormType) => void;
onCancel: () => void;
onChange: (data: PersonalInfoFormType) => void;
}
export function PersonalInfoForm({ data, editing, onSave, onCancel, onChange }: PersonalInfoFormProps) {
const [errors, setErrors] = useState<Record<string, string>>({});
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!data.realName.trim()) {
newErrors.realName = '真实姓名不能为空';
}
if (!data.email.trim()) {
newErrors.email = '邮箱不能为空';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
newErrors.email = '请输入有效的邮箱地址';
}
if (!data.phone.trim()) {
newErrors.phone = '手机号不能为空';
} else if (!/^1[3-9]\d{9}$/.test(data.phone)) {
newErrors.phone = '请输入有效的手机号码';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = () => {
if (validateForm()) {
onSave(data);
}
};
const handleInputChange = (field: keyof PersonalInfoFormType, value: string) => {
onChange({ ...data, [field]: value });
if (errors[field]) {
setErrors({ ...errors, [field]: '' });
}
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle></CardTitle>
{editing && (
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm" onClick={onCancel}>
<X className="h-4 w-4 mr-1" />
</Button>
<Button size="sm" onClick={handleSave}>
<Save className="h-4 w-4 mr-1" />
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="realName" className="flex items-center space-x-2">
<User className="h-4 w-4" />
<span> *</span>
</Label>
<Input
id="realName"
value={data.realName}
onChange={(e) => handleInputChange('realName', e.target.value)}
disabled={!editing}
className={errors.realName ? 'border-red-500' : ''}
placeholder="请输入真实姓名"
/>
{errors.realName && (
<p className="text-sm text-red-500">{errors.realName}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center space-x-2">
<Mail className="h-4 w-4" />
<span> *</span>
</Label>
<Input
id="email"
type="email"
value={data.email}
onChange={(e) => handleInputChange('email', e.target.value)}
disabled={!editing}
className={errors.email ? 'border-red-500' : ''}
placeholder="请输入邮箱地址"
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone" className="flex items-center space-x-2">
<Phone className="h-4 w-4" />
<span> *</span>
</Label>
<Input
id="phone"
value={data.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
disabled={!editing}
className={errors.phone ? 'border-red-500' : ''}
placeholder="请输入手机号码"
/>
{errors.phone && (
<p className="text-sm text-red-500">{errors.phone}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="gender"></Label>
<Select
value={data.gender || ''}
onValueChange={(value) => handleInputChange('gender', value)}
disabled={!editing}
>
<SelectTrigger>
<SelectValue placeholder="请选择性别" />
</SelectTrigger>
<SelectContent>
<SelectItem value="male"></SelectItem>
<SelectItem value="female"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="birthday"></Label>
<Input
id="birthday"
type="date"
value={data.birthday || ''}
onChange={(e) => handleInputChange('birthday', e.target.value)}
disabled={!editing}
/>
</div>
<div className="space-y-2">
<Label htmlFor="address" className="flex items-center space-x-2">
<MapPin className="h-4 w-4" />
<span></span>
</Label>
<Input
id="address"
value={data.address || ''}
onChange={(e) => handleInputChange('address', e.target.value)}
disabled={!editing}
placeholder="请输入联系地址"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bio" className="flex items-center space-x-2">
<FileText className="h-4 w-4" />
<span></span>
</Label>
<Textarea
id="bio"
value={data.bio || ''}
onChange={(e) => handleInputChange('bio', e.target.value)}
disabled={!editing}
placeholder="请输入个人简介"
rows={4}
maxLength={200}
/>
<p className="text-sm text-gray-500">
{data.bio?.length || 0}/200
</p>
</div>
{!editing && (
<div className="text-sm text-gray-500 bg-gray-50 p-3 rounded">
💡 "编辑信息"
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { useState } from 'react';
import { User, Edit, Upload, Camera } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent } from '@/components/ui/card';
import type { PersonalInfo } from '../types';
interface PersonalInfoHeaderProps {
personalInfo: PersonalInfo | null;
onEdit: () => void;
onAvatarChange: (file: File) => void;
}
export function PersonalInfoHeader({ personalInfo, onEdit, onAvatarChange }: PersonalInfoHeaderProps) {
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const handleAvatarChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
onAvatarChange(file);
}
};
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-6">
<div className="relative">
<Avatar className="h-20 w-20">
<AvatarImage src={avatarPreview || personalInfo?.avatar} alt="头像" />
<AvatarFallback>
<User className="h-10 w-10" />
</AvatarFallback>
</Avatar>
<label className="absolute bottom-0 right-0 cursor-pointer">
<div className="bg-blue-500 text-white rounded-full p-1 hover:bg-blue-600 transition-colors">
<Camera className="h-4 w-4" />
</div>
<input
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
/>
</label>
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
{personalInfo?.realName || '未设置姓名'}
</h1>
<p className="text-gray-600">
@{personalInfo?.username || '未设置用户名'}
</p>
<div className="flex items-center space-x-4 mt-2 text-sm text-gray-500">
<span>{personalInfo?.department || '未设置部门'}</span>
<span></span>
<span>{personalInfo?.position || '未设置职位'}</span>
</div>
</div>
</div>
<Button onClick={onEdit} className="flex items-center space-x-2">
<Edit className="h-4 w-4" />
<span></span>
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{personalInfo?.email || '未设置邮箱'}
</div>
<div className="text-sm text-gray-500"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{personalInfo?.phone || '未设置手机号'}
</div>
<div className="text-sm text-gray-500"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">
{personalInfo?.gender === 'male' ? '男' :
personalInfo?.gender === 'female' ? '女' :
personalInfo?.gender === 'other' ? '其他' : '未设置'}
</div>
<div className="text-sm text-gray-500"></div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,144 @@
'use client';
import { useEffect, useState } from 'react';
import { Users, UserCheck, UserX, RefreshCw } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { PersonalInfoStats as PersonalInfoStatsType } from '../types';
export function PersonalInfoStats() {
const [stats, setStats] = useState<PersonalInfoStatsType>({
totalUsers: 0,
activeUsers: 0,
inactiveUsers: 0,
recentlyUpdated: 0
});
const [loading, setLoading] = useState(true);
useEffect(() => {
// 模拟API调用
const fetchStats = async () => {
setLoading(true);
try {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500));
// 模拟统计数据
const mockStats: PersonalInfoStatsType = {
totalUsers: 1523,
activeUsers: 1245,
inactiveUsers: 278,
recentlyUpdated: 89
};
setStats(mockStats);
} catch (error) {
console.error('获取统计数据失败:', error);
} finally {
setLoading(false);
}
};
fetchStats();
}, []);
const handleRefresh = () => {
// 刷新统计数据
setLoading(true);
setTimeout(() => {
setStats({
totalUsers: Math.floor(Math.random() * 2000) + 1000,
activeUsers: Math.floor(Math.random() * 1500) + 800,
inactiveUsers: Math.floor(Math.random() * 300) + 100,
recentlyUpdated: Math.floor(Math.random() * 100) + 50
});
setLoading(false);
}, 500);
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg"></CardTitle>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={loading}
className="flex items-center space-x-1"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
<span></span>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-4 bg-blue-50 rounded-lg">
<div className="flex items-center justify-center mb-2">
<Users className="h-6 w-6 text-blue-600" />
</div>
<div className="text-2xl font-bold text-blue-600">
{loading ? '--' : stats.totalUsers.toLocaleString()}
</div>
<div className="text-sm text-blue-600"></div>
</div>
<div className="text-center p-4 bg-green-50 rounded-lg">
<div className="flex items-center justify-center mb-2">
<UserCheck className="h-6 w-6 text-green-600" />
</div>
<div className="text-2xl font-bold text-green-600">
{loading ? '--' : stats.activeUsers.toLocaleString()}
</div>
<div className="text-sm text-green-600"></div>
</div>
<div className="text-center p-4 bg-orange-50 rounded-lg">
<div className="flex items-center justify-center mb-2">
<UserX className="h-6 w-6 text-orange-600" />
</div>
<div className="text-2xl font-bold text-orange-600">
{loading ? '--' : stats.inactiveUsers.toLocaleString()}
</div>
<div className="text-sm text-orange-600"></div>
</div>
<div className="text-center p-4 bg-purple-50 rounded-lg">
<div className="flex items-center justify-center mb-2">
<RefreshCw className="h-6 w-6 text-purple-600" />
</div>
<div className="text-2xl font-bold text-purple-600">
{loading ? '--' : stats.recentlyUpdated.toLocaleString()}
</div>
<div className="text-sm text-purple-600"></div>
</div>
</div>
<div className="mt-4 p-3 bg-gray-50 rounded">
<h4 className="font-semibold text-sm mb-2"></h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span></span>
<span className="font-medium">
{loading ? '--' : `${((stats.activeUsers / stats.totalUsers) * 100).toFixed(1)}%`}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full transition-all duration-500"
style={{
width: loading ? '0%' : `${(stats.activeUsers / stats.totalUsers) * 100}%`
}}
/>
</div>
</div>
</div>
<div className="text-xs text-gray-500 text-center mt-4">
: {new Date().toLocaleString('zh-CN')}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,446 @@
'use client';
import { useState, useEffect } from 'react';
import { Card } from '@/components/ui/card';
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { UserProfile, PasswordChange } from '@/types/profile';
import { User, Mail, Phone, Building, Briefcase, Lock, Save, Shield } from 'lucide-react';
import { toast } from 'sonner';
import { useAuth } from '@/components/auth/AuthContext';
export default function PersonalInfo() {
const { user } = useAuth();
const [profile, setProfile] = useState<UserProfile>({
id: user?.id || '',
username: user?.username || '',
name: user?.realName || '',
email: user?.email || '',
phone: user?.phone || '',
avatar: '',
gender: '',
birthday: '',
department: '',
position: '',
enterpriseId: user?.enterpriseId || '',
enterpriseName: user?.enterpriseName || '',
roleIds: [],
roleNames: user?.is_superuser ? ['超级管理员'] : ['普通用户'],
bio: '',
address: '',
createdAt: user?.createdAt || '',
lastLoginTime: '',
lastLoginIp: '',
});
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const [passwordForm, setPasswordForm] = useState<PasswordChange>({
oldPassword: '',
newPassword: '',
confirmPassword: '',
});
const [hasChanges, setHasChanges] = useState(false);
useEffect(() => {
loadProfile();
}, []);
// 当用户信息变化时,更新 profile 状态
useEffect(() => {
if (user) {
setProfile(prev => ({
...prev,
id: user.id || '',
username: user.username || '',
name: user.realName || '',
email: user.email || '',
phone: user.phone || '',
enterpriseId: user.enterpriseId || '',
enterpriseName: user.enterpriseName || '',
roleNames: user.is_superuser ? ['超级管理员'] : ['普通用户'],
createdAt: user.createdAt || '',
}));
}
}, [user]);
const loadProfile = () => {
const data = localStorage.getItem('smart_agriculture_user_profile');
if (data) {
setProfile(JSON.parse(data));
} else {
saveProfile(profile);
}
};
const saveProfile = (newProfile: UserProfile) => {
localStorage.setItem('smart_agriculture_user_profile', JSON.stringify(newProfile));
setProfile(newProfile);
setHasChanges(false);
toast.success('个人信息已保存');
};
const handleSave = () => {
if (!profile.name.trim() || !profile.email.trim()) {
toast.error('请填写必填项');
return;
}
// 简单的邮箱格式验证
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(profile.email)) {
toast.error('邮箱格式不正确');
return;
}
saveProfile(profile);
};
const handlePasswordChange = () => {
if (!passwordForm.oldPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
toast.error('请填写所有密码字段');
return;
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error('两次输入的新密码不一致');
return;
}
if (passwordForm.newPassword.length < 8) {
toast.error('新密码长度不能少于8位');
return;
}
// 模拟密码修改
toast.success('密码修改成功,请重新登录');
setShowPasswordDialog(false);
setPasswordForm({
oldPassword: '',
newPassword: '',
confirmPassword: '',
});
};
const updateProfile = (updates: Partial<UserProfile>) => {
setProfile({ ...profile, ...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">
<Button variant="outline" onClick={() => setShowPasswordDialog(true)}>
<Lock className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges}
className={hasChanges ? 'bg-green-600 hover:bg-green-700' : ''}
>
<Save className="w-4 h-4 mr-2" />
{hasChanges && <span className="ml-1">()</span>}
</Button>
</div>
</div>
<Tabs defaultValue="basic" className="space-y-4">
<TabsList>
<TabsTrigger value="basic">
<User className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="work">
<Briefcase className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="security">
<Shield className="w-4 h-4 mr-2" />
</TabsTrigger>
</TabsList>
{/* 基本信息 */}
<TabsContent value="basic" className="space-y-4">
<Card className="p-6">
<div className="flex items-start gap-6">
<div className="flex flex-col items-center gap-3">
<Avatar className="w-24 h-24">
<AvatarImage src={profile.avatar} />
<AvatarFallback className="text-2xl bg-green-100 text-green-700">
{profile.name?.substring(0, 2) || '用户'}
</AvatarFallback>
</Avatar>
<Button variant="outline" size="sm">
</Button>
</div>
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label></Label>
<Input
value={profile.username}
disabled
className="bg-gray-50"
/>
</div>
<div>
<Label> *</Label>
<Input
value={profile.name}
onChange={(e) => updateProfile({ name: e.target.value })}
placeholder="请输入姓名"
/>
</div>
<div>
<Label> *</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="email"
value={profile.email}
onChange={(e) => updateProfile({ email: e.target.value })}
placeholder="example@email.com"
className="pl-10"
/>
</div>
</div>
<div>
<Label></Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={profile.phone}
onChange={(e) => updateProfile({ phone: e.target.value })}
placeholder="13800138000"
className="pl-10"
/>
</div>
</div>
<div>
<Label></Label>
<Select
value={profile.gender}
onValueChange={(value: any) => updateProfile({ gender: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="male"></SelectItem>
<SelectItem value="female"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Input
type="date"
value={profile.birthday}
onChange={(e) => updateProfile({ birthday: e.target.value })}
/>
</div>
<div className="md:col-span-2">
<Label></Label>
<Textarea
value={profile.bio}
onChange={(e) => updateProfile({ bio: e.target.value })}
placeholder="介绍一下自己"
rows={3}
/>
</div>
<div className="md:col-span-2">
<Label></Label>
<Input
value={profile.address}
onChange={(e) => updateProfile({ address: e.target.value })}
placeholder="请输入地址"
/>
</div>
</div>
</div>
</Card>
</TabsContent>
{/* 工作信息 */}
<TabsContent value="work" className="space-y-4">
<Card className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label></Label>
<div className="relative">
<Building className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={profile.enterpriseName}
disabled
className="pl-10 bg-gray-50"
/>
</div>
</div>
<div>
<Label></Label>
<Input
value={profile.department}
onChange={(e) => updateProfile({ department: e.target.value })}
placeholder="请输入部门"
/>
</div>
<div>
<Label></Label>
<Input
value={profile.position}
onChange={(e) => updateProfile({ position: e.target.value })}
placeholder="请输入职位"
/>
</div>
<div>
<Label></Label>
<div className="flex flex-wrap gap-2 pt-2">
{profile.roleNames.map((role, index) => (
<div key={index} className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm">
{role}
</div>
))}
</div>
</div>
</div>
</Card>
</TabsContent>
{/* 安全信息 */}
<TabsContent value="security" className="space-y-4">
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h4></h4>
<p className="text-sm text-muted-foreground"></p>
</div>
<Button variant="outline" onClick={() => setShowPasswordDialog(true)}>
</Button>
</div>
<div className="p-4 border rounded-lg">
<h4 className="mb-2"></h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span>{new Date(profile.createdAt).toLocaleString('zh-CN')}</span>
</div>
{profile.lastLoginTime && (
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span>{new Date(profile.lastLoginTime).toLocaleString('zh-CN')}</span>
</div>
)}
{profile.lastLoginIp && (
<div className="flex justify-between">
<span className="text-muted-foreground">IP</span>
<span>
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{profile.lastLoginIp}
</code>
</span>
</div>
)}
</div>
</div>
</div>
</Card>
</TabsContent>
</Tabs>
{/* 修改密码对话框 */}
<Dialog open={showPasswordDialog} onOpenChange={setShowPasswordDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<div className="flex items-center gap-2">
<Lock className="w-5 h-5 text-green-600" />
</div>
</DialogTitle>
<DialogDescription className="sr-only">
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
type="password"
value={passwordForm.oldPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, oldPassword: e.target.value })}
placeholder="请输入原密码"
/>
</div>
<div>
<Label> *</Label>
<Input
type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
placeholder="请输入新密码至少8位"
/>
</div>
<div>
<Label> *</Label>
<Input
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
placeholder="请再次输入新密码"
/>
</div>
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
<p></p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>8</li>
<li></li>
<li>使</li>
</ul>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPasswordDialog(false)}>
</Button>
<Button onClick={handlePasswordChange}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 使用说明 */}
<Card className="p-4 bg-blue-50 border-blue-200">
<h4 className="text-blue-900 mb-2">
<User className="w-4 h-4 inline mr-2" />
</h4>
<ul className="space-y-1 text-sm text-blue-800">
<li> "保存修改"</li>
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</Card>
</div>
);
}

View File

@@ -0,0 +1,35 @@
// 个人信息页面类型定义
export interface PersonalInfo {
id: string;
username: string;
realName: string;
email: string;
phone: string;
avatar?: string;
department?: string;
position?: string;
gender?: 'male' | 'female' | 'other';
birthday?: string;
address?: string;
bio?: string;
createdAt: string;
updatedAt: string;
}
export interface PersonalInfoForm {
realName: string;
email: string;
phone: string;
gender?: 'male' | 'female' | 'other';
birthday?: string;
address?: string;
bio?: string;
}
export interface PersonalInfoStats {
totalUsers: number;
activeUsers: number;
inactiveUsers: number;
recentlyUpdated: number;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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;
}
}

View 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;
}

View File

@@ -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>
);
}

View File

@@ -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> valuelabel</li>
<li> </li>
<li> 便</li>
</ul>
</Card>
</div>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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;
}
}

View 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;
}

View 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>
);
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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日">20241014</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</Card>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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'

View 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>
)
}

View File

@@ -0,0 +1,352 @@
'use client';
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { FileText, Building, CreditCard, User } from 'lucide-react';
import { AuditRecord, Enterprise, AuditStatus } from '../types';
interface AuditHistoryDetailDialogProps {
record: AuditRecord | null;
enterprise: Enterprise | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AuditHistoryDetailDialog({
record,
enterprise,
open,
onOpenChange
}: AuditHistoryDetailDialogProps) {
const getResultBadge = (result: AuditStatus) => {
switch (result) {
case 'pending':
return <Badge className="bg-yellow-100 text-yellow-700"></Badge>;
case 'approved':
return <Badge className="bg-green-100 text-green-700"></Badge>;
case 'rejected':
return <Badge className="bg-red-100 text-red-700"></Badge>;
default:
return <Badge>{result}</Badge>;
}
};
const getTypeBadge = (type: string) => {
switch (type) {
case 'register':
return <Badge className="bg-blue-100 text-blue-700"></Badge>;
case 'update':
return <Badge className="bg-purple-100 text-purple-700"></Badge>;
default:
return <Badge>{type}</Badge>;
}
};
if (!record || !enterprise) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="w-5 h-5 text-green-600" />
<span></span>
</div>
<div className="flex items-center gap-2">
{getResultBadge(record.result)}
</div>
</div>
</DialogTitle>
<DialogDescription className="sr-only">
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[calc(90vh-120px)]">
<div className="space-y-6 pr-6">
{/* 企业信息标签页 */}
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="basic">
<Building className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="other">
<FileText className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="bank">
<CreditCard className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="legal">
<User className="w-4 h-4 mr-2" />
</TabsTrigger>
</TabsList>
{/* 基本信息 */}
<TabsContent value="basic" className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label></Label>
<div className="field-value">{enterprise.name}</div>
</div>
<div>
<Label></Label>
<div className="field-value">{enterprise.type}</div>
</div>
<div>
<Label></Label>
<div className="field-value">
{enterprise.province} {enterprise.city} {enterprise.district}
</div>
</div>
<div>
<Label></Label>
<div className="field-value">{enterprise.address}</div>
</div>
<div>
<Label></Label>
<div className="field-value">{enterprise.registrant}</div>
</div>
<div>
<Label></Label>
<div className="field-value">{enterprise.contactPhone}</div>
</div>
</div>
</TabsContent>
{/* 其他信息 */}
<TabsContent value="other" className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label></Label>
<div className="field-value">{enterprise.companySize || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value">{enterprise.registeredCapital || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value">{enterprise.establishmentDate || '-'}</div>
</div>
<div>
<Label></Label>
<div className="field-value">{enterprise.invoiceType || '-'}</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="field-value">
<code className="text-sm font-mono">
{enterprise.socialCreditCode}
</code>
</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="field-value">{enterprise.businessScope || '-'}</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="mt-2">
{enterprise.businessLicense ? (
<img
src={enterprise.businessLicense}
alt="营业执照"
className="w-64 h-auto border rounded-lg"
/>
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</div>
</div>
</TabsContent>
{/* 开户信息 */}
<TabsContent value="bank" className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label></Label>
<div className="field-value">
{enterprise.bankAccount ? (
<code className="text-sm font-mono">
{enterprise.bankAccount}
</code>
) : '-'}
</div>
</div>
<div>
<Label></Label>
<div className="field-value">{enterprise.bankName || '-'}</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="field-value">{enterprise.bankFullName || '-'}</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="field-value">{enterprise.bankAddress || '-'}</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="mt-2">
{enterprise.bankLicense ? (
<img
src={enterprise.bankLicense}
alt="开户许可证"
className="w-64 h-auto border rounded-lg"
/>
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</div>
</div>
</TabsContent>
{/* 法人信息 */}
<TabsContent value="legal" className="space-y-4">
<div className="grid grid-cols-1 gap-6">
<div>
<Label></Label>
<div className="field-value">{enterprise.legalPerson || '-'}</div>
</div>
<div>
<Label></Label>
<div className="mt-2">
{enterprise.idCardFront ? (
<img
src={enterprise.idCardFront}
alt="身份证正面"
className="w-80 h-auto border rounded-lg"
/>
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</div>
<div>
<Label></Label>
<div className="mt-2">
{enterprise.idCardBack ? (
<img
src={enterprise.idCardBack}
alt="身份证反面"
className="w-80 h-auto border rounded-lg"
/>
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</div>
</div>
</TabsContent>
</Tabs>
{/* 审核信息 */}
<div className="mt-6 pt-6 border-t">
<h4 className="mb-4 font-bold"></h4>
<Card className="p-6 bg-gray-50 border">
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
<div>
<Label className="text-xs"></Label>
<div className="mt-1.5 text-base">
{getTypeBadge(record.auditType)}
</div>
</div>
<div>
<Label className="text-xs"></Label>
<div className="mt-1.5 text-base">
{new Date(record.submitTime).toLocaleString('zh-CN')}
</div>
</div>
{record.auditTime && (
<div>
<Label className="text-xs"></Label>
<div className="mt-1.5 text-base">
{new Date(record.auditTime).toLocaleString('zh-CN')}
</div>
</div>
)}
{record.auditor && (
<div>
<Label className="text-xs"></Label>
<div className="mt-1.5 text-base">
{record.auditor}
</div>
</div>
)}
{record.reason && (
<div className="col-span-2 pt-4 mt-2 border-t">
<Label className="text-xs"></Label>
<div className="mt-1.5 text-base text-red-800">
{record.reason}
</div>
</div>
)}
{record.remarks && (
<div className="col-span-2 pt-4 mt-2 border-t">
<Label className="text-xs"></Label>
<div className="mt-1.5 text-base">
{record.remarks}
</div>
</div>
)}
</div>
</Card>
</div>
{/* 审核流程时间线 */}
<div className="pt-6 border-t">
<h4 className="mb-4 font-bold"></h4>
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="w-2 h-2 rounded-full bg-blue-600 mt-2" />
<div>
<div className="text-sm"></div>
<div className="text-xs text-muted-foreground">
{new Date(record.submitTime).toLocaleString('zh-CN')}
</div>
</div>
</div>
{record.auditTime && (
<div className="flex items-start gap-3">
<div className={`w-2 h-2 rounded-full mt-2 ${
record.result === 'approved' ? 'bg-green-600' : 'bg-red-600'
}`} />
<div>
<div className="text-sm">
{record.result === 'approved' ? '审核通过' : '审核驳回'}
</div>
<div className="text-xs text-muted-foreground">
{new Date(record.auditTime).toLocaleString('zh-CN')}
{record.auditor && ` · ${record.auditor}`}
</div>
</div>
</div>
)}
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import React from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search } from 'lucide-react';
import { FilterOptions } from '../types';
interface AuditHistoryFiltersProps {
filters: FilterOptions;
onFiltersChange: (filters: FilterOptions) => void;
}
export function AuditHistoryFilters({ filters, onFiltersChange }: AuditHistoryFiltersProps) {
const updateFilter = (key: keyof FilterOptions, value: string) => {
onFiltersChange({
...filters,
[key]: value
});
};
return (
<Card className="p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-2">
<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={filters.searchKeyword}
onChange={(e) => updateFilter('searchKeyword', e.target.value)}
className="pl-10"
/>
</div>
</div>
<Select value={filters.typeFilter} onValueChange={(value) => updateFilter('typeFilter', value)}>
<SelectTrigger>
<SelectValue placeholder="审核类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="register"></SelectItem>
<SelectItem value="update"></SelectItem>
</SelectContent>
</Select>
<Select value={filters.resultFilter} onValueChange={(value) => updateFilter('resultFilter', value)}>
<SelectTrigger>
<SelectValue placeholder="审核结果" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="pending"></SelectItem>
<SelectItem value="approved"></SelectItem>
<SelectItem value="rejected"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="mt-4">
<Label className="text-sm text-muted-foreground mb-2 block"></Label>
<div className="flex gap-2">
{[
{ value: 'all', label: '全部' },
{ value: 'today', label: '今天' },
{ value: 'week', label: '近7天' },
{ value: 'month', label: '近30天' },
{ value: 'quarter', label: '近90天' },
].map((option) => (
<Button
key={option.value}
variant={filters.dateRange === option.value ? 'default' : 'outline'}
size="sm"
onClick={() => updateFilter('dateRange', option.value)}
>
{option.label}
</Button>
))}
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,23 @@
'use client';
import React from 'react';
import { Card } from '@/components/ui/card';
import { Calendar } from 'lucide-react';
export function AuditHistoryInstructions() {
return (
<Card className="p-4 bg-blue-50 border-blue-200">
<h4 className="text-blue-900 mb-2">
<Calendar className="w-4 h-4 inline mr-2" />
</h4>
<ul className="space-y-1 text-sm text-blue-800">
<li> </li>
<li> </li>
<li> </li>
<li> 线</li>
<li> </li>
</ul>
</Card>
);
}

View File

@@ -0,0 +1,93 @@
'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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Eye } from 'lucide-react';
import { AuditRecord, AuditStatus } from '../types';
interface AuditHistoryListProps {
records: AuditRecord[];
onViewDetail: (record: AuditRecord) => void;
}
export function AuditHistoryList({ records, onViewDetail }: AuditHistoryListProps) {
const getResultBadge = (result: AuditStatus) => {
switch (result) {
case 'pending':
return <Badge className="bg-yellow-100 text-yellow-700"></Badge>;
case 'approved':
return <Badge className="bg-green-100 text-green-700"></Badge>;
case 'rejected':
return <Badge className="bg-red-100 text-red-700"></Badge>;
default:
return <Badge>{result}</Badge>;
}
};
const getTypeBadge = (type: string) => {
switch (type) {
case 'register':
return <Badge className="bg-blue-100 text-blue-700"></Badge>;
case 'update':
return <Badge className="bg-purple-100 text-purple-700"></Badge>;
default:
return <Badge>{type}</Badge>;
}
};
return (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
</TableCell>
</TableRow>
) : (
records.map((record) => (
<TableRow key={record.id}>
<TableCell className="font-medium">{record.enterpriseName}</TableCell>
<TableCell>{getTypeBadge(record.auditType)}</TableCell>
<TableCell className="text-muted-foreground">
{new Date(record.submitTime).toLocaleString('zh-CN')}
</TableCell>
<TableCell className="text-muted-foreground">
{record.auditTime
? new Date(record.auditTime).toLocaleString('zh-CN')
: '-'}
</TableCell>
<TableCell className="text-muted-foreground">{record.auditor || '-'}</TableCell>
<TableCell>{getResultBadge(record.result)}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => onViewDetail(record)}
>
<Eye className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
);
}

View File

@@ -0,0 +1,49 @@
'use client';
import React from 'react';
import { Card } from '@/components/ui/card';
import { AuditHistoryStats, AuditRecord } from '../types';
interface AuditHistoryStatsCardsProps {
records: AuditRecord[];
}
export function AuditHistoryStatsCards({ records }: AuditHistoryStatsCardsProps) {
const stats: AuditHistoryStats[] = [
{
label: '总审核数',
value: records.length,
color: 'text-blue-600',
bg: 'bg-blue-100',
},
{
label: '已通过',
value: records.filter(r => r.result === 'approved').length,
color: 'text-green-600',
bg: 'bg-green-100',
},
{
label: '已驳回',
value: records.filter(r => r.result === 'rejected').length,
color: 'text-red-600',
bg: 'bg-red-100',
},
{
label: '待审核',
value: records.filter(r => r.result === 'pending').length,
color: 'text-yellow-600',
bg: 'bg-yellow-100',
},
];
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{stats.map((stat, index) => (
<Card key={index} className="p-4">
<div className="text-sm text-muted-foreground">{stat.label}</div>
<div className={`mt-2 ${stat.color} text-2xl font-semibold`}>{stat.value}</div>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,243 @@
/**
* filekorolheader: 审核历史API接口 - 企业审核记录查询接口服务
* 功能API请求封装、数据转换、错误处理、分页查询
* 路径:/central-config/tenant/audit-history/components/auditHistoryApi
* 规范遵循crop-x/docs/开发项目规范.md使用SDK API调用TypeScript类型安全
*/
// API响应数据类型定义
import { getAuthToken } from "@/utils/token.ts";
import {
getTenantAuditLogsApiV1TenantsAuditLogsGet,
} from "@/lib/api/sdk.gen";
export interface AuditLogData {
id: string;
action: string;
action_by: string;
action_time: string;
snapshot_company_name: string;
snapshot_company_type: string | null;
snapshot_province: string | null;
snapshot_city: string | null;
snapshot_district: string | null;
snapshot_detailed_address: string | null;
snapshot_registrant: string | null;
snapshot_contact_phone: string | null;
snapshot_bank_account: string | null;
snapshot_bank_name: string | null;
snapshot_bank_full_name: string | null;
snapshot_bank_address: string | null;
snapshot_social_credit_code: string | null;
snapshot_legal_person_name: string | null;
snapshot_audit_status: string;
snapshot_audit_comment: string | null;
snapshot_company_scale: string | null;
snapshot_registered_capital: string | null;
change_summary: string;
ip_address: string | null;
user_agent: string | null;
request_id: string | null;
created_at: string;
}
// API响应接口
export interface AuditLogsApiResponse {
data: AuditLogData[];
total: number;
page: number;
size: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
}
// 查询参数接口
export interface AuditLogsQueryParams {
tenant_id?: string;
page?: number;
size?: number;
order_by?: string;
sort_order?: 'asc' | 'desc';
}
// 审核记录页面数据类型(转换后的)
export interface AuditRecord {
id: string;
enterpriseId?: string;
enterpriseName: string;
action: 'SUBMIT' | 'AUDIT';
auditType: 'register' | 'update';
submitTime: string;
actionTime: string;
actionBy: string;
result: 'pending' | 'approved' | 'rejected' | 'draft';
auditStatus: string;
auditComment?: string;
changeSummary: string;
ipAddress?: string;
userAgent?: string;
requestId?: string;
createdAt: string;
// 快照数据
snapshot: {
companyName: string;
companyType: string | null;
province: string | null;
city: string | null;
district: string | null;
detailedAddress: string | null;
registrant: string | null;
contactPhone: string | null;
bankAccount: string | null;
bankName: string | null;
bankFullName: string | null;
bankAddress: string | null;
socialCreditCode: string | null;
legalPersonName: string | null;
auditStatus: string;
auditComment: string | null;
companyScale: string | null;
registeredCapital: string | null;
};
}
/**
* 获取审核历史记录数据
*/
export async function fetchAuditLogs(params: AuditLogsQueryParams = {}): Promise<AuditLogsApiResponse> {
try {
// 调用计数器
console.log(`[API] fetchAuditLogs 调用次数: ${++fetchAuditLogs.callCount || (fetchAuditLogs.callCount = 1)}`, params);
// 构建查询参数对象
const queryParams: any = {};
queryParams.tenant_id = "";
if (params.page) queryParams.page = params.page;
if (params.size) queryParams.size = params.size;
if (params.order_by) queryParams.order_by = params.order_by;
if (params.sort_order) queryParams.sort_order = params.sort_order;
// 默认参数
if (!params.page) queryParams.page = 1;
if (!params.size) queryParams.size = 10;
// 使用SDK API调用审核历史查询接口添加缓存破坏器和认证头部
const token = getAuthToken();
const response = await getTenantAuditLogsApiV1TenantsAuditLogsGet({
query: {
...queryParams,
// 添加时间戳防止缓存
_t: Date.now(),
},
headers: token ? {
'Authorization': `Bearer ${token}`,
} : undefined,
});
if (response.error) {
throw new Error(`API error: ${response.error.message || 'Unknown error'}`);
}
const data = response.data as any;
// 转换响应数据格式以匹配现有的接口
return {
data: data?.data || [],
total: data?.total || 0,
page: data?.page || 1,
size: data?.size || 10,
total_pages: data?.total_pages || 0,
has_next: data?.has_next || false,
has_prev: data?.has_prev || false,
};
} catch (error) {
console.error('Failed to fetch audit logs:', error);
throw error;
}
}
/**
* 将API数据转换为页面所需的审核记录格式
*/
export function transformAuditLogData(log: AuditLogData): AuditRecord {
// 判断审核类型
let auditType: 'register' | 'update' = 'register';
if (log.change_summary.includes('更新') || log.change_summary.includes('修改')) {
auditType = 'update';
}
// 映射审核状态
let result: 'pending' | 'approved' | 'rejected' | 'draft' = 'pending';
const status = log.snapshot_audit_status.toLowerCase();
if (status.includes('草稿') || status.includes('draft')) {
result = 'draft';
} else if (status.includes('待审核') || status.includes('pending')) {
result = 'pending';
} else if (status.includes('已通过') || status.includes('approved')) {
result = 'approved';
} else if (status.includes('已拒绝') || status.includes('rejected')) {
result = 'rejected';
}
return {
id: log.id,
enterpriseId: log.action_by, // 使用操作人作为企业ID关联
enterpriseName: log.snapshot_company_name,
action: log.action as 'SUBMIT' | 'AUDIT',
auditType,
submitTime: formatDate(log.action_time),
actionTime: formatDate(log.action_time),
actionBy: log.action_by,
result,
auditStatus: log.snapshot_audit_status,
auditComment: log.snapshot_audit_comment,
changeSummary: log.change_summary,
ipAddress: log.ip_address,
userAgent: log.user_agent,
requestId: log.request_id,
createdAt: formatDate(log.created_at),
// 快照数据
snapshot: {
companyName: log.snapshot_company_name,
companyType: log.snapshot_company_type,
province: log.snapshot_province,
city: log.snapshot_city,
district: log.snapshot_district,
detailedAddress: log.snapshot_detailed_address,
registrant: log.snapshot_registrant,
contactPhone: log.snapshot_contact_phone,
bankAccount: log.snapshot_bank_account,
bankName: log.snapshot_bank_name,
bankFullName: log.snapshot_bank_full_name,
bankAddress: log.snapshot_bank_address,
socialCreditCode: log.snapshot_social_credit_code,
legalPersonName: log.snapshot_legal_person_name,
auditStatus: log.snapshot_audit_status,
auditComment: log.snapshot_audit_comment,
companyScale: log.snapshot_company_scale,
registeredCapital: log.snapshot_registered_capital,
},
};
}
/**
* 格式化日期
*/
function formatDate(dateString: string): string {
try {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).replace(/\//g, '-');
} catch (error) {
return dateString;
}
}

View File

@@ -0,0 +1,125 @@
/**
* filekorolheader: 审核历史状态管理 - 审核记录数据状态管理核心
* 功能API数据管理、分页状态、加载状态、错误处理、筛选状态
* 路径:/central-config/tenant/audit-history/components/auditHistoryReducer
* 规范遵循crop-x/docs/开发项目规范.md使用useReducer状态管理模式
*/
import { AuditRecord } from '../types';
export interface FilterOptions {
searchKeyword: string;
resultFilter: string;
typeFilter: string;
dateRange: string;
}
export interface AuditHistoryState {
records: AuditRecord[];
loading: boolean;
error: string | null;
pagination: {
page: number;
size: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
filters: FilterOptions;
showDetailDialog: boolean;
selectedRecord: AuditRecord | null;
sortBy?: string;
sortOrder: 'asc' | 'desc';
}
export type AuditHistoryAction =
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string | null }
| { type: 'SET_RECORDS'; payload: { data: AuditRecord[]; pagination: AuditHistoryState['pagination'] } }
| { type: 'SET_FILTERS'; payload: Partial<FilterOptions> }
| { type: 'SET_PAGINATION'; payload: Partial<AuditHistoryState['pagination']> }
| { type: 'SET_SORT'; payload: { sortBy?: string; sortOrder: 'asc' | 'desc' } }
| { type: 'TOGGLE_DETAIL_DIALOG'; payload: boolean }
| { type: 'SET_SELECTED_RECORD'; payload: AuditRecord | null }
| { type: 'REFRESH_DATA' };
// 初始状态
export const initialState: AuditHistoryState = {
records: [],
loading: false,
error: null,
pagination: {
page: 1,
size: 10,
total: 0,
totalPages: 0,
hasNext: false,
hasPrev: false,
},
filters: {
searchKeyword: '',
resultFilter: 'all',
typeFilter: 'all',
dateRange: 'all',
},
showDetailDialog: false,
selectedRecord: null,
sortBy: undefined,
sortOrder: 'desc',
};
// Reducer
export function auditHistoryReducer(state: AuditHistoryState, action: AuditHistoryAction): AuditHistoryState {
switch (action.type) {
case 'SET_LOADING':
return { ...state, loading: action.payload };
case 'SET_ERROR':
return { ...state, error: action.payload, loading: false };
case 'SET_RECORDS':
return {
...state,
records: action.payload.data,
pagination: action.payload.pagination,
loading: false,
error: null,
};
case 'SET_FILTERS':
return {
...state,
filters: { ...state.filters, ...action.payload },
pagination: { ...state.pagination, page: 1 }, // 重置到第一页
};
case 'SET_PAGINATION':
return {
...state,
pagination: { ...state.pagination, ...action.payload },
};
case 'SET_SORT':
return {
...state,
sortBy: action.payload.sortBy,
sortOrder: action.payload.sortOrder,
};
case 'TOGGLE_DETAIL_DIALOG':
return { ...state, showDetailDialog: action.payload };
case 'SET_SELECTED_RECORD':
return { ...state, selectedRecord: action.payload };
case 'REFRESH_DATA':
return {
...state,
error: null, // 清除错误状态
};
default:
return state;
}
}

View File

@@ -0,0 +1,666 @@
/**
* filekorolheader: 审核历史页面 - 企业审核记录查询和管理页面
* 功能:审核历史记录查询、搜索筛选、详情查看、数据分析
* 路径:/central-config/tenant/audit-history
* 规范遵循crop-x/docs/开发项目规范.md使用useReducer状态管理API集成shadcn语义化样式
*/
'use client';
import { useReducer, useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { toast } from 'sonner';
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 { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Search,
Calendar,
FileText,
AlertCircle,
RefreshCw,
ChevronLeft,
ChevronRight,
Building,
User,
CreditCard,
Smartphone
} from 'lucide-react';
import { fetchAuditLogs, transformAuditLogData, AuditLogsQueryParams, AuditLogData } from './components/auditHistoryApi';
// 审核历史状态管理
interface AuditHistoryState {
records: AuditLogData[];
loading: boolean;
error: string | null;
pagination: {
page: number;
size: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
filters: {
search_keyword: string;
typeFilter: string;
resultFilter: string;
dateRange: string;
};
sortBy?: string;
sortOrder: 'asc' | 'desc';
selectedRecord: AuditLogData | null;
showDetailDialog: boolean;
}
type AuditHistoryAction =
| { type: 'SET_RECORDS'; payload: { data: AuditLogData[]; pagination: AuditHistoryState['pagination'] } }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string | null }
| { type: 'SET_FILTERS'; payload: Partial<AuditHistoryState['filters']> }
| { type: 'SET_SORT'; payload: { sortBy?: string; sortOrder: 'asc' | 'desc' } }
| { type: 'SET_PAGINATION'; payload: Partial<AuditHistoryState['pagination']> }
| { type: 'SET_SELECTED_RECORD'; payload: AuditLogData | null }
| { type: 'TOGGLE_DETAIL_DIALOG'; payload: boolean }
| { type: 'REFRESH_DATA' };
const auditHistoryReducer = (state: AuditHistoryState, action: AuditHistoryAction): AuditHistoryState => {
switch (action.type) {
case 'SET_RECORDS':
return {
...state,
records: action.payload.data,
pagination: action.payload.pagination,
loading: false,
error: null,
};
case 'SET_LOADING':
return { ...state, loading: action.payload };
case 'SET_ERROR':
return { ...state, error: action.payload, loading: false };
case 'SET_FILTERS':
return { ...state, filters: { ...state.filters, ...action.payload } };
case 'SET_SORT':
return { ...state, sortBy: action.payload.sortBy, sortOrder: action.payload.sortOrder };
case 'SET_PAGINATION':
return { ...state, pagination: { ...state.pagination, ...action.payload } };
case 'SET_SELECTED_RECORD':
return { ...state, selectedRecord: action.payload };
case 'TOGGLE_DETAIL_DIALOG':
return { ...state, showDetailDialog: !state.showDetailDialog };
case 'REFRESH_DATA':
return { ...state, error: null };
default:
return state;
}
};
const initialState: AuditHistoryState = {
records: [],
loading: false,
error: null,
pagination: {
page: 1,
size: 10,
total: 0,
totalPages: 0,
hasNext: false,
hasPrev: false,
},
filters: {
search_keyword: '',
typeFilter: 'all',
resultFilter: 'all',
dateRange: 'all',
},
sortBy: 'action_time',
sortOrder: 'desc',
selectedRecord: null,
showDetailDialog: false,
};
export default function AuditHistoryPage() {
const [state, dispatch] = useReducer(auditHistoryReducer, initialState);
const isFirstLoad = useRef(true);
// 加载审核历史数据
const loadAuditHistory = useCallback(async () => {
try {
dispatch({ type: 'SET_LOADING', payload: true });
const params: AuditLogsQueryParams = {
page: state.pagination.page,
size: state.pagination.size
};
const response = await fetchAuditLogs(params);
const transformedRecords = response.data.map(transformAuditLogData);
dispatch({
type: 'SET_RECORDS',
payload: {
data: transformedRecords,
pagination: {
page: response.page,
size: response.size,
total: response.total,
totalPages: response.total_pages,
hasNext: response.has_next,
hasPrev: response.has_prev,
}
}
});
} catch (error) {
console.error('Failed to load audit history:', error);
dispatch({
type: 'SET_ERROR',
payload: error instanceof Error ? error.message : '加载审核历史失败'
});
}
}, []); // 空依赖数组,函数内部使用最新状态
// 搜索处理
const handleSearch = (value: string) => {
dispatch({ type: 'SET_FILTERS', payload: { search_keyword: value } });
};
// 类型筛选
const handleTypeFilter = (value: string) => {
dispatch({ type: 'SET_FILTERS', payload: { typeFilter: value } });
};
// 结果筛选
const handleResultFilter = (value: string) => {
dispatch({ type: 'SET_FILTERS', payload: { resultFilter: value } });
};
// 时间筛选
const handleDateFilter = (value: string) => {
dispatch({ type: 'SET_FILTERS', payload: { dateRange: value } });
};
// 排序处理
const handleSort = (sortBy: string) => {
const newSortOrder = state.sortBy === sortBy && state.sortOrder === 'desc' ? 'asc' : 'desc';
dispatch({ type: 'SET_SORT', payload: { sortBy, sortOrder: newSortOrder } });
};
// 分页处理
const handlePageChange = (page: number) => {
// 边界检查,确保页码在有效范围内
if (page < 1) {
page = 1;
} else if (page > state.pagination.totalPages && state.pagination.totalPages > 0) {
page = state.pagination.totalPages;
}
dispatch({ type: 'SET_PAGINATION', payload: { page } });
};
// 查看详情
const handleViewDetail = (record: AuditLogData) => {
dispatch({ type: 'SET_SELECTED_RECORD', payload: record });
dispatch({ type: 'TOGGLE_DETAIL_DIALOG', payload: true });
};
// 合并所有状态变化,统一处理数据加载
useEffect(() => {
if (isFirstLoad.current) {
// 首次加载
isFirstLoad.current = false;
loadAuditHistory();
} else {
// 后续状态变化,使用防抖
const timer = setTimeout(() => {
loadAuditHistory();
}, 300);
return () => clearTimeout(timer);
}
}, [
state.filters.search_keyword,
state.filters.typeFilter,
state.filters.resultFilter,
state.filters.dateRange,
state.sortBy,
state.sortOrder,
state.pagination.page,
state.pagination.size
]);
// 工具函数
const getActionBadge = (action: string) => {
switch (action) {
case 'SUBMIT':
return <Badge className="bg-blue-100 text-blue-700"></Badge>;
case 'AUDIT':
return <Badge className="bg-orange-100 text-orange-700"></Badge>;
default:
return <Badge variant="outline">{action}</Badge>;
}
};
const getResultBadge = (result: string) => {
switch (result) {
case 'approved':
return <Badge className="bg-green-100 text-green-700"></Badge>;
case 'rejected':
return <Badge className="bg-red-100 text-red-700"></Badge>;
case 'pending':
return <Badge className="bg-yellow-100 text-yellow-700"></Badge>;
case 'draft':
return <Badge className="bg-gray-100 text-gray-700">稿</Badge>;
default:
return <Badge variant="outline">{result}</Badge>;
}
};
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800 dark:text-green-400"></h2>
<p className="text-muted-foreground"></p>
</div>
</div>
{/* Filters */}
<Card className="p-6 bg-card">
<div className="flex flex-col lg:flex-row gap-4 mb-4">
<div className="flex-1">
<Label className="text-sm"></Label>
<div className="relative mt-2">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索企业名称、变更摘要..."
value={state.filters.search_keyword}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="flex gap-4">
<div>
<Label className="text-sm"></Label>
<Select value={state.filters.typeFilter} onValueChange={handleTypeFilter}>
<SelectTrigger className="w-32 mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="SUBMIT"></SelectItem>
<SelectItem value="AUDIT"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm"></Label>
<Select value={state.filters.resultFilter} onValueChange={handleResultFilter}>
<SelectTrigger className="w-32 mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="approved"></SelectItem>
<SelectItem value="rejected"></SelectItem>
<SelectItem value="pending"></SelectItem>
<SelectItem value="draft">稿</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</Card>
{/* Time Range Filter */}
<Card className="p-4 bg-card">
<Label className="text-sm text-muted-foreground mb-2 block"></Label>
<div className="flex gap-2">
{[
{ value: 'all', label: '全部' },
{ value: 'today', label: '今天' },
{ value: 'week', label: '近7天' },
{ value: 'month', label: '近30天' },
{ value: 'quarter', label: '近90天' },
].map((option) => (
<Button
key={option.value}
variant={state.filters.dateRange === option.value ? 'default' : 'outline'}
size="sm"
onClick={() => handleDateFilter(option.value)}
>
{option.label}
</Button>
))}
</div>
</Card>
{/* Error Display */}
{state.error && (
<div className="mb-4 p-4 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
<AlertCircle className="w-4 h-4" />
<span>{state.error}</span>
</div>
</div>
)}
{/* Loading State */}
{state.loading && (
<div className="text-center py-12 text-muted-foreground">
<RefreshCw className="w-8 h-8 mx-auto mb-2 animate-spin" />
<p>...</p>
</div>
)}
{/* Data Table */}
{!state.loading && !state.error && (
<Card>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:bg-muted"
onClick={() => handleSort('enterprise_name')}
>
{state.sortBy === 'enterprise_name' && (
<span className="ml-1">{state.sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted"
onClick={() => handleSort('action')}
>
{state.sortBy === 'action' && (
<span className="ml-1">{state.sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{state.records.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
</TableCell>
</TableRow>
) : (
state.records.map((record) => (
<TableRow key={record.id}>
<TableCell>
<div className="flex items-center gap-2">
<Building className="w-4 h-4 text-blue-500" />
<span className="font-medium">{record.enterpriseName}</span>
</div>
</TableCell>
<TableCell>{getActionBadge(record.action)}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{record.action === 'SUBMIT' ? record.submitTime : '-'}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{record.action === 'AUDIT' ? record.actionTime : '-'}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-gray-500" />
<span>{record.actionBy || '-'}</span>
</div>
</TableCell>
<TableCell>{getResultBadge(record.auditStatus)}</TableCell>
<TableCell>
<Button
size="sm"
variant="outline"
onClick={() => handleViewDetail(record)}
>
<FileText className="w-3 h-3 mr-1" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{state.pagination.totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
{state.pagination.page} {state.pagination.totalPages}
<span className="ml-2"> {state.pagination.total} </span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(state.pagination.page - 1)}
disabled={!state.pagination.hasPrev || state.loading}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-medium px-2">
{state.pagination.page} / {state.pagination.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(state.pagination.page + 1)}
disabled={!state.pagination.hasNext || state.loading}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</Card>
)}
{/* Detail Dialog */}
<Dialog open={state.showDetailDialog} onOpenChange={(open) => dispatch({ type: 'TOGGLE_DETAIL_DIALOG', payload: open })}>
<DialogContent className="w-[90vw] max-w-6xl max-h-[90vh]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
{state.selectedRecord && (
<ScrollArea className="max-h-[calc(90vh-200px)]">
<Tabs defaultValue="basic" className="space-y-4">
<TabsList className="grid grid-cols-3 w-full">
<TabsTrigger value="basic">
<FileText className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="snapshot">
<Building className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="system">
<Smartphone className="w-4 h-4 mr-2" />
</TabsTrigger>
</TabsList>
{/* 基本信息 */}
<TabsContent value="basic" className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{state.selectedRecord.enterpriseName}</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{getActionBadge(state.selectedRecord.action)}</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">
{state.selectedRecord.action === 'SUBMIT' ? state.selectedRecord.submitTime : '-'}
</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">
{state.selectedRecord.action === 'AUDIT' ? state.selectedRecord.actionTime : '-'}
</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{state.selectedRecord.actionBy || '-'}</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{getResultBadge(state.selectedRecord.result)}</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md min-h-[80px] whitespace-pre-wrap">
{state.selectedRecord.changeSummary || '-'}
</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md min-h-[80px] whitespace-pre-wrap">
{state.selectedRecord.auditComment || '-'}
</div>
</div>
</div>
</TabsContent>
{/* 企业快照 */}
<TabsContent value="snapshot" className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{state.selectedRecord.snapshot.companyType || '-'}</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">
{state.selectedRecord.snapshot.province} {state.selectedRecord.snapshot.city}
</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{state.selectedRecord.snapshot.detailedAddress || '-'}</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{state.selectedRecord.snapshot.registrant || '-'}</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{state.selectedRecord.snapshot.contactPhone || '-'}</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{state.selectedRecord.snapshot.companyScale || '-'}</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{state.selectedRecord.snapshot.registeredCapital || '-'}</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">
<code className="text-sm">{state.selectedRecord.snapshot.socialCreditCode || '-'}</code>
</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{state.selectedRecord.snapshot.legalPersonName || '-'}</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">
<code className="text-sm">{state.selectedRecord.snapshot.bankAccount || '-'}</code>
</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{state.selectedRecord.snapshot.bankName || '-'}</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{state.selectedRecord.snapshot.bankFullName || '-'}</div>
</div>
<div className="col-span-2">
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{state.selectedRecord.snapshot.bankAddress || '-'}</div>
</div>
</div>
</TabsContent>
{/* 系统信息 */}
<TabsContent value="system" className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label>ID</Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">
<code className="text-sm">{state.selectedRecord.id}</code>
</div>
</div>
<div>
<Label>ID</Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">
<code className="text-sm">{state.selectedRecord.enterpriseId || '-'}</code>
</div>
</div>
<div>
<Label>IP地址</Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{state.selectedRecord.ipAddress || '-'}</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md text-sm">
{state.selectedRecord.userAgent || '-'}
</div>
</div>
<div>
<Label>ID</Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">
<code className="text-sm">{state.selectedRecord.requestId || '-'}</code>
</div>
</div>
<div>
<Label></Label>
<div className="mt-1.5 p-3 bg-gray-50 rounded-md">{state.selectedRecord.createdAt}</div>
</div>
</div>
</TabsContent>
</Tabs>
</ScrollArea>
)}
<DialogFooter>
<Button variant="outline" onClick={() => dispatch({ type: 'TOGGLE_DETAIL_DIALOG', payload: false })}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,525 @@
/**
* filekorolheader: 审核历史 - 企业审核记录查询与详情查看页面
* 功能:审核历史查询、筛选过滤、详情查看、数据导出、分页控制
* 路径:/central-config/tenant/audit-history
* 规范遵循crop-x/docs/开发项目规范.md使用useReducer状态管理API集成shadcn语义化样式
*/
'use client';
import { useReducer, useEffect, useMemo } 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 { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
History,
Download,
Search,
Eye,
AlertCircle,
ChevronLeft,
ChevronRight,
Calendar,
Clock,
User,
FileText,
Building2,
MapPin,
CreditCard,
Phone
} from 'lucide-react';
import { toast } from 'sonner';
import { auditHistoryReducer, initialState, AuditHistoryState, AuditHistoryAction } from './components/auditHistoryReducer';
import { fetchAuditLogs, transformAuditLogData, AuditLogsQueryParams } from './components/auditHistoryApi';
import { AuditRecord, FilterOptions, AuditStatus } from './types';
// Utility functions
const getStatusBadge = (status: AuditStatus) => {
switch (status) {
case 'draft':
return <Badge className="bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-800 font-light">草稿</Badge>;
case 'pending':
return <Badge className="bg-yellow-50 dark:bg-yellow-950 text-yellow-600 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800 font-light">待审核</Badge>;
case 'approved':
return <Badge className="bg-green-50 dark:bg-green-950 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800 font-light">审核通过</Badge>;
case 'rejected':
return <Badge className="bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 font-light">审核拒绝</Badge>;
default:
return <Badge className="bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-800 font-light">未知</Badge>;
}
};
const getActionBadge = (action: string) => {
if (action === 'SUBMIT') {
return <Badge className="bg-blue-50 dark:bg-blue-950 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800 font-light">提交</Badge>;
} else if (action === 'AUDIT') {
return <Badge className="bg-purple-50 dark:bg-purple-950 text-purple-600 dark:text-purple-400 border-purple-200 dark:border-purple-800 font-light">审核</Badge>;
}
return <Badge variant="outline" className="font-light">{action}</Badge>;
};
export default function AuditHistoryPage() {
const [state, dispatch] = useReducer(auditHistoryReducer, initialState);
// 加载审核历史数据
const loadAuditHistory = async (resetPage = false) => {
try {
dispatch({ type: 'SET_LOADING', payload: true });
const params: AuditLogsQueryParams = {
page: resetPage ? 1 : state.pagination.page,
size: state.pagination.size,
order_by: state.sortBy,
sort_order: state.sortOrder,
};
const response = await fetchAuditLogs(params);
const transformedData = response.data.map(transformAuditLogData);
console.log('Audit Logs API Response:', response);
console.log('Transformed Audit Data:', transformedData);
dispatch({
type: 'SET_RECORDS',
payload: {
data: transformedData,
pagination: {
page: response.page,
size: response.size,
total: response.total,
totalPages: response.total_pages,
hasNext: response.has_next,
hasPrev: response.has_prev,
}
}
});
} catch (error) {
console.error('Failed to load audit history:', error);
const errorMessage = error instanceof Error ? error.message : '加载审核历史失败';
dispatch({ type: 'SET_ERROR', payload: errorMessage });
toast.error(errorMessage);
}
};
// 初始加载
useEffect(() => {
loadAuditHistory(true);
}, [state.sortBy, state.sortOrder]);
// 分页加载
useEffect(() => {
if (state.pagination.page > 1) {
loadAuditHistory(false);
}
}, [state.pagination.page]);
// 计算统计数据 - 按照参考组件的顺序
const stats = useMemo(() => [
{
label: '审核总数',
value: state.records.length,
color: 'text-blue-600 dark:text-blue-400',
bg: 'bg-blue-50 dark:bg-blue-950',
borderColor: 'border-blue-200 dark:border-blue-800',
},
{
label: '已通过',
value: state.records.filter(r => r.result === 'approved').length,
color: 'text-green-600 dark:text-green-400',
bg: 'bg-green-50 dark:bg-green-950',
borderColor: 'border-green-200 dark:border-green-800',
},
{
label: '已驳回',
value: state.records.filter(r => r.result === 'rejected').length,
color: 'text-red-600 dark:text-red-400',
bg: 'bg-red-50 dark:bg-red-950',
borderColor: 'border-red-200 dark:border-red-800',
},
{
label: '待审核',
value: state.records.filter(r => r.result === 'pending').length,
color: 'text-yellow-600 dark:text-yellow-400',
bg: 'bg-yellow-50 dark:bg-yellow-950',
borderColor: 'border-yellow-200 dark:border-yellow-800',
},
], [state.records]);
// 筛选记录
const filteredRecords = useMemo(() => {
return state.records.filter(record => {
const matchKeyword = !state.filters.searchKeyword ||
record.enterpriseName.toLowerCase().includes(state.filters.searchKeyword.toLowerCase()) ||
record.changeSummary.toLowerCase().includes(state.filters.searchKeyword.toLowerCase());
const matchResult = state.filters.resultFilter === 'all' || record.result === state.filters.resultFilter;
const matchType = state.filters.typeFilter === 'all' || record.auditType === state.filters.typeFilter;
// 日期筛选
let matchDate = true;
if (state.filters.dateRange !== 'all' && record.actionTime) {
const auditDate = new Date(record.actionTime);
const now = new Date();
const diffDays = Math.floor((now.getTime() - auditDate.getTime()) / (1000 * 60 * 60 * 24));
switch (state.filters.dateRange) {
case 'today':
matchDate = diffDays === 0;
break;
case 'week':
matchDate = diffDays <= 7;
break;
case 'month':
matchDate = diffDays <= 30;
break;
case 'quarter':
matchDate = diffDays <= 90;
break;
}
}
return matchKeyword && matchResult && matchType && matchDate;
});
}, [state.records, state.filters]);
// 事件处理器
const handleSearch = (value: string) => {
dispatch({ type: 'SET_FILTERS', payload: { searchKeyword: value } });
};
const handleResultFilter = (value: string) => {
dispatch({ type: 'SET_FILTERS', payload: { resultFilter: value } });
};
const handleTypeFilter = (value: string) => {
dispatch({ type: 'SET_FILTERS', payload: { typeFilter: value } });
};
const handleDateFilter = (value: string) => {
dispatch({ type: 'SET_FILTERS', payload: { dateRange: value } });
};
const handleSort = (sortBy?: string) => {
const newSortOrder = state.sortBy === sortBy && state.sortOrder === 'desc' ? 'asc' : 'desc';
dispatch({ type: 'SET_SORT', payload: { sortBy, sortOrder: newSortOrder } });
};
const handlePageChange = (page: number) => {
// 边界检查
if (page < 1) {
page = 1;
} else if (page > state.pagination.totalPages && state.pagination.totalPages > 0) {
page = state.pagination.totalPages;
}
dispatch({ type: 'SET_PAGINATION', payload: { page } });
};
const handleViewDetail = (record: AuditRecord) => {
dispatch({ type: 'SET_SELECTED_RECORD', payload: record });
dispatch({ type: 'TOGGLE_DETAIL_DIALOG', payload: true });
};
const handleExport = () => {
const exportData = filteredRecords.map(record => ({
企业名称: record.enterpriseName,
操作类型: record.action === 'SUBMIT' ? '提交' : '审核',
审核类型: record.auditType === 'register' ? '注册' : '更新',
操作时间: record.actionTime,
操作人: record.actionBy,
审核结果: record.result === 'approved' ? '审核通过' :
record.result === 'rejected' ? '审核拒绝' :
record.result === 'pending' ? '待审核' : '草稿',
变更摘要: record.changeSummary,
}));
const dataStr = JSON.stringify(exportData, 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 = `audit_history_${new Date().getTime()}.json`;
link.click();
toast.success('审核历史数据导出成功');
};
return (
<div className="space-y-6">
{/* Page Header */}
<Card className="p-6 bg-gradient-to-r from-purple-50 dark:from-purple-950 to-pink-50 dark:to-pink-950 border-purple-200 dark:border-purple-800">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<History className="w-6 h-6 text-purple-600 dark:text-purple-400 flex-shrink-0 mt-1" />
<div className="flex-1">
<h2 className="mb-2">审核历史</h2>
<p className="text-sm text-muted-foreground mb-3">
追溯查询全部企业的历史审核记录
</p>
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="bg-white dark:bg-gray-800 font-light">
<Search className="w-3 h-3 mr-1" />
智能查询
</Badge>
<Badge variant="outline" className="bg-white dark:bg-gray-800 font-light">
<Calendar className="w-3 h-3 mr-1" />
时间筛选
</Badge>
<Badge variant="outline" className="bg-white dark:bg-gray-800 font-light">
<FileText className="w-3 h-3 mr-1" />
详情查看
</Badge>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button onClick={handleExport}>
<Download className="w-4 h-4 mr-2" />
导出记录
</Button>
</div>
</div>
</Card>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat, index) => (
<Card key={index} className={`p-6 bg-card hover:bg-muted transition-colors ${stat.borderColor} border-2`}>
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-muted-foreground">{stat.label}</div>
<History className="w-5 h-5 text-purple-500" />
</div>
<div className={`text-3xl font-bold mb-1 ${stat.color}`}>{stat.value}</div>
<div className="text-xs text-muted-foreground">
条记录
</div>
</Card>
))}
</div>
{/* Filters */}
<Card className="p-6 bg-card">
<div className="flex flex-col lg:flex-row gap-4 mb-4">
<div className="flex-1">
<Label className="text-sm">搜索关键词</Label>
<div className="relative mt-2">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索企业名称、变更摘要..."
value={state.filters.searchKeyword}
onChange={(e) => handleSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="flex gap-4">
<div>
<Label className="text-sm">全部类型</Label>
<Select value={state.filters.typeFilter} onValueChange={handleTypeFilter}>
<SelectTrigger className="w-32 mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部类型</SelectItem>
<SelectItem value="register">注册审核</SelectItem>
<SelectItem value="update">变更审核</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm">全部结果</Label>
<Select value={state.filters.resultFilter} onValueChange={handleResultFilter}>
<SelectTrigger className="w-32 mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部结果</SelectItem>
<SelectItem value="pending">待审核</SelectItem>
<SelectItem value="approved">已通过</SelectItem>
<SelectItem value="rejected">已驳回</SelectItem>
<SelectItem value="draft">草稿</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</Card>
{/* Time Range Filter */}
<Card className="p-4 bg-card">
<Label className="text-sm text-muted-foreground mb-2 block">时间范围</Label>
<div className="flex gap-2">
{[
{ value: 'all', label: '全部' },
{ value: 'today', label: '今天' },
{ value: 'week', label: '近7天' },
{ value: 'month', label: '近30天' },
{ value: 'quarter', label: '近90天' },
].map((option) => (
<Button
key={option.value}
variant={state.filters.dateRange === option.value ? 'default' : 'outline'}
size="sm"
onClick={() => handleDateFilter(option.value)}
>
{option.label}
</Button>
))}
</div>
</Card>
{/* Error Display */}
{state.error && (
<div className="mb-4 p-4 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
<AlertCircle className="w-4 h-4" />
<span>{state.error}</span>
</div>
</div>
)}
{/* Loading State */}
{state.loading && (
<div className="text-center py-12 text-muted-foreground">
<RefreshCw className="w-8 h-8 mx-auto mb-2 animate-spin" />
<p>加载中...</p>
</div>
)}
{/* Data Table */}
{!state.loading && !state.error && (
<>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:bg-muted"
onClick={() => handleSort('enterprise_name')}
>
企业名称
{state.sortBy === 'enterprise_name' && (
<span className="ml-1">{state.sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted"
onClick={() => handleSort('action')}
>
操作类型
{state.sortBy === 'action' && (
<span className="ml-1">{state.sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</TableHead>
<TableHead>操作人</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted"
onClick={() => handleSort('action_time')}
>
操作时间
{state.sortBy === 'action_time' && (
<span className="ml-1">{state.sortOrder === 'asc' ? '↑' : '↓'}</span>
)}
</TableHead>
<TableHead>审核结果</TableHead>
<TableHead>变更摘要</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRecords.map((record) => (
<TableRow key={record.id}>
<TableCell>
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-purple-500" />
<span className="font-medium">{record.enterpriseName}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{getActionBadge(record.action)}
{record.auditType === 'register' && (
<Badge variant="outline" className="font-light">注册</Badge>
)}
{record.auditType === 'update' && (
<Badge variant="outline" className="font-light">更新</Badge>
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<User className="w-4 h-4 text-gray-500" />
<span className="text-sm">{record.actionBy}</span>
</div>
</TableCell>
<TableCell className="text-sm">{record.actionTime}</TableCell>
<TableCell>{getStatusBadge(record.result)}</TableCell>
<TableCell className="text-sm max-w-xs truncate" title={record.changeSummary}>
{record.changeSummary}
</TableCell>
<TableCell>
<Button
size="sm"
variant="outline"
onClick={() => handleViewDetail(record)}
>
<Eye className="w-3 h-3 mr-1" />
查看
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{filteredRecords.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<History className="w-12 h-12 mx-auto mb-4 opacity-20" />
<p>暂无审核历史记录</p>
</div>
)}
{/* Pagination */}
{state.pagination.totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
显示第 {state.pagination.page} 页,共 {state.pagination.totalPages} 页
<span className="ml-2">总计 {state.pagination.total} 条记录</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(state.pagination.page - 1)}
disabled={!state.pagination.hasPrev || state.loading}
>
<ChevronLeft className="w-4 h-4" />
上一页
</Button>
<span className="text-sm font-medium px-2">
{state.pagination.page} / {state.pagination.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(state.pagination.page + 1)}
disabled={!state.pagination.hasNext || state.loading}
>
下一页
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</>
)}
</Card>

View File

@@ -0,0 +1,107 @@
// 审核历史相关类型定义
export interface Enterprise {
id: string;
// 企业基本信息
name: string;
type: string; // 企业类型
province: string;
city: string;
district?: string;
// 其他信息
companySize?: string; // 公司规模
registeredCapital?: string; // 注册资本
establishmentDate?: string; // 成立时间
invoiceType?: string; // 发票类型
socialCreditCode: string; // 社会信用代码
businessScope?: string; // 经营范围
businessLicense?: string; // 营业执照图片URL
// 开户信息
bankAccount?: string; // 银行账号
bankName?: string; // 开户行
bankFullName?: string; // 开户行全称
bankAddress?: string; // 开户行地址
bankLicense?: string; // 开户许可证图片URL
// 法人信息
legalPerson?: string; // 法人名称
idCardFront?: string; // 身份证正面图片URL
idCardBack?: string; // 身份证反面图片URL
// 联系信息
registrant: string;
contactPhone: string;
address: string;
// 系统信息
status: EnterpriseStatus;
auditStatus: AuditStatus;
auditReason?: string;
auditTime?: string;
auditor?: string;
createdAt: string;
updatedAt: string;
}
export type EnterpriseStatus = 'active' | 'inactive' | 'suspended';
export type AuditStatus = 'pending' | 'approved' | 'rejected' | 'draft';
// 审核记录基于API响应结构
export interface AuditRecord {
id: string;
enterpriseId?: string;
enterpriseName: string;
action: 'SUBMIT' | 'AUDIT';
auditType: 'register' | 'update';
submitTime: string;
actionTime: string;
actionBy: string;
result: AuditStatus;
auditStatus: string;
auditComment?: string;
changeSummary: string;
ipAddress?: string;
userAgent?: string;
requestId?: string;
createdAt: string;
// 快照数据
snapshot: {
companyName: string;
companyType: string | null;
province: string | null;
city: string | null;
district: string | null;
detailedAddress: string | null;
registrant: string | null;
contactPhone: string | null;
bankAccount: string | null;
bankName: string | null;
bankFullName: string | null;
bankAddress: string | null;
socialCreditCode: string | null;
legalPersonName: string | null;
auditStatus: string;
auditComment: string | null;
companyScale: string | null;
registeredCapital: string | null;
};
}
// 统计数据
export interface AuditHistoryStats {
label: string;
value: number;
color: string;
bg: string;
}
// 筛选条件
export interface FilterOptions {
searchKeyword: string;
resultFilter: string;
typeFilter: string;
dateRange: string;
}

View File

@@ -0,0 +1,83 @@
/**
* filekorolheader: 企业审核分页组件 - 分页控制界面
* 功能:分页导航、页面跳转、分页信息展示
* 路径:/central-config/tenant/enterprise-audit/components/AuditPagination
* 规范遵循crop-x/docs/开发项目规范.md使用shadcn/ui组件TypeScript类型安全
*/
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface PaginationInfo {
page: number;
size: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
}
interface AuditPaginationProps {
pagination: PaginationInfo;
onPageChange: (page: number) => void;
loading?: boolean;
}
export function AuditPagination({
pagination,
onPageChange,
loading = false
}: AuditPaginationProps) {
const { page, size, total, totalPages, hasNext, hasPrev } = pagination;
if (total === 0) return null;
return (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{(page - 1) * size + 1} - {Math.min(page * size, total)} {total}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page - 1)}
disabled={!hasPrev || loading}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground"></span>
<Input
type="number"
min={1}
max={totalPages}
value={page}
onChange={(e) => {
const newPage = parseInt(e.target.value);
if (!isNaN(newPage)) {
onPageChange(newPage);
}
}}
className="w-16 h-8 text-center"
disabled={loading}
/>
<span className="text-sm text-muted-foreground">/ {totalPages} </span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={!hasNext || loading}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
/**
* filekorolheader: 企业审核搜索筛选组件 - 搜索和筛选功能界面
* 功能:关键词搜索、状态筛选、搜索功能实现
* 路径:/central-config/tenant/enterprise-audit/components/AuditSearchAndFilter
* 规范遵循crop-x/docs/开发项目规范.md使用shadcn/ui组件TypeScript类型安全
*/
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search, RotateCcw } from 'lucide-react';
interface AuditSearchAndFilterProps {
searchKeyword: string;
onSearchChange: (keyword: string) => void;
statusFilter: string;
onStatusFilterChange: (status: string) => void;
onRefresh: () => void;
loading?: boolean;
}
export function AuditSearchAndFilter({
searchKeyword,
onSearchChange,
statusFilter,
onStatusFilterChange,
onRefresh,
loading = false
}: AuditSearchAndFilterProps) {
return (
<Card className="p-4">
<div className="flex gap-4">
<div className="flex-1">
<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>
</div>
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="草稿">稿</SelectItem>
<SelectItem value="待审核"></SelectItem>
<SelectItem value="已通过"></SelectItem>
<SelectItem value="已驳回"></SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
onClick={onRefresh}
disabled={loading}
>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
</div>
</Card>
);
}

Some files were not shown because too many files have changed in this diff Show More