子仓库提交
This commit is contained in:
11
src/app/(app)/central-config/layout.tsx
Normal file
11
src/app/(app)/central-config/layout.tsx
Normal 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}</>
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
115
src/app/(app)/central-config/message/log/mock/mockData.ts
Normal file
115
src/app/(app)/central-config/message/log/mock/mockData.ts
Normal 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,
|
||||
}
|
||||
];
|
||||
139
src/app/(app)/central-config/message/log/page.tsx
Normal file
139
src/app/(app)/central-config/message/log/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/app/(app)/central-config/message/page.tsx
Normal file
26
src/app/(app)/central-config/message/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { MessageStatsCards } from './MessageStatsCards';
|
||||
export { MessageSendTable } from './MessageSendTable';
|
||||
export { SendMessageDialog } from './SendMessageDialog';
|
||||
export { MessagePreviewDialog } from './MessagePreviewDialog';
|
||||
export { MessageInstructions } from './MessageInstructions';
|
||||
493
src/app/(app)/central-config/message/send/page.tsx
Normal file
493
src/app/(app)/central-config/message/send/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/app/(app)/central-config/message/send/types.ts
Normal file
12
src/app/(app)/central-config/message/send/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
269
src/app/(app)/central-config/message/template/page.tsx
Normal file
269
src/app/(app)/central-config/message/template/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/app/(app)/central-config/message/template/types.ts
Normal file
14
src/app/(app)/central-config/message/template/types.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './loginLogApi'
|
||||
@@ -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);
|
||||
};
|
||||
404
src/app/(app)/central-config/monitor/login-log/page.tsx
Normal file
404
src/app/(app)/central-config/monitor/login-log/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
139
src/app/(app)/central-config/monitor/network-log/page.tsx
Normal file
139
src/app/(app)/central-config/monitor/network-log/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './operationLogApi'
|
||||
@@ -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);
|
||||
};
|
||||
378
src/app/(app)/central-config/monitor/operation-log/page.tsx
Normal file
378
src/app/(app)/central-config/monitor/operation-log/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/app/(app)/central-config/monitor/page.tsx
Normal file
30
src/app/(app)/central-config/monitor/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
274
src/app/(app)/central-config/monitor/performance/page.tsx
Normal file
274
src/app/(app)/central-config/monitor/performance/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/(app)/central-config/page.tsx
Normal file
13
src/app/(app)/central-config/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
22
src/app/(app)/central-config/personal-center/page.tsx
Normal file
22
src/app/(app)/central-config/personal-center/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
interface CategoryFiltersProps {
|
||||
searchKeyword: string;
|
||||
typeFilter: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onTypeFilterChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function CategoryFilters({
|
||||
searchKeyword,
|
||||
typeFilter,
|
||||
onSearchChange,
|
||||
onTypeFilterChange,
|
||||
}: CategoryFiltersProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索分类名称、编码..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={typeFilter} onValueChange={onTypeFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="分类类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="industry">行业类型</SelectItem>
|
||||
<SelectItem value="equipment">设备类型</SelectItem>
|
||||
<SelectItem value="crop">作物类型</SelectItem>
|
||||
<SelectItem value="operation">作业类型</SelectItem>
|
||||
<SelectItem value="other">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { CategoryDictionary, CategoryFormData } from '../types';
|
||||
|
||||
interface CategoryFormDialogProps {
|
||||
open: boolean;
|
||||
editing?: CategoryDictionary;
|
||||
parent?: CategoryDictionary | null;
|
||||
formData: CategoryFormData;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onFormDataChange: (data: Partial<CategoryFormData>) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export function CategoryFormDialog({
|
||||
open,
|
||||
editing,
|
||||
parent,
|
||||
formData,
|
||||
onOpenChange,
|
||||
onFormDataChange,
|
||||
onSave,
|
||||
}: CategoryFormDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? '编辑分类' : '新增分类'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{editing ? '编辑分类信息' : '添加新分类'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{parent && (
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<Label className="text-sm text-blue-900 dark:text-blue-100">上级分类</Label>
|
||||
<p className="mt-1 dark:text-gray-100">{parent.name}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="code">分类编码 *</Label>
|
||||
<Input
|
||||
id="code"
|
||||
value={formData.code}
|
||||
onChange={(e) => onFormDataChange({ code: e.target.value })}
|
||||
placeholder="IND001"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="name">分类名称 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => onFormDataChange({ name: e.target.value })}
|
||||
placeholder="请输入名称"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="type">分类类型</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(value) => onFormDataChange({ type: value })}
|
||||
disabled={!!parent}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="industry">行业类型</SelectItem>
|
||||
<SelectItem value="equipment">设备类型</SelectItem>
|
||||
<SelectItem value="crop">作物类型</SelectItem>
|
||||
<SelectItem value="operation">作业类型</SelectItem>
|
||||
<SelectItem value="other">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => onFormDataChange({ description: e.target.value })}
|
||||
placeholder="请输入描述"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="sortOrder">排序</Label>
|
||||
<Input
|
||||
id="sortOrder"
|
||||
type="number"
|
||||
value={formData.sortOrder}
|
||||
onChange={(e) => onFormDataChange({ sortOrder: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Label htmlFor="isActive">是否启用</Label>
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => onFormDataChange({ isActive: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={onSave}>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { FolderTree } from 'lucide-react';
|
||||
|
||||
export function CategoryInstructions() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
|
||||
<h4 className="text-blue-900 dark:text-blue-100 mb-2">
|
||||
<FolderTree className="w-4 h-4 inline mr-2" />
|
||||
分类字典说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800 dark:text-blue-200">
|
||||
<li>• 支持多级树形结构,可无限级嵌套</li>
|
||||
<li>• 点击文件夹图标可展开/收起子分类</li>
|
||||
<li>• 鼠标悬停在分类上可显示操作按钮</li>
|
||||
<li>• 删除前需先删除所有子分类</li>
|
||||
<li>• 分类编码建议使用层级编码规则(如 IND001-01)</li>
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
File,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { CategoryDictionary } from '../types';
|
||||
|
||||
interface CategoryTreeProps {
|
||||
categories: CategoryDictionary[];
|
||||
expandedIds: Set<string>;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onAdd: (parent?: CategoryDictionary) => void;
|
||||
onEdit: (category: CategoryDictionary) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function CategoryTree({
|
||||
categories,
|
||||
expandedIds,
|
||||
onToggleExpand,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CategoryTreeProps) {
|
||||
const renderTree = (nodes: CategoryDictionary[], level: number = 0) => {
|
||||
return nodes.map(node => (
|
||||
<div key={node.id} style={{ marginLeft: `${level * 24}px` }}>
|
||||
<div className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg group">
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{node.children && node.children.length > 0 ? (
|
||||
<button
|
||||
onClick={() => onToggleExpand(node.id)}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
{expandedIds.has(node.id) ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6" />
|
||||
)}
|
||||
{node.children && node.children.length > 0 ? (
|
||||
<Folder className="w-4 h-4 text-yellow-600 dark:text-yellow-500" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="dark:text-gray-100">{node.name}</span>
|
||||
<Badge variant="outline" className="text-xs">{node.code}</Badge>
|
||||
{!node.isActive && (
|
||||
<Badge variant="outline" className="text-xs text-red-600 dark:text-red-400">已停用</Badge>
|
||||
)}
|
||||
</div>
|
||||
{node.description && (
|
||||
<p className="text-xs text-muted-foreground dark:text-gray-400">{node.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onAdd(node)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(node)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(node.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{expandedIds.has(node.id) && node.children && renderTree(node.children, level + 1)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="min-h-[400px]">
|
||||
{categories.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
暂无分类数据
|
||||
</div>
|
||||
) : (
|
||||
renderTree(categories)
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
306
src/app/(app)/central-config/system/category/page.tsx
Normal file
306
src/app/(app)/central-config/system/category/page.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
'use client';
|
||||
|
||||
import React, { useReducer, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { CategoryDictionary, CategoryAction } from './types';
|
||||
import { categoryReducer, initialState } from './reducer';
|
||||
import { CategoryFilters } from './components/CategoryFilters';
|
||||
import { CategoryTree } from './components/CategoryTree';
|
||||
import { CategoryFormDialog } from './components/CategoryFormDialog';
|
||||
import { CategoryInstructions } from './components/CategoryInstructions';
|
||||
|
||||
export default function CategoryDictionaryPage() {
|
||||
const [state, dispatch] = useReducer(categoryReducer, initialState);
|
||||
|
||||
// 模拟数据加载
|
||||
useEffect(() => {
|
||||
const mockData: CategoryDictionary[] = [
|
||||
{
|
||||
id: 'cat-1',
|
||||
code: 'IND001',
|
||||
name: '种植业',
|
||||
type: 'industry',
|
||||
level: 1,
|
||||
sortOrder: 1,
|
||||
description: '农作物种植相关行业',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-2',
|
||||
code: 'IND001-01',
|
||||
name: '粮食作物',
|
||||
type: 'industry',
|
||||
parentId: 'cat-1',
|
||||
level: 2,
|
||||
sortOrder: 1,
|
||||
description: '小麦、水稻、玉米等粮食作物',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-3',
|
||||
code: 'IND001-02',
|
||||
name: '经济作物',
|
||||
type: 'industry',
|
||||
parentId: 'cat-1',
|
||||
level: 2,
|
||||
sortOrder: 2,
|
||||
description: '棉花、油料、糖料等经济作物',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-4',
|
||||
code: 'IND002',
|
||||
name: '畜牧业',
|
||||
type: 'industry',
|
||||
level: 1,
|
||||
sortOrder: 2,
|
||||
description: '牲畜饲养相关行业',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-5',
|
||||
code: 'EQP001',
|
||||
name: '动力机械',
|
||||
type: 'equipment',
|
||||
level: 1,
|
||||
sortOrder: 1,
|
||||
description: '拖拉机等动力设备',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-6',
|
||||
code: 'EQP001-01',
|
||||
name: '轮式拖拉机',
|
||||
type: 'equipment',
|
||||
parentId: 'cat-5',
|
||||
level: 2,
|
||||
sortOrder: 1,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-7',
|
||||
code: 'EQP001-02',
|
||||
name: '履带式拖拉机',
|
||||
type: 'equipment',
|
||||
parentId: 'cat-5',
|
||||
level: 2,
|
||||
sortOrder: 2,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-8',
|
||||
code: 'EQP002',
|
||||
name: '收获机械',
|
||||
type: 'equipment',
|
||||
level: 1,
|
||||
sortOrder: 2,
|
||||
description: '收割机、采摘机等',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
];
|
||||
|
||||
// 尝试从 localStorage 加载数据
|
||||
const storedData = localStorage.getItem('smart_agriculture_category_dictionary');
|
||||
if (storedData) {
|
||||
try {
|
||||
const data = JSON.parse(storedData);
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: data });
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored data:', error);
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: mockData });
|
||||
}
|
||||
} else {
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: mockData });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 保存数据到 localStorage
|
||||
const saveCategories = (categories: CategoryDictionary[]) => {
|
||||
localStorage.setItem('smart_agriculture_category_dictionary', JSON.stringify(categories));
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: categories });
|
||||
};
|
||||
|
||||
// 构建树形结构
|
||||
const buildTree = (items: CategoryDictionary[]): CategoryDictionary[] => {
|
||||
const map = new Map<string, CategoryDictionary>();
|
||||
const roots: CategoryDictionary[] = [];
|
||||
|
||||
// 创建映射
|
||||
items.forEach(item => {
|
||||
map.set(item.id, { ...item, children: [] });
|
||||
});
|
||||
|
||||
// 构建树
|
||||
items.forEach(item => {
|
||||
const node = map.get(item.id)!;
|
||||
if (item.parentId) {
|
||||
const parent = map.get(item.parentId);
|
||||
if (parent) {
|
||||
parent.children = parent.children || [];
|
||||
parent.children.push(node);
|
||||
}
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
return roots;
|
||||
};
|
||||
|
||||
// 过滤分类数据
|
||||
const filteredCategories = state.categories.filter(cat => {
|
||||
const matchKeyword = !state.searchKeyword ||
|
||||
cat.name.includes(state.searchKeyword) ||
|
||||
cat.code.includes(state.searchKeyword);
|
||||
const matchType = state.typeFilter === 'all' || cat.type === state.typeFilter;
|
||||
return matchKeyword && matchType;
|
||||
});
|
||||
|
||||
const treeData = buildTree(filteredCategories);
|
||||
|
||||
// 处理新增
|
||||
const handleAdd = (parent?: CategoryDictionary) => {
|
||||
dispatch({
|
||||
type: 'SET_DIALOG_STATE',
|
||||
payload: {
|
||||
open: true,
|
||||
editing: undefined,
|
||||
parent: parent || null,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = (category: CategoryDictionary) => {
|
||||
dispatch({
|
||||
type: 'SET_DIALOG_STATE',
|
||||
payload: {
|
||||
open: true,
|
||||
editing: category,
|
||||
parent: undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 处理删除
|
||||
const handleDelete = (id: string) => {
|
||||
// 检查是否有子分类
|
||||
const hasChildren = state.categories.some(cat => cat.parentId === id);
|
||||
if (hasChildren) {
|
||||
toast.error('请先删除子分类');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = state.categories.filter(cat => cat.id !== id);
|
||||
saveCategories(updated);
|
||||
toast.success('删除成功');
|
||||
};
|
||||
|
||||
// 处理保存
|
||||
const handleSave = () => {
|
||||
if (!state.formData.code.trim() || !state.formData.name.trim()) {
|
||||
toast.error('请填写编码和名称');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.dialogState.editing) {
|
||||
// 编辑
|
||||
dispatch({
|
||||
type: 'UPDATE_CATEGORY',
|
||||
payload: {
|
||||
id: state.dialogState.editing.id,
|
||||
updates: state.formData,
|
||||
},
|
||||
});
|
||||
saveCategories(state.categories);
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
// 新增
|
||||
const newCategory: CategoryDictionary = {
|
||||
id: `cat-${Date.now()}`,
|
||||
...state.formData,
|
||||
parentId: state.dialogState.parent?.id,
|
||||
level: state.dialogState.parent ? state.dialogState.parent.level + 1 : 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
dispatch({ type: 'ADD_CATEGORY', payload: newCategory });
|
||||
saveCategories([...state.categories, newCategory]);
|
||||
toast.success('添加成功');
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'SET_DIALOG_STATE',
|
||||
payload: { open: false, editing: undefined, parent: undefined },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-green-800 dark:text-green-600">分类字典</h2>
|
||||
<p className="text-muted-foreground dark:text-gray-400">树形结构管理系统常用分类数据</p>
|
||||
</div>
|
||||
<Button onClick={() => handleAdd()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增分类
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<CategoryFilters
|
||||
searchKeyword={state.searchKeyword}
|
||||
typeFilter={state.typeFilter}
|
||||
onSearchChange={(value) => dispatch({ type: 'SET_SEARCH_KEYWORD', payload: value })}
|
||||
onTypeFilterChange={(value) => dispatch({ type: 'SET_TYPE_FILTER', payload: value })}
|
||||
/>
|
||||
|
||||
{/* 分类树 */}
|
||||
<CategoryTree
|
||||
categories={treeData}
|
||||
expandedIds={state.expandedIds}
|
||||
onToggleExpand={(id) => dispatch({ type: 'TOGGLE_EXPAND', payload: id })}
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* 编辑对话框 */}
|
||||
<CategoryFormDialog
|
||||
open={state.dialogState.open}
|
||||
editing={state.dialogState.editing}
|
||||
parent={state.dialogState.parent}
|
||||
formData={state.formData}
|
||||
onOpenChange={(open) => dispatch({
|
||||
type: 'SET_DIALOG_STATE',
|
||||
payload: { open, editing: undefined, parent: undefined },
|
||||
})}
|
||||
onFormDataChange={(data) => dispatch({ type: 'SET_FORM_DATA', payload: data })}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<CategoryInstructions />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
src/app/(app)/central-config/system/category/reducer.ts
Normal file
94
src/app/(app)/central-config/system/category/reducer.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { CategoryDictionary, CategoryAction, CategoryState, CategoryFormData } from './types';
|
||||
|
||||
const initialFormData: CategoryFormData = {
|
||||
code: '',
|
||||
name: '',
|
||||
type: 'industry',
|
||||
description: '',
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
export const initialState: CategoryState = {
|
||||
categories: [],
|
||||
searchKeyword: '',
|
||||
typeFilter: 'all',
|
||||
expandedIds: new Set(),
|
||||
dialogState: {
|
||||
open: false,
|
||||
editing: undefined,
|
||||
parent: undefined,
|
||||
},
|
||||
formData: initialFormData,
|
||||
};
|
||||
|
||||
export function categoryReducer(state: CategoryState, action: CategoryAction): CategoryState {
|
||||
switch (action.type) {
|
||||
case 'SET_CATEGORIES':
|
||||
return { ...state, categories: action.payload };
|
||||
|
||||
case 'ADD_CATEGORY':
|
||||
return { ...state, categories: [...state.categories, action.payload] };
|
||||
|
||||
case 'UPDATE_CATEGORY':
|
||||
return {
|
||||
...state,
|
||||
categories: state.categories.map(cat =>
|
||||
cat.id === action.payload.id
|
||||
? { ...cat, ...action.payload.updates, updatedAt: new Date().toISOString() }
|
||||
: cat
|
||||
),
|
||||
};
|
||||
|
||||
case 'DELETE_CATEGORY':
|
||||
return {
|
||||
...state,
|
||||
categories: state.categories.filter(cat => cat.id !== action.payload),
|
||||
};
|
||||
|
||||
case 'SET_SEARCH_KEYWORD':
|
||||
return { ...state, searchKeyword: action.payload };
|
||||
|
||||
case 'SET_TYPE_FILTER':
|
||||
return { ...state, typeFilter: action.payload };
|
||||
|
||||
case 'TOGGLE_EXPAND':
|
||||
const newExpanded = new Set(state.expandedIds);
|
||||
if (newExpanded.has(action.payload)) {
|
||||
newExpanded.delete(action.payload);
|
||||
} else {
|
||||
newExpanded.add(action.payload);
|
||||
}
|
||||
return { ...state, expandedIds: newExpanded };
|
||||
|
||||
case 'SET_DIALOG_STATE':
|
||||
return {
|
||||
...state,
|
||||
dialogState: action.payload,
|
||||
formData: action.payload.editing
|
||||
? {
|
||||
code: action.payload.editing.code,
|
||||
name: action.payload.editing.name,
|
||||
type: action.payload.editing.type,
|
||||
description: action.payload.editing.description || '',
|
||||
sortOrder: action.payload.editing.sortOrder,
|
||||
isActive: action.payload.editing.isActive,
|
||||
}
|
||||
: action.payload.parent
|
||||
? {
|
||||
...initialFormData,
|
||||
type: action.payload.parent.type,
|
||||
}
|
||||
: initialFormData,
|
||||
};
|
||||
|
||||
case 'SET_FORM_DATA':
|
||||
return {
|
||||
...state,
|
||||
formData: { ...state.formData, ...action.payload },
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
53
src/app/(app)/central-config/system/category/types.ts
Normal file
53
src/app/(app)/central-config/system/category/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// 分类字典类型定义
|
||||
export interface CategoryDictionary {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
type: string; // 分类类型:industry, equipment, crop等
|
||||
parentId?: string;
|
||||
level: number;
|
||||
sortOrder: number;
|
||||
description?: string;
|
||||
isActive: boolean;
|
||||
children?: CategoryDictionary[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type CategoryType = 'industry' | 'equipment' | 'crop' | 'operation' | 'other';
|
||||
|
||||
// 分类表单数据
|
||||
export interface CategoryFormData {
|
||||
code: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
// 分类操作类型
|
||||
export type CategoryAction =
|
||||
| { type: 'SET_CATEGORIES'; payload: CategoryDictionary[] }
|
||||
| { type: 'ADD_CATEGORY'; payload: CategoryDictionary }
|
||||
| { type: 'UPDATE_CATEGORY'; payload: { id: string; updates: Partial<CategoryDictionary> } }
|
||||
| { type: 'DELETE_CATEGORY'; payload: string }
|
||||
| { type: 'SET_SEARCH_KEYWORD'; payload: string }
|
||||
| { type: 'SET_TYPE_FILTER'; payload: string }
|
||||
| { type: 'TOGGLE_EXPAND'; payload: string }
|
||||
| { type: 'SET_DIALOG_STATE'; payload: { open: boolean; editing?: CategoryDictionary; parent?: CategoryDictionary | null } }
|
||||
| { type: 'SET_FORM_DATA'; payload: Partial<CategoryFormData> };
|
||||
|
||||
// 分类状态
|
||||
export interface CategoryState {
|
||||
categories: CategoryDictionary[];
|
||||
searchKeyword: string;
|
||||
typeFilter: string;
|
||||
expandedIds: Set<string>;
|
||||
dialogState: {
|
||||
open: boolean;
|
||||
editing?: CategoryDictionary;
|
||||
parent?: CategoryDictionary | null;
|
||||
};
|
||||
formData: CategoryFormData;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { CategoryFormData, CategoryDictionary } from '../types';
|
||||
|
||||
interface CategoryFormProps {
|
||||
open: boolean;
|
||||
editing: CategoryDictionary | null;
|
||||
formData: CategoryFormData;
|
||||
onFormDataChange: (data: Partial<CategoryFormData>) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export function CategoryForm({
|
||||
open,
|
||||
editing,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: CategoryFormProps) {
|
||||
const handleSave = () => {
|
||||
if (!formData.code.trim() || !formData.name.trim() || !formData.value.trim() || !formData.label.trim()) {
|
||||
return false;
|
||||
}
|
||||
onSave();
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? '编辑字典' : '新增字典'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{editing ? '编辑数据字典' : '添加新数据字典'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>编码 *</Label>
|
||||
<Input
|
||||
value={formData.code}
|
||||
onChange={(e) => onFormDataChange({ code: e.target.value })}
|
||||
placeholder="GENDER_MALE"
|
||||
disabled={editing?.isSystem}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>名称 *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => onFormDataChange({ name: e.target.value })}
|
||||
placeholder="性别-男"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>字典分类 *</Label>
|
||||
<Select
|
||||
value={formData.category}
|
||||
onValueChange={(value) => onFormDataChange({ category: value })}
|
||||
disabled={editing?.isSystem}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="gender">性别</SelectItem>
|
||||
<SelectItem value="status">状态</SelectItem>
|
||||
<SelectItem value="unit">单位</SelectItem>
|
||||
<SelectItem value="weather">天气</SelectItem>
|
||||
<SelectItem value="soil_type">土壤类型</SelectItem>
|
||||
<SelectItem value="irrigation_method">灌溉方式</SelectItem>
|
||||
<SelectItem value="fertilizer_type">肥料类型</SelectItem>
|
||||
<SelectItem value="pesticide_type">农药类型</SelectItem>
|
||||
<SelectItem value="task_status">任务状态</SelectItem>
|
||||
<SelectItem value="task_priority">任务优先级</SelectItem>
|
||||
<SelectItem value="approval_status">审批状态</SelectItem>
|
||||
<SelectItem value="operation_type">作业类型</SelectItem>
|
||||
<SelectItem value="other">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>值 *</Label>
|
||||
<Input
|
||||
value={formData.value}
|
||||
onChange={(e) => onFormDataChange({ value: e.target.value })}
|
||||
placeholder="male"
|
||||
disabled={editing?.isSystem}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
程序中使用的值,建议使用英文
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>标签 *</Label>
|
||||
<Input
|
||||
value={formData.label}
|
||||
onChange={(e) => onFormDataChange({ label: e.target.value })}
|
||||
placeholder="男"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
界面上显示的文本
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>描述</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => onFormDataChange({ description: e.target.value })}
|
||||
placeholder="请输入描述"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>排序</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.sortOrder}
|
||||
onChange={(e) => onFormDataChange({ sortOrder: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Label>是否启用</Label>
|
||||
<Switch
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => onFormDataChange({ isActive: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Search, BookOpen, Edit, Trash2 } from 'lucide-react';
|
||||
import { CategoryDictionary } from '../types';
|
||||
|
||||
interface CategoryListProps {
|
||||
categories: CategoryDictionary[];
|
||||
searchKeyword: string;
|
||||
categoryFilter: string;
|
||||
onSearchChange: (keyword: string) => void;
|
||||
onCategoryFilterChange: (category: string) => void;
|
||||
onEdit: (category: CategoryDictionary) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function CategoryList({
|
||||
categories,
|
||||
searchKeyword,
|
||||
categoryFilter,
|
||||
onSearchChange,
|
||||
onCategoryFilterChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CategoryListProps) {
|
||||
// 过滤字典
|
||||
const filteredCategories = categories.filter(category => {
|
||||
const matchKeyword = !searchKeyword ||
|
||||
category.name.includes(searchKeyword) ||
|
||||
category.code.includes(searchKeyword) ||
|
||||
category.label.includes(searchKeyword) ||
|
||||
category.value.includes(searchKeyword);
|
||||
const matchCategory = categoryFilter === 'all' || category.category === categoryFilter;
|
||||
return matchKeyword && matchCategory;
|
||||
});
|
||||
|
||||
// 按分类分组
|
||||
const groupedCategories = filteredCategories.reduce((acc, category) => {
|
||||
if (!acc[category.category]) {
|
||||
acc[category.category] = [];
|
||||
}
|
||||
acc[category.category].push(category);
|
||||
return acc;
|
||||
}, {} as Record<string, CategoryDictionary[]>);
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
gender: '性别',
|
||||
status: '状态',
|
||||
unit: '单位',
|
||||
weather: '天气',
|
||||
soil_type: '土壤类型',
|
||||
irrigation_method: '灌溉方式',
|
||||
fertilizer_type: '肥料类型',
|
||||
pesticide_type: '农药类型',
|
||||
task_status: '任务状态',
|
||||
task_priority: '任务优先级',
|
||||
approval_status: '审批状态',
|
||||
operation_type: '作业类型',
|
||||
other: '其他',
|
||||
};
|
||||
return labels[category] || category;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 搜索和筛选 */}
|
||||
<Card className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索编码、名称、标签、值..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={categoryFilter} onValueChange={onCategoryFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="字典分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部分类</SelectItem>
|
||||
<SelectItem value="gender">性别</SelectItem>
|
||||
<SelectItem value="status">状态</SelectItem>
|
||||
<SelectItem value="unit">单位</SelectItem>
|
||||
<SelectItem value="weather">天气</SelectItem>
|
||||
<SelectItem value="soil_type">土壤类型</SelectItem>
|
||||
<SelectItem value="irrigation_method">灌溉方式</SelectItem>
|
||||
<SelectItem value="fertilizer_type">肥料类型</SelectItem>
|
||||
<SelectItem value="pesticide_type">农药类型</SelectItem>
|
||||
<SelectItem value="task_status">任务状态</SelectItem>
|
||||
<SelectItem value="task_priority">任务优先级</SelectItem>
|
||||
<SelectItem value="approval_status">审批状态</SelectItem>
|
||||
<SelectItem value="operation_type">作业类型</SelectItem>
|
||||
<SelectItem value="other">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 字典列表 */}
|
||||
{Object.entries(groupedCategories).map(([category, items]) => (
|
||||
<Card key={category}>
|
||||
<div className="p-4 border-b bg-muted/50">
|
||||
<h3 className="flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5 text-green-600" />
|
||||
{getCategoryLabel(category)}
|
||||
<Badge variant="outline">{items.length}</Badge>
|
||||
</h3>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>编码</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>值</TableHead>
|
||||
<TableHead>标签</TableHead>
|
||||
<TableHead>排序</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.sort((a, b) => a.sortOrder - b.sortOrder).map((category) => (
|
||||
<TableRow key={category.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded">{category.code}</code>
|
||||
{category.isSystem && (
|
||||
<Badge variant="outline" className="text-xs">系统</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div>{category.name}</div>
|
||||
{category.description && (
|
||||
<p className="text-xs text-muted-foreground">{category.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs">{category.value}</code>
|
||||
</TableCell>
|
||||
<TableCell>{category.label}</TableCell>
|
||||
<TableCell>{category.sortOrder}</TableCell>
|
||||
<TableCell>
|
||||
{category.isActive ? (
|
||||
<Badge className="bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">启用</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">停用</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(category)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{!category.isSystem && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(category.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{filteredCategories.length === 0 && (
|
||||
<Card className="p-12 text-center text-muted-foreground">
|
||||
暂无字典数据
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 使用说明 */}
|
||||
<Card className="p-4 bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800">
|
||||
<h4 className="text-blue-900 dark:text-blue-100 mb-2">
|
||||
<BookOpen className="w-4 h-4 inline mr-2" />
|
||||
数据字典说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800 dark:text-blue-200">
|
||||
<li>• 数据字典用于存储系统中的枚举值和下拉选项</li>
|
||||
<li>• 编码应遵循命名规范,使用大写字母和下划线(如 GENDER_MALE)</li>
|
||||
<li>• 值(value)用于程序逻辑,标签(label)用于界面显示</li>
|
||||
<li>• 系统内置字典不可删除,但可以编辑标签和状态</li>
|
||||
<li>• 支持按分类分组展示,便于管理和查找</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { CategoryDictionary } from '../types';
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
open: boolean;
|
||||
category: CategoryDictionary | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteConfirmDialog({
|
||||
open,
|
||||
category,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: DeleteConfirmDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
确定要删除字典项"{category?.name}"吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{category && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">编码:</span>
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded">{category.code}</code>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">名称:</span>
|
||||
<span>{category.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">分类:</span>
|
||||
<span>{category.category}</span>
|
||||
</div>
|
||||
{category.isSystem && (
|
||||
<div className="mt-2 p-2 bg-destructive/10 border border-destructive/20 rounded text-destructive text-xs">
|
||||
⚠️ 这是系统内置字典,通常不建议删除
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
disabled={category?.isSystem}
|
||||
>
|
||||
{category?.isSystem ? '系统字典不可删除' : '确认删除'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
385
src/app/(app)/central-config/system/dictionary/page.tsx
Normal file
385
src/app/(app)/central-config/system/dictionary/page.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
'use client';
|
||||
|
||||
import React, { useReducer, useLayoutEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, Download } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { CategoryList } from './components/CategoryList';
|
||||
import { CategoryForm } from './components/CategoryForm';
|
||||
import { DeleteConfirmDialog } from './components/DeleteConfirmDialog';
|
||||
import { CategoryDictionary } from './types';
|
||||
import { categoryReducer, initialCategoryState } from './reducer';
|
||||
|
||||
// 模拟数据
|
||||
const mockData: CategoryDictionary[] = [
|
||||
// 性别
|
||||
{
|
||||
id: 'dict-1',
|
||||
code: 'GENDER_MALE',
|
||||
name: '性别-男',
|
||||
category: 'gender',
|
||||
value: 'male',
|
||||
label: '男',
|
||||
sortOrder: 1,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-2',
|
||||
code: 'GENDER_FEMALE',
|
||||
name: '性别-女',
|
||||
category: 'gender',
|
||||
value: 'female',
|
||||
label: '女',
|
||||
sortOrder: 2,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
// 状态
|
||||
{
|
||||
id: 'dict-3',
|
||||
code: 'STATUS_ACTIVE',
|
||||
name: '状态-激活',
|
||||
category: 'status',
|
||||
value: 'active',
|
||||
label: '激活',
|
||||
sortOrder: 1,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-4',
|
||||
code: 'STATUS_INACTIVE',
|
||||
name: '状态-停用',
|
||||
category: 'status',
|
||||
value: 'inactive',
|
||||
label: '停用',
|
||||
sortOrder: 2,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
// 单位类型
|
||||
{
|
||||
id: 'dict-5',
|
||||
code: 'UNIT_AREA_MU',
|
||||
name: '面积单位-亩',
|
||||
category: 'unit',
|
||||
value: 'mu',
|
||||
label: '亩',
|
||||
sortOrder: 1,
|
||||
description: '中国传统面积单位',
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-6',
|
||||
code: 'UNIT_AREA_HECTARE',
|
||||
name: '面积单位-公顷',
|
||||
category: 'unit',
|
||||
value: 'hectare',
|
||||
label: '公顷',
|
||||
sortOrder: 2,
|
||||
description: '国际通用面积单位',
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-7',
|
||||
code: 'UNIT_WEIGHT_KG',
|
||||
name: '重量单位-千克',
|
||||
category: 'unit',
|
||||
value: 'kg',
|
||||
label: '千克',
|
||||
sortOrder: 3,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-8',
|
||||
code: 'UNIT_WEIGHT_TON',
|
||||
name: '重量单位-吨',
|
||||
category: 'unit',
|
||||
value: 'ton',
|
||||
label: '吨',
|
||||
sortOrder: 4,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
// 天气
|
||||
{
|
||||
id: 'dict-9',
|
||||
code: 'WEATHER_SUNNY',
|
||||
name: '天气-晴',
|
||||
category: 'weather',
|
||||
value: 'sunny',
|
||||
label: '晴',
|
||||
sortOrder: 1,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-10',
|
||||
code: 'WEATHER_CLOUDY',
|
||||
name: '天气-多云',
|
||||
category: 'weather',
|
||||
value: 'cloudy',
|
||||
label: '多云',
|
||||
sortOrder: 2,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-11',
|
||||
code: 'WEATHER_RAINY',
|
||||
name: '天气-雨',
|
||||
category: 'weather',
|
||||
value: 'rainy',
|
||||
label: '雨',
|
||||
sortOrder: 3,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
// 土壤类型
|
||||
{
|
||||
id: 'dict-12',
|
||||
code: 'SOIL_SANDY',
|
||||
name: '土壤-砂土',
|
||||
category: 'soil_type',
|
||||
value: 'sandy',
|
||||
label: '砂土',
|
||||
sortOrder: 1,
|
||||
description: '含砂粒较多的土壤',
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-13',
|
||||
code: 'SOIL_LOAMY',
|
||||
name: '土壤-壤土',
|
||||
category: 'soil_type',
|
||||
value: 'loamy',
|
||||
label: '壤土',
|
||||
sortOrder: 2,
|
||||
description: '砂粘适中的土壤',
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-14',
|
||||
code: 'SOIL_CLAY',
|
||||
name: '土壤-黏土',
|
||||
category: 'soil_type',
|
||||
value: 'clay',
|
||||
label: '黏土',
|
||||
sortOrder: 3,
|
||||
description: '含黏粒较多的土壤',
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DataDictionaryPage() {
|
||||
const [state, dispatch] = useReducer(categoryReducer, initialCategoryState);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
|
||||
const [categoryToDelete, setCategoryToDelete] = React.useState<CategoryDictionary | null>(null);
|
||||
|
||||
// 加载数据
|
||||
useLayoutEffect(() => {
|
||||
const data = localStorage.getItem('smart_agriculture_category_dictionary');
|
||||
if (data) {
|
||||
try {
|
||||
const categories = JSON.parse(data);
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: categories });
|
||||
} catch (error) {
|
||||
console.error('Failed to parse category dictionary data:', error);
|
||||
loadMockData();
|
||||
}
|
||||
} else {
|
||||
loadMockData();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMockData = () => {
|
||||
localStorage.setItem('smart_agriculture_category_dictionary', JSON.stringify(mockData));
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: mockData });
|
||||
};
|
||||
|
||||
const saveCategories = (categories: CategoryDictionary[]) => {
|
||||
localStorage.setItem('smart_agriculture_category_dictionary', JSON.stringify(categories));
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: categories });
|
||||
};
|
||||
|
||||
// 处理新增
|
||||
const handleAdd = () => {
|
||||
dispatch({ type: 'SET_DIALOG_STATE', payload: { open: true, editing: null } });
|
||||
};
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = (category: CategoryDictionary) => {
|
||||
dispatch({ type: 'SET_DIALOG_STATE', payload: { open: true, editing: category } });
|
||||
};
|
||||
|
||||
// 处理删除
|
||||
const handleDelete = (id: string) => {
|
||||
const category = state.categories.find(c => c.id === id);
|
||||
if (!category) return;
|
||||
|
||||
if (category.isSystem) {
|
||||
toast.error('系统内置字典不能删除');
|
||||
return;
|
||||
}
|
||||
|
||||
setCategoryToDelete(category);
|
||||
setDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = () => {
|
||||
if (!categoryToDelete) return;
|
||||
|
||||
const updated = state.categories.filter(c => c.id !== categoryToDelete.id);
|
||||
saveCategories(updated);
|
||||
toast.success('删除成功');
|
||||
setCategoryToDelete(null);
|
||||
};
|
||||
|
||||
// 处理保存
|
||||
const handleSave = () => {
|
||||
const { formData, dialogState } = state;
|
||||
|
||||
if (!formData.code.trim() || !formData.name.trim() || !formData.value.trim() || !formData.label.trim()) {
|
||||
toast.error('请填写必填项');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (dialogState.editing) {
|
||||
// 编辑
|
||||
const updated = state.categories.map(category =>
|
||||
category.id === dialogState.editing!.id
|
||||
? {
|
||||
...category,
|
||||
...formData,
|
||||
updatedAt: now,
|
||||
}
|
||||
: category
|
||||
);
|
||||
saveCategories(updated);
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
// 新增
|
||||
const newCategory: CategoryDictionary = {
|
||||
id: `dict-${Date.now()}`,
|
||||
...formData,
|
||||
isSystem: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
saveCategories([...state.categories, newCategory]);
|
||||
toast.success('添加成功');
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_DIALOG_STATE', payload: { open: false, editing: null } });
|
||||
};
|
||||
|
||||
// 处理导出
|
||||
const handleExport = () => {
|
||||
const filteredCategories = state.categories.filter(category => {
|
||||
const matchKeyword = !state.searchKeyword ||
|
||||
category.name.includes(state.searchKeyword) ||
|
||||
category.code.includes(state.searchKeyword) ||
|
||||
category.label.includes(state.searchKeyword) ||
|
||||
category.value.includes(state.searchKeyword);
|
||||
const matchCategory = state.categoryFilter === 'all' || category.category === state.categoryFilter;
|
||||
return matchKeyword && matchCategory;
|
||||
});
|
||||
|
||||
const dataStr = JSON.stringify(filteredCategories, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `category_dictionary_${new Date().getTime()}.json`;
|
||||
link.click();
|
||||
toast.success('导出成功');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800 dark:text-green-600">分类字典</h2>
|
||||
<p className="text-muted-foreground">集中管理系统内所有基础字典项</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleExport}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出
|
||||
</Button>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增字典
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字典列表 */}
|
||||
<CategoryList
|
||||
categories={state.categories}
|
||||
searchKeyword={state.searchKeyword}
|
||||
categoryFilter={state.categoryFilter}
|
||||
onSearchChange={(keyword) => dispatch({ type: 'SET_SEARCH_KEYWORD', payload: keyword })}
|
||||
onCategoryFilterChange={(category) => dispatch({ type: 'SET_CATEGORY_FILTER', payload: category })}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* 编辑表单 */}
|
||||
<CategoryForm
|
||||
open={state.dialogState.open}
|
||||
editing={state.dialogState.editing}
|
||||
formData={state.formData}
|
||||
onFormDataChange={(data) => dispatch({ type: 'SET_FORM_DATA', payload: data })}
|
||||
onOpenChange={(open) => dispatch({ type: 'SET_DIALOG_STATE', payload: { open, editing: null } })}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<DeleteConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
category={categoryToDelete}
|
||||
onOpenChange={setDeleteConfirmOpen}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
src/app/(app)/central-config/system/dictionary/reducer.ts
Normal file
109
src/app/(app)/central-config/system/dictionary/reducer.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { CategoryState, CategoryAction, CategoryFormData } from './types';
|
||||
|
||||
// 初始状态
|
||||
export const initialCategoryState: CategoryState = {
|
||||
categories: [],
|
||||
searchKeyword: '',
|
||||
categoryFilter: 'all',
|
||||
dialogState: {
|
||||
open: false,
|
||||
editing: null,
|
||||
},
|
||||
formData: {
|
||||
code: '',
|
||||
name: '',
|
||||
category: 'other',
|
||||
value: '',
|
||||
label: '',
|
||||
sortOrder: 0,
|
||||
description: '',
|
||||
isActive: true,
|
||||
},
|
||||
};
|
||||
|
||||
// 初始表单数据
|
||||
export const initialFormData: CategoryFormData = {
|
||||
code: '',
|
||||
name: '',
|
||||
category: 'other',
|
||||
value: '',
|
||||
label: '',
|
||||
sortOrder: 0,
|
||||
description: '',
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
// Reducer
|
||||
export function categoryReducer(state: CategoryState, action: CategoryAction): CategoryState {
|
||||
switch (action.type) {
|
||||
case 'SET_CATEGORIES':
|
||||
return {
|
||||
...state,
|
||||
categories: action.payload,
|
||||
};
|
||||
|
||||
case 'ADD_CATEGORY':
|
||||
return {
|
||||
...state,
|
||||
categories: [...state.categories, action.payload],
|
||||
};
|
||||
|
||||
case 'UPDATE_CATEGORY':
|
||||
return {
|
||||
...state,
|
||||
categories: state.categories.map(category =>
|
||||
category.id === action.payload.id
|
||||
? { ...category, ...action.payload.updates, updatedAt: new Date().toISOString() }
|
||||
: category
|
||||
),
|
||||
};
|
||||
|
||||
case 'DELETE_CATEGORY':
|
||||
return {
|
||||
...state,
|
||||
categories: state.categories.filter(category => category.id !== action.payload),
|
||||
};
|
||||
|
||||
case 'SET_SEARCH_KEYWORD':
|
||||
return {
|
||||
...state,
|
||||
searchKeyword: action.payload,
|
||||
};
|
||||
|
||||
case 'SET_CATEGORY_FILTER':
|
||||
return {
|
||||
...state,
|
||||
categoryFilter: action.payload,
|
||||
};
|
||||
|
||||
case 'SET_DIALOG_STATE':
|
||||
return {
|
||||
...state,
|
||||
dialogState: action.payload,
|
||||
formData: action.payload.editing
|
||||
? {
|
||||
code: action.payload.editing.code,
|
||||
name: action.payload.editing.name,
|
||||
category: action.payload.editing.category,
|
||||
value: action.payload.editing.value,
|
||||
label: action.payload.editing.label,
|
||||
sortOrder: action.payload.editing.sortOrder,
|
||||
description: action.payload.editing.description || '',
|
||||
isActive: action.payload.editing.isActive,
|
||||
}
|
||||
: initialFormData,
|
||||
};
|
||||
|
||||
case 'SET_FORM_DATA':
|
||||
return {
|
||||
...state,
|
||||
formData: {
|
||||
...state.formData,
|
||||
...action.payload,
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
67
src/app/(app)/central-config/system/dictionary/types.ts
Normal file
67
src/app/(app)/central-config/system/dictionary/types.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// 分类字典类型定义
|
||||
|
||||
export interface CategoryDictionary {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
category: string; // 字典分类
|
||||
value: string;
|
||||
label: string;
|
||||
sortOrder: number;
|
||||
description?: string;
|
||||
isSystem: boolean; // 是否系统内置
|
||||
isActive: boolean;
|
||||
extendData?: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type DictionaryCategory =
|
||||
| 'gender'
|
||||
| 'status'
|
||||
| 'unit'
|
||||
| 'weather'
|
||||
| 'soil_type'
|
||||
| 'irrigation_method'
|
||||
| 'fertilizer_type'
|
||||
| 'pesticide_type'
|
||||
| 'task_status'
|
||||
| 'task_priority'
|
||||
| 'approval_status'
|
||||
| 'operation_type'
|
||||
| 'other';
|
||||
|
||||
// 分类表单数据
|
||||
export interface CategoryFormData {
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
value: string;
|
||||
label: string;
|
||||
sortOrder: number;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
// 分类操作类型
|
||||
export type CategoryAction =
|
||||
| { type: 'SET_CATEGORIES'; payload: CategoryDictionary[] }
|
||||
| { type: 'ADD_CATEGORY'; payload: CategoryDictionary }
|
||||
| { type: 'UPDATE_CATEGORY'; payload: { id: string; updates: Partial<CategoryDictionary> } }
|
||||
| { type: 'DELETE_CATEGORY'; payload: string }
|
||||
| { type: 'SET_SEARCH_KEYWORD'; payload: string }
|
||||
| { type: 'SET_CATEGORY_FILTER'; payload: string }
|
||||
| { type: 'SET_DIALOG_STATE'; payload: { open: boolean; editing?: CategoryDictionary | null } }
|
||||
| { type: 'SET_FORM_DATA'; payload: Partial<CategoryFormData> };
|
||||
|
||||
// 分类状态
|
||||
export interface CategoryState {
|
||||
categories: CategoryDictionary[];
|
||||
searchKeyword: string;
|
||||
categoryFilter: string;
|
||||
dialogState: {
|
||||
open: boolean;
|
||||
editing?: CategoryDictionary | null;
|
||||
};
|
||||
formData: CategoryFormData;
|
||||
}
|
||||
26
src/app/(app)/central-config/system/page.tsx
Normal file
26
src/app/(app)/central-config/system/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function SystemPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">系统参数</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Link href="/central-config/system/settings" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">系统设置</h3>
|
||||
<p className="text-gray-600 text-sm">配置系统基本参数</p>
|
||||
</Link>
|
||||
<Link href="/central-config/system/category" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">分类字典</h3>
|
||||
<p className="text-gray-600 text-sm">管理分类字典</p>
|
||||
</Link>
|
||||
<Link href="/central-config/system/dictionary" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">数据字典</h3>
|
||||
<p className="text-gray-600 text-sm">管理数据字典</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface CopyrightInfoCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function CopyrightInfoCard({ settings, onSettingsChange }: CopyrightInfoCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">版权信息</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>ICP备案号</Label>
|
||||
<Input
|
||||
value={settings.icp || ''}
|
||||
onChange={(e) => onSettingsChange({ icp: e.target.value })}
|
||||
placeholder="京ICP备12345678号"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>版权声明</Label>
|
||||
<Input
|
||||
value={settings.copyright || ''}
|
||||
onChange={(e) => onSettingsChange({ copyright: e.target.value })}
|
||||
placeholder="© 2024 公司名称 版权所有"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface FeatureToggleCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function FeatureToggleCard({ settings, onSettingsChange }: FeatureToggleCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">功能开关</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>允许用户注册</Label>
|
||||
<p className="text-sm text-muted-foreground">开启后允许新用户自主注册账号</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.enableRegistration}
|
||||
onCheckedChange={(checked) => onSettingsChange({ enableRegistration: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>允许访客访问</Label>
|
||||
<p className="text-sm text-muted-foreground">开启后允许未登录用户访问部分公开内容</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.enableGuestAccess}
|
||||
onCheckedChange={(checked) => onSettingsChange({ enableGuestAccess: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface PasswordPolicyCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function PasswordPolicyCard({ settings, onSettingsChange }: PasswordPolicyCardProps) {
|
||||
const updatePasswordPolicy = (updates: Partial<SystemSettings['passwordPolicy']>) => {
|
||||
onSettingsChange({
|
||||
passwordPolicy: { ...settings.passwordPolicy, ...updates }
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">密码策略</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>最小密码长度</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.passwordPolicy.minLength}
|
||||
onChange={(e) => updatePasswordPolicy({ minLength: parseInt(e.target.value) || 8 })}
|
||||
min={6}
|
||||
max={32}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label>密码复杂度要求</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">要求包含大写字母</span>
|
||||
<Switch
|
||||
checked={settings.passwordPolicy.requireUppercase}
|
||||
onCheckedChange={(checked) => updatePasswordPolicy({ requireUppercase: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">要求包含小写字母</span>
|
||||
<Switch
|
||||
checked={settings.passwordPolicy.requireLowercase}
|
||||
onCheckedChange={(checked) => updatePasswordPolicy({ requireLowercase: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">要求包含数字</span>
|
||||
<Switch
|
||||
checked={settings.passwordPolicy.requireNumbers}
|
||||
onCheckedChange={(checked) => updatePasswordPolicy({ requireNumbers: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">要求包含特殊字符</span>
|
||||
<Switch
|
||||
checked={settings.passwordPolicy.requireSpecialChars}
|
||||
onCheckedChange={(checked) => updatePasswordPolicy({ requireSpecialChars: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface PlatformInfoCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function PlatformInfoCard({ settings, onSettingsChange }: PlatformInfoCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">平台信息</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>平台名称 *</Label>
|
||||
<Input
|
||||
value={settings.platformName}
|
||||
onChange={(e) => onSettingsChange({ platformName: e.target.value })}
|
||||
placeholder="请输入平台名称"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>公司名称</Label>
|
||||
<Input
|
||||
value={settings.companyName || ''}
|
||||
onChange={(e) => onSettingsChange({ companyName: e.target.value })}
|
||||
placeholder="请输入公司名称"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>联系邮箱</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={settings.contactEmail || ''}
|
||||
onChange={(e) => onSettingsChange({ contactEmail: e.target.value })}
|
||||
placeholder="support@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>联系电话</Label>
|
||||
<Input
|
||||
value={settings.contactPhone || ''}
|
||||
onChange={(e) => onSettingsChange({ contactPhone: e.target.value })}
|
||||
placeholder="400-888-8888"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label>公司地址</Label>
|
||||
<Input
|
||||
value={settings.address || ''}
|
||||
onChange={(e) => onSettingsChange({ address: e.target.value })}
|
||||
placeholder="请输入公司地址"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface RegionalSettingsCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function RegionalSettingsCard({ settings, onSettingsChange }: RegionalSettingsCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">区域与语言</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>语言</Label>
|
||||
<Select
|
||||
value={settings.language}
|
||||
onValueChange={(value) => onSettingsChange({ language: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zh-CN">简体中文</SelectItem>
|
||||
<SelectItem value="zh-TW">繁體中文</SelectItem>
|
||||
<SelectItem value="en-US">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>时区</Label>
|
||||
<Select
|
||||
value={settings.timezone}
|
||||
onValueChange={(value) => onSettingsChange({ timezone: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Asia/Shanghai">中国标准时间 (UTC+8)</SelectItem>
|
||||
<SelectItem value="Asia/Tokyo">日本标准时间 (UTC+9)</SelectItem>
|
||||
<SelectItem value="America/New_York">美国东部时间 (UTC-5)</SelectItem>
|
||||
<SelectItem value="Europe/London">格林威治标准时间 (UTC+0)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>日期格式</Label>
|
||||
<Select
|
||||
value={settings.dateFormat}
|
||||
onValueChange={(value) => onSettingsChange({ dateFormat: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="YYYY-MM-DD">2024-10-14</SelectItem>
|
||||
<SelectItem value="DD/MM/YYYY">14/10/2024</SelectItem>
|
||||
<SelectItem value="MM/DD/YYYY">10/14/2024</SelectItem>
|
||||
<SelectItem value="YYYY年MM月DD日">2024年10月14日</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface SessionManagementCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function SessionManagementCard({ settings, onSettingsChange }: SessionManagementCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">会话管理</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>会话超时时间(分钟)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.sessionTimeout}
|
||||
onChange={(e) => onSettingsChange({ sessionTimeout: parseInt(e.target.value) || 30 })}
|
||||
min={5}
|
||||
max={1440}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
用户无操作超过此时间后将自动退出登录
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>最大登录尝试次数</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.maxLoginAttempts}
|
||||
onChange={(e) => onSettingsChange({ maxLoginAttempts: parseInt(e.target.value) || 5 })}
|
||||
min={3}
|
||||
max={10}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
连续登录失败达到此次数后账号将被临时锁定
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Settings } from 'lucide-react'
|
||||
|
||||
export function SettingsInfoCard() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="text-blue-900 mb-2">
|
||||
<Settings className="w-4 h-4 inline mr-2" />
|
||||
设置说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800">
|
||||
<li>• 平台名称和Logo将显示在系统导航栏和登录页面</li>
|
||||
<li>• 系统公告会在登录后首页显著位置展示</li>
|
||||
<li>• 密码策略设置将在用户创建或修改密码时生效</li>
|
||||
<li>• 会话超时设置可提高系统安全性,防止账号被盗用</li>
|
||||
<li>• 修改设置后需要点击"保存设置"按钮才会生效</li>
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface SystemAnnouncementCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function SystemAnnouncementCard({ settings, onSettingsChange }: SystemAnnouncementCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">系统公告</h3>
|
||||
<Textarea
|
||||
value={settings.systemAnnouncement || ''}
|
||||
onChange={(e) => onSettingsChange({ systemAnnouncement: e.target.value })}
|
||||
placeholder="输入系统公告内容,将显示在登录页面"
|
||||
rows={4}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { PlatformInfoCard } from './PlatformInfoCard'
|
||||
export { SystemAnnouncementCard } from './SystemAnnouncementCard'
|
||||
export { CopyrightInfoCard } from './CopyrightInfoCard'
|
||||
export { FeatureToggleCard } from './FeatureToggleCard'
|
||||
export { SessionManagementCard } from './SessionManagementCard'
|
||||
export { PasswordPolicyCard } from './PasswordPolicyCard'
|
||||
export { RegionalSettingsCard } from './RegionalSettingsCard'
|
||||
export { SettingsInfoCard } from './SettingsInfoCard'
|
||||
168
src/app/(app)/central-config/system/settings/page.tsx
Normal file
168
src/app/(app)/central-config/system/settings/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
import { Save, RefreshCw, Info, Shield, Globe } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// Import modular components
|
||||
import {
|
||||
PlatformInfoCard,
|
||||
SystemAnnouncementCard,
|
||||
CopyrightInfoCard,
|
||||
FeatureToggleCard,
|
||||
SessionManagementCard,
|
||||
PasswordPolicyCard,
|
||||
RegionalSettingsCard,
|
||||
SettingsInfoCard
|
||||
} from './components'
|
||||
|
||||
export default function SystemSettingsPage() {
|
||||
const [settings, setSettings] = useState<SystemSettings>({
|
||||
platformName: '智慧农业生产管理系统',
|
||||
platformLogo: '',
|
||||
systemAnnouncement: '欢迎使用智慧农业生产管理系统!',
|
||||
contactEmail: 'support@smart-agriculture.com',
|
||||
contactPhone: '400-888-8888',
|
||||
address: '北京市海淀区中关村大街1号',
|
||||
companyName: '智慧农业科技有限公司',
|
||||
icp: '京ICP备12345678号',
|
||||
copyright: '© 2024 智慧农业科技有限公司 版权所有',
|
||||
enableRegistration: true,
|
||||
enableGuestAccess: false,
|
||||
sessionTimeout: 30,
|
||||
maxLoginAttempts: 5,
|
||||
passwordPolicy: {
|
||||
minLength: 8,
|
||||
requireUppercase: true,
|
||||
requireLowercase: true,
|
||||
requireNumbers: true,
|
||||
requireSpecialChars: false,
|
||||
},
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
timezone: 'Asia/Shanghai',
|
||||
language: 'zh-CN',
|
||||
})
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [])
|
||||
|
||||
const loadSettings = () => {
|
||||
const data = localStorage.getItem('smart_agriculture_system_settings')
|
||||
if (data) {
|
||||
setSettings(JSON.parse(data))
|
||||
} else {
|
||||
saveSettings(settings)
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = (newSettings: SystemSettings) => {
|
||||
localStorage.setItem('smart_agriculture_system_settings', JSON.stringify(newSettings))
|
||||
setSettings(newSettings)
|
||||
setHasChanges(false)
|
||||
toast.success('系统设置已保存')
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
saveSettings(settings)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
loadSettings()
|
||||
setHasChanges(false)
|
||||
toast.info('已恢复到上次保存的设置')
|
||||
}
|
||||
|
||||
const updateSettings = (updates: Partial<SystemSettings>) => {
|
||||
setSettings({ ...settings, ...updates })
|
||||
setHasChanges(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800">系统设置</h2>
|
||||
<p className="text-muted-foreground">配置系统全局基础参数和个性化设置</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{hasChanges && (
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
重置
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSave} disabled={!hasChanges}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="basic" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="basic">
|
||||
<Info className="w-4 h-4 mr-2" />
|
||||
基本设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security">
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
安全设置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="regional">
|
||||
<Globe className="w-4 h-4 mr-2" />
|
||||
区域设置
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 基本设置 */}
|
||||
<TabsContent value="basic" className="space-y-4">
|
||||
<PlatformInfoCard
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
/>
|
||||
<SystemAnnouncementCard
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
/>
|
||||
<CopyrightInfoCard
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
/>
|
||||
<FeatureToggleCard
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 安全设置 */}
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
<SessionManagementCard
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
/>
|
||||
<PasswordPolicyCard
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 区域设置 */}
|
||||
<TabsContent value="regional" className="space-y-4">
|
||||
<RegionalSettingsCard
|
||||
settings={settings}
|
||||
onSettingsChange={updateSettings}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 设置预览 */}
|
||||
<SettingsInfoCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
666
src/app/(app)/central-config/tenant/audit-history/page.tsx
Normal file
666
src/app/(app)/central-config/tenant/audit-history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
525
src/app/(app)/central-config/tenant/audit-history/page.tsx.tmp
Normal file
525
src/app/(app)/central-config/tenant/audit-history/page.tsx.tmp
Normal 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>
|
||||
107
src/app/(app)/central-config/tenant/audit-history/types.ts
Normal file
107
src/app/(app)/central-config/tenant/audit-history/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user