Compare commits
2 Commits
5c783c73e1
...
9afc680833
| Author | SHA1 | Date | |
|---|---|---|---|
| 9afc680833 | |||
| 4a5d278d89 |
435
crop-x/package-lock.json
generated
435
crop-x/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,37 +18,38 @@
|
|||||||
"scripts:disable": "node scripts/setup-dev-tools.js --disable"
|
"scripts:disable": "node scripts/setup-dev-tools.js --disable"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
"@radix-ui/react-avatar": "^1.1.3",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.6",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.6",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-menubar": "^1.1.6",
|
"@radix-ui/react-menubar": "^1.1.16",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-popover": "^1.1.6",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.2",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-radio-group": "^1.2.3",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slider": "^1.2.3",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.1.3",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.3",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toggle": "^1.1.2",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/postcss": "^4.1.14",
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "*",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.487.0",
|
"lucide-react": "^0.487.0",
|
||||||
@@ -56,15 +57,16 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"qrcode": "*",
|
"qrcode": "*",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "^7.65.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.9",
|
||||||
"recharts": "^2.15.2",
|
"recharts": "^2.15.4",
|
||||||
"sonner": "^2.0.3",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^1.1.2"
|
"vaul": "^1.1.2",
|
||||||
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
|||||||
143
crop-x/src/app/(app)/central-config/layout.tsx
Normal file
143
crop-x/src/app/(app)/central-config/layout.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import SideBar from '@/components/layouts/SideBar/SideBar'
|
||||||
|
|
||||||
|
// 中心配置路由数据
|
||||||
|
const centralConfigData = {
|
||||||
|
versions: ["1.0.0", "2.0.0"],
|
||||||
|
navMain: [
|
||||||
|
{
|
||||||
|
title: "租户管理",
|
||||||
|
url: "/central-config/tenant",
|
||||||
|
icon: "🏢",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "企业审核",
|
||||||
|
url: "/central-config/tenant/enterprise-audit",
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "审核历史",
|
||||||
|
url: "/central-config/tenant/audit-history",
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "企业信息",
|
||||||
|
url: "/central-config/tenant/enterprise-info",
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "用户管理",
|
||||||
|
url: "/central-config/tenant/user-management",
|
||||||
|
isActive: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "用户管理",
|
||||||
|
url: "/central-config/user",
|
||||||
|
icon: "👥",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "员工管理",
|
||||||
|
url: "/central-config/user/employee",
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "角色管理",
|
||||||
|
url: "/central-config/user/role",
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "菜单管理",
|
||||||
|
url: "/central-config/user/menu",
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "权限配置管理",
|
||||||
|
url: "/central-config/user/permission",
|
||||||
|
isActive: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "系统参数",
|
||||||
|
url: "/central-config/system",
|
||||||
|
icon: "🔧",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "系统设置",
|
||||||
|
url: "/central-config/system/settings",
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "分类字典",
|
||||||
|
url: "/central-config/system/category",
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "数据字典",
|
||||||
|
url: "/central-config/system/dictionary",
|
||||||
|
isActive: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "系统监控",
|
||||||
|
url: "/central-config/monitor",
|
||||||
|
icon: "📈",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "登录日志",
|
||||||
|
url: "/central-config/monitor/login-log",
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作日志",
|
||||||
|
url: "/central-config/monitor/operation-log",
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "性能监控",
|
||||||
|
url: "/central-config/monitor/performance",
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "网络日志",
|
||||||
|
url: "/central-config/monitor/network-log",
|
||||||
|
isActive: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "消息中心",
|
||||||
|
url: "/central-config/message",
|
||||||
|
icon: "📨",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "消息发送",
|
||||||
|
url: "/central-config/message/send",
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "消息模版",
|
||||||
|
url: "/central-config/message/template",
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "消息日志",
|
||||||
|
url: "/central-config/message/log",
|
||||||
|
isActive: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CentralConfigLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return <SideBar data={centralConfigData}>{children}</SideBar>
|
||||||
|
}
|
||||||
@@ -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
crop-x/src/app/(app)/central-config/message/log/mock/mockData.ts
Normal file
115
crop-x/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
crop-x/src/app/(app)/central-config/message/log/page.tsx
Normal file
139
crop-x/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
crop-x/src/app/(app)/central-config/message/page.tsx
Normal file
26
crop-x/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
crop-x/src/app/(app)/central-config/message/send/page.tsx
Normal file
493
crop-x/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
crop-x/src/app/(app)/central-config/message/send/types.ts
Normal file
12
crop-x/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 bg-gray-100 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
crop-x/src/app/(app)/central-config/message/template/page.tsx
Normal file
269
crop-x/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,59 @@
|
|||||||
|
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 LoginLogFiltersProps {
|
||||||
|
searchKeyword: string
|
||||||
|
onSearchChange: (value: string) => void
|
||||||
|
statusFilter: string
|
||||||
|
onStatusFilterChange: (value: string) => void
|
||||||
|
dateRange: string
|
||||||
|
onDateRangeChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginLogFilters({
|
||||||
|
searchKeyword,
|
||||||
|
onSearchChange,
|
||||||
|
statusFilter,
|
||||||
|
onStatusFilterChange,
|
||||||
|
dateRange,
|
||||||
|
onDateRangeChange
|
||||||
|
}: LoginLogFiltersProps) {
|
||||||
|
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="搜索用户名、IP地址、位置..."
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="登录状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="success">成功</SelectItem>
|
||||||
|
<SelectItem value="failed">失败</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={dateRange} onValueChange={onDateRangeChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="时间范围" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部时间</SelectItem>
|
||||||
|
<SelectItem value="today">今天</SelectItem>
|
||||||
|
<SelectItem value="week">近7天</SelectItem>
|
||||||
|
<SelectItem value="month">近30天</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Shield } from 'lucide-react'
|
||||||
|
|
||||||
|
export function LoginLogInfo() {
|
||||||
|
return (
|
||||||
|
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||||
|
<h4 className="text-blue-900 mb-2">
|
||||||
|
<Shield className="w-4 h-4 inline mr-2" />
|
||||||
|
登录日志说明
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1 text-sm text-blue-800">
|
||||||
|
<li>• 记录所有用户的登录行为,包括成功和失败的登录尝试</li>
|
||||||
|
<li>• 支持按用户名、IP地址、位置等多条件检索</li>
|
||||||
|
<li>• 登录失败会记录失败原因,便于安全审计</li>
|
||||||
|
<li>• 可用于识别异常登录行为和未授权访问尝试</li>
|
||||||
|
<li>• 日志数据可导出用于长期归档和合规审计</li>
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { LoginLog } from '@/types/monitor'
|
||||||
|
|
||||||
|
interface LoginLogStatsProps {
|
||||||
|
logs: LoginLog[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginLogStats({ logs }: LoginLogStatsProps) {
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
label: '总登录次数',
|
||||||
|
value: logs.length,
|
||||||
|
color: 'text-blue-600',
|
||||||
|
bg: 'bg-blue-100',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '成功登录',
|
||||||
|
value: logs.filter(l => l.status === 'success').length,
|
||||||
|
color: 'text-green-600',
|
||||||
|
bg: 'bg-green-100',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '失败登录',
|
||||||
|
value: logs.filter(l => l.status === 'failed').length,
|
||||||
|
color: 'text-red-600',
|
||||||
|
bg: 'bg-red-100',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '今日登录',
|
||||||
|
value: logs.filter(l => {
|
||||||
|
const logDate = new Date(l.loginTime)
|
||||||
|
const today = new Date()
|
||||||
|
return logDate.toDateString() === today.toDateString()
|
||||||
|
}).length,
|
||||||
|
color: 'text-purple-600',
|
||||||
|
bg: 'bg-purple-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}`}>{stat.value}</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { LoginLog } from '@/types/monitor'
|
||||||
|
import { Shield, Monitor, MapPin } from 'lucide-react'
|
||||||
|
|
||||||
|
interface LoginLogTableProps {
|
||||||
|
logs: LoginLog[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginLogTable({ logs }: LoginLogTableProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>用户名</TableHead>
|
||||||
|
<TableHead>登录时间</TableHead>
|
||||||
|
<TableHead>IP地址</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) => (
|
||||||
|
<TableRow key={log.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-gray-400" />
|
||||||
|
{log.username}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{new Date(log.loginTime).toLocaleString('zh-CN')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||||
|
{log.ipAddress}
|
||||||
|
</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Monitor className="w-4 h-4 text-gray-400" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<div>{log.device}</div>
|
||||||
|
{log.browser && (
|
||||||
|
<div className="text-xs text-muted-foreground">{log.browser}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{log.location && (
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<MapPin className="w-3 h-3 text-gray-400" />
|
||||||
|
{log.location}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{log.status === 'success' ? (
|
||||||
|
<Badge className="bg-green-100 text-green-700">成功</Badge>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Badge className="bg-red-100 text-red-700">失败</Badge>
|
||||||
|
{log.failReason && (
|
||||||
|
<p className="text-xs text-red-600 mt-1">{log.failReason}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { LoginLogStats } from './LoginLogStats'
|
||||||
|
export { LoginLogFilters } from './LoginLogFilters'
|
||||||
|
export { LoginLogTable } from './LoginLogTable'
|
||||||
|
export { LoginLogInfo } from './LoginLogInfo'
|
||||||
202
crop-x/src/app/(app)/central-config/monitor/login-log/page.tsx
Normal file
202
crop-x/src/app/(app)/central-config/monitor/login-log/page.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { LoginLog } from '@/types/monitor'
|
||||||
|
import { Download } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
// Import modular components
|
||||||
|
import {
|
||||||
|
LoginLogStats,
|
||||||
|
LoginLogFilters,
|
||||||
|
LoginLogTable,
|
||||||
|
LoginLogInfo
|
||||||
|
} from './components'
|
||||||
|
|
||||||
|
export default function LoginLogPage() {
|
||||||
|
const [logs, setLogs] = useState<LoginLog[]>([])
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [dateRange, setDateRange] = useState<string>('all')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLogs()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadLogs = () => {
|
||||||
|
// 强制重新加载mock数据以解决显示问题
|
||||||
|
localStorage.removeItem('smart_agriculture_login_logs')
|
||||||
|
|
||||||
|
const mockLogs: LoginLog[] = [
|
||||||
|
{
|
||||||
|
id: 'log-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
username: 'admin',
|
||||||
|
loginTime: '2024-10-21T09:30:00',
|
||||||
|
ipAddress: '192.168.1.100',
|
||||||
|
device: 'Windows 11',
|
||||||
|
browser: 'Chrome 118',
|
||||||
|
os: 'Windows',
|
||||||
|
location: '北京市海淀区',
|
||||||
|
status: 'success',
|
||||||
|
sessionId: 'sess-001',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-2',
|
||||||
|
userId: 'user-2',
|
||||||
|
username: 'zhangsan',
|
||||||
|
loginTime: '2024-10-21T10:15:00',
|
||||||
|
ipAddress: '192.168.1.101',
|
||||||
|
device: 'macOS 14',
|
||||||
|
browser: 'Safari 17',
|
||||||
|
os: 'macOS',
|
||||||
|
location: '上海市浦东新区',
|
||||||
|
status: 'success',
|
||||||
|
sessionId: 'sess-002',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-3',
|
||||||
|
userId: 'user-3',
|
||||||
|
username: 'lisi',
|
||||||
|
loginTime: '2024-10-21T11:20:00',
|
||||||
|
ipAddress: '192.168.1.102',
|
||||||
|
device: 'Android 13',
|
||||||
|
browser: 'Chrome Mobile 118',
|
||||||
|
os: 'Android',
|
||||||
|
location: '广州市天河区',
|
||||||
|
status: 'failed',
|
||||||
|
failReason: '密码错误',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-4',
|
||||||
|
userId: 'user-1',
|
||||||
|
username: 'admin',
|
||||||
|
loginTime: '2024-10-21T14:45:00',
|
||||||
|
ipAddress: '192.168.1.100',
|
||||||
|
device: 'Windows 11',
|
||||||
|
browser: 'Chrome 118',
|
||||||
|
os: 'Windows',
|
||||||
|
location: '北京市海淀区',
|
||||||
|
status: 'success',
|
||||||
|
sessionId: 'sess-003',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-5',
|
||||||
|
userId: 'user-4',
|
||||||
|
username: 'wangwu',
|
||||||
|
loginTime: '2024-10-21T15:30:00',
|
||||||
|
ipAddress: '192.168.1.103',
|
||||||
|
device: 'iOS 17',
|
||||||
|
browser: 'Safari 17',
|
||||||
|
os: 'iOS',
|
||||||
|
location: '深圳市南山区',
|
||||||
|
status: 'failed',
|
||||||
|
failReason: '账号被锁定',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-6',
|
||||||
|
userId: 'user-5',
|
||||||
|
username: 'zhaoliu',
|
||||||
|
loginTime: '2024-10-21T16:20:00',
|
||||||
|
ipAddress: '192.168.1.104',
|
||||||
|
device: 'Windows 10',
|
||||||
|
browser: 'Edge 118',
|
||||||
|
os: 'Windows',
|
||||||
|
location: '杭州市西湖区',
|
||||||
|
status: 'success',
|
||||||
|
sessionId: 'sess-004',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-7',
|
||||||
|
userId: 'user-6',
|
||||||
|
username: 'chenqi',
|
||||||
|
loginTime: '2024-10-21T17:10:00',
|
||||||
|
ipAddress: '192.168.1.105',
|
||||||
|
device: 'Ubuntu 22.04',
|
||||||
|
browser: 'Firefox 119',
|
||||||
|
os: 'Linux',
|
||||||
|
location: '成都市武侯区',
|
||||||
|
status: 'success',
|
||||||
|
sessionId: 'sess-005',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
localStorage.setItem('smart_agriculture_login_logs', JSON.stringify(mockLogs))
|
||||||
|
setLogs(mockLogs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredLogs = logs.filter(log => {
|
||||||
|
const matchKeyword = !searchKeyword ||
|
||||||
|
log.username.includes(searchKeyword) ||
|
||||||
|
log.ipAddress.includes(searchKeyword) ||
|
||||||
|
(log.location && log.location.includes(searchKeyword))
|
||||||
|
|
||||||
|
const matchStatus = statusFilter === 'all' || log.status === statusFilter
|
||||||
|
|
||||||
|
let matchDate = true
|
||||||
|
if (dateRange !== 'all') {
|
||||||
|
const logDate = new Date(log.loginTime)
|
||||||
|
const now = new Date()
|
||||||
|
const diffDays = Math.floor((now.getTime() - logDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
switch (dateRange) {
|
||||||
|
case 'today':
|
||||||
|
matchDate = diffDays === 0
|
||||||
|
break
|
||||||
|
case 'week':
|
||||||
|
matchDate = diffDays <= 7
|
||||||
|
break
|
||||||
|
case 'month':
|
||||||
|
matchDate = diffDays <= 30
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchKeyword && matchStatus && matchDate
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
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 = `login_logs_${new Date().getTime()}.json`
|
||||||
|
link.click()
|
||||||
|
toast.success('导出成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
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}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
导出日志
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<LoginLogStats logs={logs} />
|
||||||
|
|
||||||
|
{/* 搜索和筛选 */}
|
||||||
|
<LoginLogFilters
|
||||||
|
searchKeyword={searchKeyword}
|
||||||
|
onSearchChange={setSearchKeyword}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onStatusFilterChange={setStatusFilter}
|
||||||
|
dateRange={dateRange}
|
||||||
|
onDateRangeChange={setDateRange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 日志列表 */}
|
||||||
|
<LoginLogTable logs={filteredLogs} />
|
||||||
|
|
||||||
|
{/* 使用说明 */}
|
||||||
|
<LoginLogInfo />
|
||||||
|
</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
crop-x/src/app/(app)/central-config/monitor/network-log/page.tsx
Normal file
139
crop-x/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,183 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { OperationLog } from '@/types/monitor'
|
||||||
|
import { FileText } from 'lucide-react'
|
||||||
|
|
||||||
|
interface OperationLogDetailDialogProps {
|
||||||
|
log: OperationLog | null
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OperationLogDetailDialog({
|
||||||
|
log,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
isLoading = false
|
||||||
|
}: OperationLogDetailDialogProps) {
|
||||||
|
const getModuleLabel = (module: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
user: '用户管理',
|
||||||
|
role: '角色管理',
|
||||||
|
permission: '权限管理',
|
||||||
|
machinery: '农机管理',
|
||||||
|
driver: '驾驶员管理',
|
||||||
|
task: '任务管理',
|
||||||
|
system: '系统配置',
|
||||||
|
other: '其他',
|
||||||
|
}
|
||||||
|
return labels[module] || module
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionLabel = (action: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
create: '新增',
|
||||||
|
update: '修改',
|
||||||
|
delete: '删除',
|
||||||
|
view: '查看',
|
||||||
|
export: '导出',
|
||||||
|
import: '导入',
|
||||||
|
login: '登录',
|
||||||
|
logout: '登出',
|
||||||
|
}
|
||||||
|
return labels[action] || action
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionBadge = (action: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
create: 'bg-green-100 text-green-700',
|
||||||
|
update: 'bg-blue-100 text-blue-700',
|
||||||
|
delete: 'bg-red-100 text-red-700',
|
||||||
|
view: 'bg-gray-100 text-gray-700',
|
||||||
|
export: 'bg-purple-100 text-purple-700',
|
||||||
|
import: 'bg-yellow-100 text-yellow-700',
|
||||||
|
}
|
||||||
|
return colors[action] || 'bg-gray-100 text-gray-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<FileText className="w-5 h-5 text-green-600" />
|
||||||
|
操作日志详情
|
||||||
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 6 }).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 font-medium">{log.username}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">操作时间</p>
|
||||||
|
<p className="mt-1">{new Date(log.operationTime).toLocaleString('zh-CN')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">操作模块</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
<Badge variant="outline">{getModuleLabel(log.module)}</Badge>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">操作类型</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
<Badge className={getActionBadge(log.action)}>
|
||||||
|
{getActionLabel(log.action)}
|
||||||
|
</Badge>
|
||||||
|
</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.ipAddress}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">耗时</p>
|
||||||
|
<p className="mt-1">{log.duration ? `${log.duration}ms` : '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">操作描述</p>
|
||||||
|
<p className="mt-1">{log.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{log.requestUrl && (
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
{log.requestMethod} {log.requestUrl}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</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">
|
||||||
|
{(() => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(log.requestParams), null, 2)
|
||||||
|
} catch {
|
||||||
|
return log.requestParams
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log.responseData && (
|
||||||
|
<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.responseData), null, 2)
|
||||||
|
} catch {
|
||||||
|
return log.responseData
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log.errorMessage && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">错误信息</p>
|
||||||
|
<p className="mt-1 text-red-600 bg-red-50 p-3 rounded">{log.errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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 OperationLogFiltersProps {
|
||||||
|
searchKeyword: string
|
||||||
|
onSearchChange: (value: string) => void
|
||||||
|
moduleFilter: string
|
||||||
|
onModuleFilterChange: (value: string) => void
|
||||||
|
actionFilter: string
|
||||||
|
onActionFilterChange: (value: string) => void
|
||||||
|
statusFilter: string
|
||||||
|
onStatusFilterChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OperationLogFilters({
|
||||||
|
searchKeyword,
|
||||||
|
onSearchChange,
|
||||||
|
moduleFilter,
|
||||||
|
onModuleFilterChange,
|
||||||
|
actionFilter,
|
||||||
|
onActionFilterChange,
|
||||||
|
statusFilter,
|
||||||
|
onStatusFilterChange
|
||||||
|
}: OperationLogFiltersProps) {
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 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={moduleFilter} onValueChange={onModuleFilterChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="操作模块" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部模块</SelectItem>
|
||||||
|
<SelectItem value="user">用户管理</SelectItem>
|
||||||
|
<SelectItem value="role">角色管理</SelectItem>
|
||||||
|
<SelectItem value="permission">权限管理</SelectItem>
|
||||||
|
<SelectItem value="machinery">农机管理</SelectItem>
|
||||||
|
<SelectItem value="driver">驾驶员管理</SelectItem>
|
||||||
|
<SelectItem value="task">任务管理</SelectItem>
|
||||||
|
<SelectItem value="system">系统配置</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={actionFilter} onValueChange={onActionFilterChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="操作类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部类型</SelectItem>
|
||||||
|
<SelectItem value="create">新增</SelectItem>
|
||||||
|
<SelectItem value="update">修改</SelectItem>
|
||||||
|
<SelectItem value="delete">删除</SelectItem>
|
||||||
|
<SelectItem value="view">查看</SelectItem>
|
||||||
|
<SelectItem value="export">导出</SelectItem>
|
||||||
|
<SelectItem value="import">导入</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="操作状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="success">成功</SelectItem>
|
||||||
|
<SelectItem value="failed">失败</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Activity } from 'lucide-react'
|
||||||
|
|
||||||
|
export function OperationLogInfo() {
|
||||||
|
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>• 记录用户在系统中的所有关键操作,包括数据修改、流程触发、配置变更等</li>
|
||||||
|
<li>• 详细记录操作人、时间、模块、动作及请求详情</li>
|
||||||
|
<li>• 支持按多个维度进行复杂条件搜索和过滤</li>
|
||||||
|
<li>• 可查看原始请求信息,满足运维审计需求</li>
|
||||||
|
<li>• 失败操作会记录错误信息,便于事故追溯</li>
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { OperationLog } from '@/types/monitor'
|
||||||
|
import { ApiResponse, PaginatedResponse, PaginationParams } from '@/types'
|
||||||
|
|
||||||
|
export interface OperationLogFilters {
|
||||||
|
searchKeyword?: string
|
||||||
|
module?: string
|
||||||
|
action?: string
|
||||||
|
status?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperationLogListParams extends PaginationParams {
|
||||||
|
filters?: OperationLogFilters
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OperationLogService {
|
||||||
|
private static baseUrl = '/api/monitor/operation-logs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取操作日志列表
|
||||||
|
*/
|
||||||
|
static async getOperationLogs(params: OperationLogListParams = {}): Promise<PaginatedResponse<OperationLog>> {
|
||||||
|
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.module && params.filters.module !== 'all') queryParams.append('module', params.filters.module)
|
||||||
|
if (params.filters.action && params.filters.action !== 'all') queryParams.append('action', params.filters.action)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 operation logs:', error)
|
||||||
|
// 降级处理:返回mock数据
|
||||||
|
return this.getMockData(params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取操作日志详情
|
||||||
|
*/
|
||||||
|
static async getOperationLogDetail(id: string): Promise<ApiResponse<OperationLog>> {
|
||||||
|
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 operation log detail:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出操作日志
|
||||||
|
*/
|
||||||
|
static async exportOperationLogs(filters?: OperationLogFilters): Promise<Blob> {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
|
||||||
|
if (filters) {
|
||||||
|
if (filters.searchKeyword) queryParams.append('searchKeyword', filters.searchKeyword)
|
||||||
|
if (filters.module && filters.module !== 'all') queryParams.append('module', filters.module)
|
||||||
|
if (filters.action && filters.action !== 'all') queryParams.append('action', filters.action)
|
||||||
|
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}/export?${queryParams}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to export operation logs:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取操作日志统计信息
|
||||||
|
*/
|
||||||
|
static async getOperationLogStats(filters?: OperationLogFilters): Promise<ApiResponse<{
|
||||||
|
total: number
|
||||||
|
success: number
|
||||||
|
failed: number
|
||||||
|
averageDuration: number
|
||||||
|
moduleStats: Array<{ module: string, count: number }>
|
||||||
|
actionStats: Array<{ action: string, count: number }>
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
|
||||||
|
if (filters) {
|
||||||
|
if (filters.module && filters.module !== 'all') queryParams.append('module', filters.module)
|
||||||
|
if (filters.action && filters.action !== 'all') queryParams.append('action', filters.action)
|
||||||
|
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 operation log stats:', error)
|
||||||
|
// 降级处理:返回mock统计数据
|
||||||
|
return this.getMockStats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock数据 - 用于降级处理
|
||||||
|
*/
|
||||||
|
private static getMockData(params: OperationLogListParams): PaginatedResponse<OperationLog> {
|
||||||
|
const mockLogs: OperationLog[] = [
|
||||||
|
{
|
||||||
|
id: 'op-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
username: 'admin',
|
||||||
|
operationTime: '2024-10-21T09:35:00',
|
||||||
|
module: 'user',
|
||||||
|
action: 'create',
|
||||||
|
description: '创建用户账号: zhangsan',
|
||||||
|
ipAddress: '192.168.1.100',
|
||||||
|
requestUrl: '/api/users',
|
||||||
|
requestMethod: 'POST',
|
||||||
|
requestParams: JSON.stringify({ username: 'zhangsan', name: '张三' }),
|
||||||
|
duration: 150,
|
||||||
|
status: 'success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'op-2',
|
||||||
|
userId: 'user-2',
|
||||||
|
username: 'zhangsan',
|
||||||
|
operationTime: '2024-10-21T10:20:00',
|
||||||
|
module: 'machinery',
|
||||||
|
action: 'update',
|
||||||
|
description: '更新农机信息: 约翰迪尔拖拉机',
|
||||||
|
ipAddress: '192.168.1.101',
|
||||||
|
requestUrl: '/api/machinery/123',
|
||||||
|
requestMethod: 'PUT',
|
||||||
|
duration: 89,
|
||||||
|
status: 'success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'op-3',
|
||||||
|
userId: 'user-3',
|
||||||
|
username: 'lisi',
|
||||||
|
operationTime: '2024-10-21T11:25:00',
|
||||||
|
module: 'role',
|
||||||
|
action: 'delete',
|
||||||
|
description: '删除角色: 临时操作员',
|
||||||
|
ipAddress: '192.168.1.102',
|
||||||
|
requestUrl: '/api/roles/456',
|
||||||
|
requestMethod: 'DELETE',
|
||||||
|
duration: 120,
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: '该角色下仍有关联用户,无法删除',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'op-4',
|
||||||
|
userId: 'user-1',
|
||||||
|
username: 'admin',
|
||||||
|
operationTime: '2024-10-21T14:50:00',
|
||||||
|
module: 'system',
|
||||||
|
action: 'update',
|
||||||
|
description: '修改系统配置: 会话超时时间',
|
||||||
|
ipAddress: '192.168.1.100',
|
||||||
|
requestUrl: '/api/system/settings',
|
||||||
|
requestMethod: 'PUT',
|
||||||
|
duration: 95,
|
||||||
|
status: 'success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'op-5',
|
||||||
|
userId: 'user-2',
|
||||||
|
username: 'zhangsan',
|
||||||
|
operationTime: '2024-10-21T15:35:00',
|
||||||
|
module: 'task',
|
||||||
|
action: 'create',
|
||||||
|
description: '创建作业任务: 小麦播种作业',
|
||||||
|
ipAddress: '192.168.1.101',
|
||||||
|
requestUrl: '/api/tasks',
|
||||||
|
requestMethod: 'POST',
|
||||||
|
duration: 180,
|
||||||
|
status: 'success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'op-6',
|
||||||
|
userId: 'user-1',
|
||||||
|
username: 'admin',
|
||||||
|
operationTime: '2024-10-21T16:15:00',
|
||||||
|
module: 'user',
|
||||||
|
action: 'export',
|
||||||
|
description: '导出用户列表数据',
|
||||||
|
ipAddress: '192.168.1.100',
|
||||||
|
requestUrl: '/api/users/export',
|
||||||
|
requestMethod: 'GET',
|
||||||
|
duration: 1250,
|
||||||
|
status: 'success',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 应用筛选器
|
||||||
|
let filteredLogs = mockLogs.filter(log => {
|
||||||
|
if (params.filters?.searchKeyword) {
|
||||||
|
const keyword = params.filters.searchKeyword.toLowerCase()
|
||||||
|
if (!log.username.toLowerCase().includes(keyword) &&
|
||||||
|
!log.description.toLowerCase().includes(keyword) &&
|
||||||
|
!log.module.toLowerCase().includes(keyword)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.filters?.module && params.filters.module !== 'all') {
|
||||||
|
if (log.module !== params.filters.module) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.filters?.action && params.filters.action !== 'all') {
|
||||||
|
if (log.action !== params.filters.action) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.filters?.status && params.filters.status !== 'all') {
|
||||||
|
if (log.status !== params.filters.status) 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
|
||||||
|
failed: number
|
||||||
|
averageDuration: number
|
||||||
|
moduleStats: Array<{ module: string, count: number }>
|
||||||
|
actionStats: Array<{ action: string, count: number }>
|
||||||
|
}> {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
total: 6,
|
||||||
|
success: 5,
|
||||||
|
failed: 1,
|
||||||
|
averageDuration: 314,
|
||||||
|
moduleStats: [
|
||||||
|
{ module: 'user', count: 2 },
|
||||||
|
{ module: 'machinery', count: 1 },
|
||||||
|
{ module: 'role', count: 1 },
|
||||||
|
{ module: 'system', count: 1 },
|
||||||
|
{ module: 'task', count: 1 }
|
||||||
|
],
|
||||||
|
actionStats: [
|
||||||
|
{ action: 'create', count: 2 },
|
||||||
|
{ action: 'update', count: 2 },
|
||||||
|
{ action: 'delete', count: 1 },
|
||||||
|
{ action: 'export', count: 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { OperationLog } from '@/types/monitor'
|
||||||
|
|
||||||
|
interface OperationLogStatsProps {
|
||||||
|
logs: OperationLog[]
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OperationLogStats({ logs, isLoading = false }: OperationLogStatsProps) {
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
label: '总操作数',
|
||||||
|
value: logs.length,
|
||||||
|
color: 'text-blue-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '成功操作',
|
||||||
|
value: logs.filter(l => l.status === 'success').length,
|
||||||
|
color: 'text-green-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '失败操作',
|
||||||
|
value: logs.filter(l => l.status === 'failed').length,
|
||||||
|
color: 'text-red-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '平均耗时',
|
||||||
|
value: logs.length > 0
|
||||||
|
? Math.round(logs.reduce((sum, l) => sum + (l.duration || 0), 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,144 @@
|
|||||||
|
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 { OperationLog } from '@/types/monitor'
|
||||||
|
import { Eye } from 'lucide-react'
|
||||||
|
|
||||||
|
interface OperationLogTableProps {
|
||||||
|
logs: OperationLog[]
|
||||||
|
isLoading?: boolean
|
||||||
|
onViewDetail: (log: OperationLog) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OperationLogTable({ logs, isLoading = false, onViewDetail }: OperationLogTableProps) {
|
||||||
|
const getModuleLabel = (module: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
user: '用户管理',
|
||||||
|
role: '角色管理',
|
||||||
|
permission: '权限管理',
|
||||||
|
machinery: '农机管理',
|
||||||
|
driver: '驾驶员管理',
|
||||||
|
task: '任务管理',
|
||||||
|
system: '系统配置',
|
||||||
|
other: '其他',
|
||||||
|
}
|
||||||
|
return labels[module] || module
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionLabel = (action: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
create: '新增',
|
||||||
|
update: '修改',
|
||||||
|
delete: '删除',
|
||||||
|
view: '查看',
|
||||||
|
export: '导出',
|
||||||
|
import: '导入',
|
||||||
|
login: '登录',
|
||||||
|
logout: '登出',
|
||||||
|
}
|
||||||
|
return labels[action] || action
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionBadge = (action: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
create: 'bg-green-100 text-green-700',
|
||||||
|
update: 'bg-blue-100 text-blue-700',
|
||||||
|
delete: 'bg-red-100 text-red-700',
|
||||||
|
view: 'bg-gray-100 text-gray-700',
|
||||||
|
export: 'bg-purple-100 text-purple-700',
|
||||||
|
import: 'bg-yellow-100 text-yellow-700',
|
||||||
|
}
|
||||||
|
return colors[action] || 'bg-gray-100 text-gray-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
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-32 rounded"></div>
|
||||||
|
<div className="bg-gray-200 h-4 w-16 rounded"></div>
|
||||||
|
<div className="bg-gray-200 h-4 w-24 rounded"></div>
|
||||||
|
<div className="bg-gray-200 h-4 w-40 rounded"></div>
|
||||||
|
<div className="bg-gray-200 h-4 w-16 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>模块</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>{log.username}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{new Date(log.operationTime).toLocaleString('zh-CN')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{getModuleLabel(log.module)}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={getActionBadge(log.action)}>
|
||||||
|
{getActionLabel(log.action)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-xs truncate" title={log.description}>
|
||||||
|
{log.description}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{log.duration ? `${log.duration}ms` : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{log.status === 'success' ? (
|
||||||
|
<Badge className="bg-green-100 text-green-700">成功</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-red-100 text-red-700">失败</Badge>
|
||||||
|
)}
|
||||||
|
</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,9 @@
|
|||||||
|
export { OperationLogService } from './OperationLogService'
|
||||||
|
export { OperationLogStats } from './OperationLogStats'
|
||||||
|
export { OperationLogFilters } from './OperationLogFilters'
|
||||||
|
export { OperationLogTable } from './OperationLogTable'
|
||||||
|
export { OperationLogDetailDialog } from './OperationLogDetailDialog'
|
||||||
|
export { OperationLogInfo } from './OperationLogInfo'
|
||||||
|
|
||||||
|
export type { OperationLogListParams } from './OperationLogService'
|
||||||
|
export type { OperationLogFilters as OperationLogFiltersType } from './OperationLogService'
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { OperationLog } from '@/types/monitor'
|
||||||
|
import { Download } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
// Import modular components
|
||||||
|
import {
|
||||||
|
OperationLogService,
|
||||||
|
OperationLogStats,
|
||||||
|
OperationLogFilters,
|
||||||
|
OperationLogTable,
|
||||||
|
OperationLogDetailDialog,
|
||||||
|
OperationLogInfo
|
||||||
|
} from './components'
|
||||||
|
|
||||||
|
export default function OperationLogPage() {
|
||||||
|
const [logs, setLogs] = useState<OperationLog[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
|
const [moduleFilter, setModuleFilter] = useState<string>('all')
|
||||||
|
const [actionFilter, setActionFilter] = useState<string>('all')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [showDetailDialog, setShowDetailDialog] = useState(false)
|
||||||
|
const [selectedLog, setSelectedLog] = useState<OperationLog | null>(null)
|
||||||
|
const [isDetailLoading, setIsDetailLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLogs()
|
||||||
|
}, [searchKeyword, moduleFilter, actionFilter, statusFilter])
|
||||||
|
|
||||||
|
const loadLogs = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await OperationLogService.getOperationLogs({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
filters: {
|
||||||
|
searchKeyword,
|
||||||
|
module: moduleFilter,
|
||||||
|
action: actionFilter,
|
||||||
|
status: statusFilter
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setLogs(response.data)
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '加载操作日志失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load operation logs:', error)
|
||||||
|
toast.error('加载操作日志失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewDetail = async (log: OperationLog) => {
|
||||||
|
setSelectedLog(log)
|
||||||
|
setShowDetailDialog(true)
|
||||||
|
|
||||||
|
setIsDetailLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await OperationLogService.getOperationLogDetail(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 OperationLogService.exportOperationLogs({
|
||||||
|
searchKeyword,
|
||||||
|
module: moduleFilter,
|
||||||
|
action: actionFilter,
|
||||||
|
status: statusFilter
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
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 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">操作日志</h2>
|
||||||
|
<p className="text-muted-foreground">详细追踪用户在系统中的关键操作行为</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={handleRefresh} disabled={isLoading}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleExport} disabled={isLoading || logs.length === 0}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
导出日志
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OperationLogStats logs={logs} isLoading={isLoading} />
|
||||||
|
|
||||||
|
<OperationLogFilters
|
||||||
|
searchKeyword={searchKeyword}
|
||||||
|
onSearchChange={setSearchKeyword}
|
||||||
|
moduleFilter={moduleFilter}
|
||||||
|
onModuleFilterChange={setModuleFilter}
|
||||||
|
actionFilter={actionFilter}
|
||||||
|
onActionFilterChange={setActionFilter}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onStatusFilterChange={setStatusFilter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OperationLogTable
|
||||||
|
logs={logs}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onViewDetail={handleViewDetail}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OperationLogDetailDialog
|
||||||
|
log={selectedLog}
|
||||||
|
isOpen={showDetailDialog}
|
||||||
|
onClose={() => setShowDetailDialog(false)}
|
||||||
|
isLoading={isDetailLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OperationLogInfo />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
crop-x/src/app/(app)/central-config/monitor/page.tsx
Normal file
30
crop-x/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
crop-x/src/app/(app)/central-config/monitor/performance/page.tsx
Normal file
274
crop-x/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
crop-x/src/app/(app)/central-config/system/category/page.tsx
Normal file
14
crop-x/src/app/(app)/central-config/system/category/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function CategoryDictionaryPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">分类字典</h1>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<p>分类字典页面 - 路径: /config/system/category</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function DataDictionaryPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">数据字典</h1>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<p>数据字典页面 - 路径: /config/system/dictionary</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
crop-x/src/app/(app)/central-config/system/page.tsx
Normal file
26
crop-x/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
crop-x/src/app/(app)/central-config/system/settings/page.tsx
Normal file
168
crop-x/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,211 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
|
import { AuditHistoryStatsCards } from './components/AuditHistoryStatsCards';
|
||||||
|
import { AuditHistoryFilters } from './components/AuditHistoryFilters';
|
||||||
|
import { AuditHistoryList } from './components/AuditHistoryList';
|
||||||
|
import { AuditHistoryDetailDialog } from './components/AuditHistoryDetailDialog';
|
||||||
|
import { AuditHistoryInstructions } from './components/AuditHistoryInstructions';
|
||||||
|
import { AuditRecord, Enterprise, FilterOptions } from './types';
|
||||||
|
|
||||||
|
export default function AuditHistoryPage() {
|
||||||
|
const [records, setRecords] = useState<AuditRecord[]>([]);
|
||||||
|
const [enterprises, setEnterprises] = useState<Enterprise[]>([]);
|
||||||
|
const [showDetailDialog, setShowDetailDialog] = useState(false);
|
||||||
|
const [selectedRecord, setSelectedRecord] = useState<AuditRecord | null>(null);
|
||||||
|
const [selectedEnterprise, setSelectedEnterprise] = useState<Enterprise | null>(null);
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<FilterOptions>({
|
||||||
|
searchKeyword: '',
|
||||||
|
resultFilter: 'all',
|
||||||
|
typeFilter: 'all',
|
||||||
|
dateRange: 'all'
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEnterprises();
|
||||||
|
loadAuditHistory();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadEnterprises = () => {
|
||||||
|
const data = localStorage.getItem('smart_agriculture_enterprises');
|
||||||
|
if (data) {
|
||||||
|
setEnterprises(JSON.parse(data));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAuditHistory = () => {
|
||||||
|
const data = localStorage.getItem('smart_agriculture_audit_records');
|
||||||
|
if (data) {
|
||||||
|
setRecords(JSON.parse(data));
|
||||||
|
} else {
|
||||||
|
// 初始化审核历史数据
|
||||||
|
const mockRecords: AuditRecord[] = [
|
||||||
|
{
|
||||||
|
id: 'audit-1',
|
||||||
|
enterpriseId: 'ent-2',
|
||||||
|
enterpriseName: '丰收现代农业集团',
|
||||||
|
auditType: 'register',
|
||||||
|
submitTime: '2024-10-05T10:00:00',
|
||||||
|
auditTime: '2024-10-08T14:30:00',
|
||||||
|
auditor: '系统管理员',
|
||||||
|
result: 'approved',
|
||||||
|
remarks: '企业资质完整,审核通过',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-2',
|
||||||
|
enterpriseId: 'ent-3',
|
||||||
|
enterpriseName: '金穗农机服务中心',
|
||||||
|
auditType: 'register',
|
||||||
|
submitTime: '2024-10-06T09:00:00',
|
||||||
|
auditTime: '2024-10-09T16:00:00',
|
||||||
|
auditor: '系统管理员',
|
||||||
|
result: 'rejected',
|
||||||
|
reason: '资质材料不完整,请补充营业执照副本',
|
||||||
|
remarks: '缺少必要的资质证明文件',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-3',
|
||||||
|
enterpriseId: 'ent-1',
|
||||||
|
enterpriseName: '绿野农业科技有限公司',
|
||||||
|
auditType: 'register',
|
||||||
|
submitTime: '2024-10-10T08:00:00',
|
||||||
|
result: 'pending',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-4',
|
||||||
|
enterpriseId: 'ent-2',
|
||||||
|
enterpriseName: '丰收现代农业集团',
|
||||||
|
auditType: 'update',
|
||||||
|
submitTime: '2024-10-12T15:30:00',
|
||||||
|
auditTime: '2024-10-13T10:00:00',
|
||||||
|
auditor: '系统管理员',
|
||||||
|
result: 'approved',
|
||||||
|
remarks: '企业地址变更审核通过',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-5',
|
||||||
|
enterpriseId: 'ent-4',
|
||||||
|
enterpriseName: '智慧农田科技公司',
|
||||||
|
auditType: 'register',
|
||||||
|
submitTime: '2024-09-28T11:00:00',
|
||||||
|
auditTime: '2024-09-30T09:30:00',
|
||||||
|
auditor: '系统管理员',
|
||||||
|
result: 'approved',
|
||||||
|
remarks: '优质企业,快速审核通过',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit-6',
|
||||||
|
enterpriseId: 'ent-5',
|
||||||
|
enterpriseName: '农业机械租赁中心',
|
||||||
|
auditType: 'register',
|
||||||
|
submitTime: '2024-10-03T14:20:00',
|
||||||
|
auditTime: '2024-10-05T11:00:00',
|
||||||
|
auditor: '系统管理员',
|
||||||
|
result: 'rejected',
|
||||||
|
reason: '企业经营范围与平台业务不符',
|
||||||
|
remarks: '建议企业完善相关资质后重新申请',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
localStorage.setItem('smart_agriculture_audit_records', JSON.stringify(mockRecords));
|
||||||
|
setRecords(mockRecords);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredRecords = records.filter(record => {
|
||||||
|
const matchKeyword = !filters.searchKeyword ||
|
||||||
|
record.enterpriseName.includes(filters.searchKeyword) ||
|
||||||
|
(record.auditor && record.auditor.includes(filters.searchKeyword));
|
||||||
|
|
||||||
|
const matchResult = filters.resultFilter === 'all' || record.result === filters.resultFilter;
|
||||||
|
const matchType = filters.typeFilter === 'all' || record.auditType === filters.typeFilter;
|
||||||
|
|
||||||
|
// 日期筛选
|
||||||
|
let matchDate = true;
|
||||||
|
if (filters.dateRange !== 'all' && record.auditTime) {
|
||||||
|
const auditDate = new Date(record.auditTime);
|
||||||
|
const now = new Date();
|
||||||
|
const diffDays = Math.floor((now.getTime() - auditDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
switch (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;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleViewDetail = (record: AuditRecord) => {
|
||||||
|
setSelectedRecord(record);
|
||||||
|
// 查找对应的企业信息
|
||||||
|
const enterprise = enterprises.find(e => e.id === record.enterpriseId);
|
||||||
|
setSelectedEnterprise(enterprise || null);
|
||||||
|
setShowDetailDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const dataStr = JSON.stringify(filteredRecords, 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">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-green-800">审核历史</h2>
|
||||||
|
<p className="text-muted-foreground">追溯查询全部企业的历史审核记录</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleExport}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
导出记录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<AuditHistoryStatsCards records={records} />
|
||||||
|
|
||||||
|
{/* 搜索和筛选 */}
|
||||||
|
<AuditHistoryFilters
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={setFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 审核历史列表 */}
|
||||||
|
<AuditHistoryList
|
||||||
|
records={filteredRecords}
|
||||||
|
onViewDetail={handleViewDetail}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 详情对话框 */}
|
||||||
|
<AuditHistoryDetailDialog
|
||||||
|
record={selectedRecord}
|
||||||
|
enterprise={selectedEnterprise}
|
||||||
|
open={showDetailDialog}
|
||||||
|
onOpenChange={setShowDetailDialog}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 使用说明 */}
|
||||||
|
<AuditHistoryInstructions />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// 审核历史相关类型定义
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
// 审核记录
|
||||||
|
export interface AuditRecord {
|
||||||
|
id: string;
|
||||||
|
enterpriseId: string;
|
||||||
|
enterpriseName: string;
|
||||||
|
auditType: 'register' | 'update';
|
||||||
|
submitTime: string;
|
||||||
|
auditTime?: string;
|
||||||
|
auditor?: string;
|
||||||
|
result: AuditStatus;
|
||||||
|
reason?: string;
|
||||||
|
remarks?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
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,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { AuditStats, Enterprise } from '../types';
|
||||||
|
|
||||||
|
interface AuditStatsCardsProps {
|
||||||
|
enterprises: Enterprise[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditStatsCards({ enterprises }: AuditStatsCardsProps) {
|
||||||
|
const stats: AuditStats[] = [
|
||||||
|
{
|
||||||
|
label: '待审核',
|
||||||
|
value: enterprises.filter(e => e.auditStatus === 'pending').length,
|
||||||
|
color: 'text-yellow-600',
|
||||||
|
bg: 'bg-yellow-100',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '已通过',
|
||||||
|
value: enterprises.filter(e => e.auditStatus === 'approved').length,
|
||||||
|
color: 'text-green-600',
|
||||||
|
bg: 'bg-green-100',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '已驳回',
|
||||||
|
value: enterprises.filter(e => e.auditStatus === 'rejected').length,
|
||||||
|
color: 'text-red-600',
|
||||||
|
bg: 'bg-red-100',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '总企业数',
|
||||||
|
value: enterprises.length,
|
||||||
|
color: 'text-blue-600',
|
||||||
|
bg: 'bg-blue-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,318 @@
|
|||||||
|
'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 { Textarea } from '@/components/ui/textarea';
|
||||||
|
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 { Building, FileText, CreditCard, User, CheckCircle, XCircle, Image as ImageIcon } from 'lucide-react';
|
||||||
|
import { Enterprise, AuditStatus } from '../types';
|
||||||
|
|
||||||
|
interface EnterpriseDetailDialogProps {
|
||||||
|
enterprise: Enterprise | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
auditReason: string;
|
||||||
|
onAuditReasonChange: (reason: string) => void;
|
||||||
|
onApprove: () => void;
|
||||||
|
onReject: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnterpriseDetailDialog({
|
||||||
|
enterprise,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
auditReason,
|
||||||
|
onAuditReasonChange,
|
||||||
|
onApprove,
|
||||||
|
onReject
|
||||||
|
}: EnterpriseDetailDialogProps) {
|
||||||
|
const getAuditStatusBadge = (status: AuditStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
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>{status}</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!enterprise) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-5xl max-h-[90vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-between pr-8">
|
||||||
|
<DialogTitle>企业详情审核</DialogTitle>
|
||||||
|
{getAuditStatusBadge(enterprise.auditStatus)}
|
||||||
|
</div>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
查看企业的详细信息和审核状态
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="max-h-[calc(90vh-200px)]">
|
||||||
|
<Tabs defaultValue="basic" className="space-y-4">
|
||||||
|
<TabsList className="grid grid-cols-4 w-full">
|
||||||
|
<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">
|
||||||
|
{new Date(enterprise.createdAt).toLocaleString('zh-CN')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{enterprise.auditTime && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">审核时间</Label>
|
||||||
|
<div className="mt-1.5 text-base">
|
||||||
|
{new Date(enterprise.auditTime).toLocaleString('zh-CN')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{enterprise.auditor && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">审核人</Label>
|
||||||
|
<div className="mt-1.5 text-base">
|
||||||
|
{enterprise.auditor}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{enterprise.auditReason && (
|
||||||
|
<div className="col-span-2 pt-4 mt-2 border-t">
|
||||||
|
<Label className="text-xs">审核意见</Label>
|
||||||
|
<div className="mt-1.5 text-base">
|
||||||
|
{enterprise.auditReason}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 审核操作区 - 仅待审核状态显示 */}
|
||||||
|
{enterprise.auditStatus === 'pending' && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<Label>审核意见</Label>
|
||||||
|
<Textarea
|
||||||
|
value={auditReason}
|
||||||
|
onChange={(e) => onAuditReasonChange(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="请填写审核意见(驳回时必填)..."
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter className="border-t pt-4">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
{enterprise.auditStatus === 'pending' && (
|
||||||
|
<>
|
||||||
|
<Button variant="destructive" onClick={onReject}>
|
||||||
|
<XCircle className="w-4 h-4 mr-2" />
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onApprove}>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'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 { Enterprise, AuditStatus } from '../types';
|
||||||
|
|
||||||
|
interface EnterpriseListProps {
|
||||||
|
enterprises: Enterprise[];
|
||||||
|
onViewDetail: (enterprise: Enterprise) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnterpriseList({ enterprises, onViewDetail }: EnterpriseListProps) {
|
||||||
|
const getAuditStatusBadge = (status: AuditStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
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>{status}</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>企业名称</TableHead>
|
||||||
|
<TableHead>企业类型</TableHead>
|
||||||
|
<TableHead>社会信用代码</TableHead>
|
||||||
|
<TableHead>法人</TableHead>
|
||||||
|
<TableHead>所在地区</TableHead>
|
||||||
|
<TableHead>审核状态</TableHead>
|
||||||
|
<TableHead>提交时间</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{enterprises.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
enterprises.map((enterprise) => (
|
||||||
|
<TableRow key={enterprise.id}>
|
||||||
|
<TableCell className="font-medium">{enterprise.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{enterprise.type}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
<code className="text-xs">{enterprise.socialCreditCode}</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{enterprise.legalPerson || '-'}</TableCell>
|
||||||
|
<TableCell>{`${enterprise.province} ${enterprise.city}`}</TableCell>
|
||||||
|
<TableCell>{getAuditStatusBadge(enterprise.auditStatus)}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{new Date(enterprise.createdAt).toLocaleDateString('zh-CN')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewDetail(enterprise)}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 mr-1" />
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 SearchFiltersProps {
|
||||||
|
searchKeyword: string;
|
||||||
|
setSearchKeyword: (value: string) => void;
|
||||||
|
statusFilter: string;
|
||||||
|
setStatusFilter: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchFilters({
|
||||||
|
searchKeyword,
|
||||||
|
setSearchKeyword,
|
||||||
|
statusFilter,
|
||||||
|
setStatusFilter
|
||||||
|
}: SearchFiltersProps) {
|
||||||
|
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) => setSearchKeyword(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="pending">待审核</SelectItem>
|
||||||
|
<SelectItem value="approved">已通过</SelectItem>
|
||||||
|
<SelectItem value="rejected">已驳回</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { AuditStatsCards } from './components/AuditStatsCards';
|
||||||
|
import { SearchFilters } from './components/SearchFilters';
|
||||||
|
import { EnterpriseList } from './components/EnterpriseList';
|
||||||
|
import { EnterpriseDetailDialog } from './components/EnterpriseDetailDialog';
|
||||||
|
import { Enterprise, AuditStatus } from './types';
|
||||||
|
|
||||||
|
export default function EnterpriseAuditPage() {
|
||||||
|
const [enterprises, setEnterprises] = useState<Enterprise[]>([]);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
const [showDetailDialog, setShowDetailDialog] = useState(false);
|
||||||
|
const [selectedEnterprise, setSelectedEnterprise] = useState<Enterprise | null>(null);
|
||||||
|
const [auditReason, setAuditReason] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEnterprises();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadEnterprises = () => {
|
||||||
|
const data = localStorage.getItem('smart_agriculture_enterprises');
|
||||||
|
if (data) {
|
||||||
|
setEnterprises(JSON.parse(data));
|
||||||
|
} else {
|
||||||
|
// 初始化示例数据
|
||||||
|
const mockEnterprises: Enterprise[] = [
|
||||||
|
{
|
||||||
|
id: 'ent-1',
|
||||||
|
name: '绿野农业科技有限公司',
|
||||||
|
type: '有限责任公司',
|
||||||
|
province: '北京市',
|
||||||
|
city: '海淀区',
|
||||||
|
district: '中关村街道',
|
||||||
|
companySize: '50-200人',
|
||||||
|
registeredCapital: '1000万元',
|
||||||
|
establishmentDate: '2020-03-15',
|
||||||
|
invoiceType: '增值税专用发票',
|
||||||
|
socialCreditCode: '91110000123456789X',
|
||||||
|
businessScope: '农业技术开发、技术咨询、技术服务;销售机械设备、电子产品。',
|
||||||
|
bankAccount: '1234567890123456789',
|
||||||
|
bankName: '中国工商银行',
|
||||||
|
bankFullName: '中国工商银行股份有限公司北京中关村支行',
|
||||||
|
bankAddress: '北京市海淀区中关村大街1号',
|
||||||
|
legalPerson: '张伟',
|
||||||
|
registrant: '张经理',
|
||||||
|
contactPhone: '13800138001',
|
||||||
|
address: '北京市海淀区中关村大街1号科技大厦',
|
||||||
|
status: 'active',
|
||||||
|
auditStatus: 'pending',
|
||||||
|
createdAt: '2024-10-10T08:00:00',
|
||||||
|
updatedAt: '2024-10-10T08:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ent-2',
|
||||||
|
name: '丰收现代农业集团',
|
||||||
|
type: '股份有限公司',
|
||||||
|
province: '江苏省',
|
||||||
|
city: '南京市',
|
||||||
|
district: '江宁区',
|
||||||
|
companySize: '200-500人',
|
||||||
|
registeredCapital: '5000万元',
|
||||||
|
establishmentDate: '2018-06-20',
|
||||||
|
invoiceType: '增值税专用发票',
|
||||||
|
socialCreditCode: '91320000987654321Y',
|
||||||
|
businessScope: '现代农业种植、农产品加工与销售、农业技术推广服务。',
|
||||||
|
bankAccount: '9876543210987654321',
|
||||||
|
bankName: '中国农业银行',
|
||||||
|
bankFullName: '中国农业银行股份有限公司南京江宁支行',
|
||||||
|
bankAddress: '江苏省南京市江宁区农业大道88号',
|
||||||
|
legalPerson: '李明',
|
||||||
|
registrant: '李总',
|
||||||
|
contactPhone: '13900139002',
|
||||||
|
address: '江苏省南京市江宁区农业大道88号',
|
||||||
|
status: 'active',
|
||||||
|
auditStatus: 'approved',
|
||||||
|
auditTime: '2024-10-08T14:30:00',
|
||||||
|
auditor: '系统管理员',
|
||||||
|
createdAt: '2024-10-05T10:00:00',
|
||||||
|
updatedAt: '2024-10-08T14:30:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ent-3',
|
||||||
|
name: '金穗农机服务中心',
|
||||||
|
type: '个人独资企业',
|
||||||
|
province: '山东省',
|
||||||
|
city: '济南市',
|
||||||
|
district: '历城区',
|
||||||
|
companySize: '1-50人',
|
||||||
|
registeredCapital: '200万元',
|
||||||
|
establishmentDate: '2021-09-10',
|
||||||
|
invoiceType: '增值税普通发票',
|
||||||
|
socialCreditCode: '91370000456789012Z',
|
||||||
|
businessScope: '农业机械租赁、维修服务、农机作业服务。',
|
||||||
|
bankAccount: '5555666677778888',
|
||||||
|
bankName: '中国建设银行',
|
||||||
|
bankFullName: '中国建设银行股份有限公司济南历城支行',
|
||||||
|
bankAddress: '山东省济南市历城区农机路66号',
|
||||||
|
legalPerson: '王刚',
|
||||||
|
registrant: '王主任',
|
||||||
|
contactPhone: '13700137003',
|
||||||
|
address: '山东省济南市历城区农机路66号',
|
||||||
|
status: 'inactive',
|
||||||
|
auditStatus: 'rejected',
|
||||||
|
auditReason: '资质材料不完整,请补充营业执照副本和法人身份证复印件',
|
||||||
|
auditTime: '2024-10-09T16:00:00',
|
||||||
|
auditor: '系统管理员',
|
||||||
|
createdAt: '2024-10-06T09:00:00',
|
||||||
|
updatedAt: '2024-10-09T16:00:00',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
localStorage.setItem('smart_agriculture_enterprises', JSON.stringify(mockEnterprises));
|
||||||
|
setEnterprises(mockEnterprises);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredEnterprises = enterprises.filter(ent => {
|
||||||
|
const matchKeyword = !searchKeyword ||
|
||||||
|
ent.name.includes(searchKeyword) ||
|
||||||
|
ent.socialCreditCode.includes(searchKeyword) ||
|
||||||
|
ent.registrant.includes(searchKeyword);
|
||||||
|
|
||||||
|
const matchStatus = statusFilter === 'all' || ent.auditStatus === statusFilter;
|
||||||
|
|
||||||
|
return matchKeyword && matchStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleViewDetail = (enterprise: Enterprise) => {
|
||||||
|
setSelectedEnterprise(enterprise);
|
||||||
|
setAuditReason('');
|
||||||
|
setShowDetailDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApprove = () => {
|
||||||
|
if (!selectedEnterprise) return;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const updated = enterprises.map(ent =>
|
||||||
|
ent.id === selectedEnterprise.id
|
||||||
|
? {
|
||||||
|
...ent,
|
||||||
|
auditStatus: 'approved' as AuditStatus,
|
||||||
|
status: 'active' as const,
|
||||||
|
auditTime: now,
|
||||||
|
auditor: '系统管理员',
|
||||||
|
auditReason: auditReason || undefined,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
: ent
|
||||||
|
);
|
||||||
|
|
||||||
|
// 创建审核历史记录
|
||||||
|
const auditRecords = JSON.parse(localStorage.getItem('smart_agriculture_audit_records') || '[]');
|
||||||
|
const newRecord = {
|
||||||
|
id: `audit-${Date.now()}`,
|
||||||
|
enterpriseId: selectedEnterprise.id,
|
||||||
|
enterpriseName: selectedEnterprise.name,
|
||||||
|
auditType: 'register',
|
||||||
|
submitTime: selectedEnterprise.createdAt,
|
||||||
|
auditTime: now,
|
||||||
|
auditor: '系统管理员',
|
||||||
|
result: 'approved',
|
||||||
|
remarks: auditReason || '审核通过',
|
||||||
|
};
|
||||||
|
auditRecords.push(newRecord);
|
||||||
|
localStorage.setItem('smart_agriculture_audit_records', JSON.stringify(auditRecords));
|
||||||
|
|
||||||
|
setEnterprises(updated);
|
||||||
|
localStorage.setItem('smart_agriculture_enterprises', JSON.stringify(updated));
|
||||||
|
setShowDetailDialog(false);
|
||||||
|
toast.success('审核通过');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = () => {
|
||||||
|
if (!selectedEnterprise) return;
|
||||||
|
if (!auditReason.trim()) {
|
||||||
|
toast.error('请填写驳回原因');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const updated = enterprises.map(ent =>
|
||||||
|
ent.id === selectedEnterprise.id
|
||||||
|
? {
|
||||||
|
...ent,
|
||||||
|
auditStatus: 'rejected' as AuditStatus,
|
||||||
|
status: 'inactive' as const,
|
||||||
|
auditTime: now,
|
||||||
|
auditor: '系统管理员',
|
||||||
|
auditReason: auditReason,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
: ent
|
||||||
|
);
|
||||||
|
|
||||||
|
// 创建审核历史记录
|
||||||
|
const auditRecords = JSON.parse(localStorage.getItem('smart_agriculture_audit_records') || '[]');
|
||||||
|
const newRecord = {
|
||||||
|
id: `audit-${Date.now()}`,
|
||||||
|
enterpriseId: selectedEnterprise.id,
|
||||||
|
enterpriseName: selectedEnterprise.name,
|
||||||
|
auditType: 'register',
|
||||||
|
submitTime: selectedEnterprise.createdAt,
|
||||||
|
auditTime: now,
|
||||||
|
auditor: '系统管理员',
|
||||||
|
result: 'rejected',
|
||||||
|
reason: auditReason,
|
||||||
|
remarks: '审核驳回',
|
||||||
|
};
|
||||||
|
auditRecords.push(newRecord);
|
||||||
|
localStorage.setItem('smart_agriculture_audit_records', JSON.stringify(auditRecords));
|
||||||
|
|
||||||
|
setEnterprises(updated);
|
||||||
|
localStorage.setItem('smart_agriculture_enterprises', JSON.stringify(updated));
|
||||||
|
setShowDetailDialog(false);
|
||||||
|
toast.success('已驳回');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-green-800">企业审核</h2>
|
||||||
|
<p className="text-muted-foreground">管理企业注册与变更审核流程</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<AuditStatsCards enterprises={enterprises} />
|
||||||
|
|
||||||
|
{/* 搜索和筛选 */}
|
||||||
|
<SearchFilters
|
||||||
|
searchKeyword={searchKeyword}
|
||||||
|
setSearchKeyword={setSearchKeyword}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
setStatusFilter={setStatusFilter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 企业列表 */}
|
||||||
|
<EnterpriseList
|
||||||
|
enterprises={filteredEnterprises}
|
||||||
|
onViewDetail={handleViewDetail}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 详情审核对话框 */}
|
||||||
|
<EnterpriseDetailDialog
|
||||||
|
enterprise={selectedEnterprise}
|
||||||
|
open={showDetailDialog}
|
||||||
|
onOpenChange={setShowDetailDialog}
|
||||||
|
auditReason={auditReason}
|
||||||
|
onAuditReasonChange={setAuditReason}
|
||||||
|
onApprove={handleApprove}
|
||||||
|
onReject={handleReject}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// 企业审核相关类型定义
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
// 审核记录
|
||||||
|
export interface AuditRecord {
|
||||||
|
id: string;
|
||||||
|
enterpriseId: string;
|
||||||
|
enterpriseName: string;
|
||||||
|
auditType: 'register' | 'update';
|
||||||
|
submitTime: string;
|
||||||
|
auditTime?: string;
|
||||||
|
auditor?: string;
|
||||||
|
result: AuditStatus;
|
||||||
|
reason?: string;
|
||||||
|
remarks?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
export interface AuditStats {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
bg: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Enterprise, AuditStatus } from '../types';
|
||||||
|
|
||||||
|
interface AuditStatusAlertProps {
|
||||||
|
enterprise: Enterprise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditStatusAlert({ enterprise }: AuditStatusAlertProps) {
|
||||||
|
const getAuditStatusBadge = (status: AuditStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
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>{status}</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`p-4 ${
|
||||||
|
enterprise.auditStatus === 'pending' ? 'bg-yellow-50 border-yellow-200' :
|
||||||
|
enterprise.auditStatus === 'rejected' ? 'bg-red-50 border-red-200' :
|
||||||
|
'bg-green-50 border-green-200'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>当前审核状态:</span>
|
||||||
|
{getAuditStatusBadge(enterprise.auditStatus)}
|
||||||
|
</div>
|
||||||
|
{enterprise.auditStatus === 'rejected' && enterprise.auditReason && (
|
||||||
|
<p className="text-sm text-red-700 mt-2">
|
||||||
|
驳回原因:{enterprise.auditReason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{enterprise.auditStatus === 'pending' && (
|
||||||
|
<p className="text-sm text-yellow-700 mt-2">
|
||||||
|
信息修改后需要管理员重新审核
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{enterprise.auditTime && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
审核时间:{new Date(enterprise.auditTime).toLocaleString('zh-CN')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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