Compare commits

...

26 Commits

Author SHA1 Message Date
7615ca9895 生产管理系统前端 - 1.修改了左侧菜单的icon 2. 顶部菜单的绿色显示 test版本 2025-10-24 14:34:32 +08:00
贺海国
e784e68404 fix: 修复错误的依赖 2025-10-24 14:17:31 +08:00
贺海国
b70922e4d7 fix: 修复错误的依赖 2025-10-24 14:12:59 +08:00
贺海国
9452d748aa fix: 修复错误的依赖 2025-10-24 11:48:41 +08:00
贺海国
55c1248a8b fix: 修复错误的依赖 2025-10-24 11:39:40 +08:00
贺海国
978419fa2c 添加crop-x 的 dockerfile,暂时禁用lint检查和ts检查 2025-10-24 11:34:14 +08:00
a17da68fcd 生产管理系统前端 - 开发数据字典 2025-10-24 08:36:51 +08:00
23e881215d 生产管理系统前端 修复ip地址列暗色下不匹配的问题 2025-10-23 20:00:51 +08:00
19a2025931 生产管理系统前端 - 修复模板编码亮暗色不匹配的问题 2025-10-23 19:59:10 +08:00
3c92cb89f2 生产管理系统前端 - 修复textarea的亮暗色样式问题 2025-10-23 19:52:40 +08:00
dbbdf1f2d7 生产管理系统前端 1开发分类字典 2.适配input框灰色背景 3.适配textarea灰色背景. 2025-10-23 18:04:05 +08:00
d254790901 生产管理系统前端 1.修复了左侧菜单激活样式 2.修复了主题,连带解决h1-h6样式问题 2025-10-23 16:32:50 +08:00
4f3beb2568 生产管理系统前端 菜单箭头显示fix 2025-10-23 15:13:33 +08:00
ce2510d526 生产管理系统前端 - 多滚动条问题解决 2025-10-23 14:08:59 +08:00
f93f9e4d88 生产管理系统前端 暗色切换调试按钮 2025-10-23 11:37:12 +08:00
28229ce795 生产管理系统前端 - 更新瓦力提交的产品原型到参考目录 2025-10-23 10:57:14 +08:00
83523dad64 生产管理系统前端 - 修改navbar导致无法登录的问题 2025-10-23 10:26:26 +08:00
ed642fc9c7 生产管理系统 修复左侧菜单栏布局。并且页面布局全解决 2025-10-23 08:35:22 +08:00
贺海国
8da01a207d 重构: 升级ESLint配置并优化项目结构 │
│                                                                                                                    │
│   - 迁移至ESLint新版配置格式(eslint.config.mjs)                                                                    │
│   - 添加Next.js ESLint配置支持                                                                                     │
│   - 新增样式类型定义文件                                                                                           │
│   - 优化TypeScript和Vite配置                                                                                       │
│   - 更新Tailwind CSS配置                                                                                           │
│                                                                                                                    │
│   🤖 Generated with [Claude Code](https://claude.com/claude-code)                                                  │
│                                                                                                                    │
│   Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 19:02:47 +08:00
9866a86f32 生产管理系统前端-1.修复两处滚动条 2.尽可能缩小菜单收缩,并且删除面包屑 2025-10-22 17:46:12 +08:00
8ea90d980b 生产管理系统前端 - 主页滚动条开发完毕 2025-10-22 16:43:15 +08:00
f1ffcc72fc 生产管理系统 - 评审前可用版提交 2025-10-22 15:42:16 +08:00
04d61ae3b9 生产管理系统 修复手机端代码 2025-10-22 15:20:33 +08:00
7a21043dd8 生产管理系统前端 1.修复系统导航过长的问题 2.利用旧菜单交互 开发菜单与导航 2025-10-22 15:18:36 +08:00
9afc680833 生产管理系统前端 开发中心配置系统 所有页面 2025-10-21 18:04:39 +08:00
4a5d278d89 修复了登录页面还存在顶部导航栏的问题 2025-10-21 09:49:59 +08:00
637 changed files with 173571 additions and 12649 deletions

3
.gitignore vendored
View File

@@ -145,4 +145,5 @@ Thumbs.db
# Temporary folders
tmp/
temp/
temp/
nul

47
Dockerfile.crop-x Normal file
View File

@@ -0,0 +1,47 @@
FROM registry.dev.maimaiag.com/library/node:20-alpine AS base
RUN npm config set registry https://registry.npmmirror.com/
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY crop-x/package.json crop-x/package-lock.json ./
RUN npm ci --registry=https://registry.npmmirror.com/
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY crop-x/ .
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

97
crop-x/.dockerignore Normal file
View File

@@ -0,0 +1,97 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Build outputs
dist
build
.output
.nuxt
.next
.vite
.cache
# Development files
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE files
.vscode
.idea
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git
.gitignore
.gitattributes
# Logs
logs
*.log
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Documentation
README.md
CHANGELOG.md
LICENSE.md
docs
# Config files that shouldn't be in container
.eslintrc*
.prettierrc*
prettier.config.js
.editorconfig
# Testing
jest.config.js
cypress
test
tests
# Misc
.turbo
.vercel
.netlify
# TypeScript
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Package manager lock files (keep package-lock.json but ignore others)
yarn.lock
pnpm-lock.yaml
# Docker
Dockerfile
docker-compose*.yml
.dockerignore

View File

@@ -1,15 +0,0 @@
node_modules
dist
build
*.log
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
coverage
.nyc_output
.cache
.temp
.vscode
.idea

47
crop-x/Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
FROM registry.dev.maimaiag.com/library/node:20-alpine AS base
RUN npm config set registry https://registry.npmmirror.com/
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json ./
RUN npm ci --registry=https://registry.npmmirror.com/
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

25
crop-x/eslint.config.mjs Normal file
View File

@@ -0,0 +1,25 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;

25
crop-x/next.config.js Normal file
View File

@@ -0,0 +1,25 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
typescript: {
ignoreBuildErrors: true, // TODO: 暂时完全禁用TypeScript类型检查
},
eslint: {
ignoreDuringBuilds: true, // TODO: 暂时禁用eslint校验错误
},
transpilePackages: ['lucide-react'],
output: 'standalone',
// 修复CSS构建问题
experimental: {
// forceSwcTransforms: true,
turbo: {
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
},
},
},
};
export default nextConfig;

3493
crop-x/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,51 +4,53 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"next:dev": "next dev --turbopack",
"build": "tsc && vite build",
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --ext ts,tsx --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"type-check": "tsc --noEmit",
"preview": "vite preview",
"scripts:setup": "node scripts/setup-dev-tools.js",
"scripts:enable": "node scripts/setup-dev-tools.js --enable",
"scripts:disable": "node scripts/setup-dev-tools.js --disable"
"scripts:disable": "node scripts/setup-dev-tools.js --disable",
"deploy": "node scripts/deploy.js"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-aspect-ratio": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@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-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-menubar": "^1.1.6",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@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-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/postcss": "^4.1.14",
"axios": "^1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "*",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.487.0",
@@ -56,18 +58,21 @@
"next-themes": "^0.4.6",
"qrcode": "*",
"react": "^19.2.0",
"react-day-picker": "^8.10.1",
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.0",
"react-hook-form": "^7.55.0",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2",
"sonner": "^2.0.3",
"react-hook-form": "^7.65.0",
"react-resizable-panels": "^2.1.9",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2"
"vaul": "^1.1.2",
"zod": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
"@tailwindcss/postcss": "^4",
"@types/node": "^20.10.0",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
@@ -76,6 +81,7 @@
"@vitejs/plugin-react-swc": "^3.10.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.11.1",
"eslint-config-next": "15.5.6",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12",
"husky": "^9.1.6",

View File

@@ -3,4 +3,4 @@ export default {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
}

44
crop-x/scripts/deploy.js Normal file
View File

@@ -0,0 +1,44 @@
import axios from 'axios';
var data = JSON.stringify({
"namespace": "argo",
"template_name": "repo-runtime-workflow",
"parameters": {
"git-schema": "http",
"git-domain": "gitea-service-http.cropflow-dev.svc.cluster.local:3000",
"git-user": "cavin",
"git-repo": "smart-crop-ui",
"git-revision": "main",
"git-pat": "b6c02bf1aec73d7bbbfbe590ea37564a29c4bd5d",
"docker-image-domain": "172.16.102.3:30648",
"docker-dockerfile-path": "./Dockerfile.crop-x",
"resource-cpu-limit": "500m",
"resource-memory-limit": "512Mi",
"resource-gpu-mem-limit": "",
"resource-mount-path": "/data",
"resource-mount-capacity": "",
"app-namespace": "argo",
"app-env-vars": "",
"app-ingress-host": ".dev.maimaiag.com",
"app-container-port": "3000",
"security-scan-enabled": "false"
}
});
var config = {
method: 'post',
url: 'https://gitea-admin-argo-workflow-api-app.dev.maimaiag.com/api/v1/workflows/from-template',
headers: {
'Content-Type': 'application/json'
},
data : data
};
axios(config)
.then(function (response) {
let url = `https://gitea-admin-argo-workflow-api-app.dev.maimaiag.com/api/v1/workflows/${response.data.name}/log`
console.log(`打开 ${url} 查看日志`);
})
.catch(function (error) {
console.log(error);
});

View File

@@ -0,0 +1,7 @@
export default function AssetLabelingLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

View File

@@ -0,0 +1,7 @@
export default function AgriculturalAssetLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

View File

@@ -0,0 +1,7 @@
export default function AgriculturalMachineryLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

View File

@@ -0,0 +1,7 @@
export default function AiCropModelLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

View File

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

View File

@@ -0,0 +1,64 @@
'use client';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search } from 'lucide-react';
interface MessageLogFilterProps {
searchKeyword: string;
typeFilter: string;
statusFilter: string;
onSearchChange: (value: string) => void;
onTypeChange: (value: string) => void;
onStatusChange: (value: string) => void;
}
export function MessageLogFilter({
searchKeyword,
typeFilter,
statusFilter,
onSearchChange,
onTypeChange,
onStatusChange,
}: MessageLogFilterProps) {
return (
<Card className="p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索接收人、内容..."
value={searchKeyword}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<Select value={typeFilter} onValueChange={onTypeChange}>
<SelectTrigger>
<SelectValue placeholder="消息类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="sms"></SelectItem>
<SelectItem value="email"></SelectItem>
<SelectItem value="internal"></SelectItem>
<SelectItem value="push"></SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger>
<SelectValue placeholder="发送状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="sent"></SelectItem>
<SelectItem value="read"></SelectItem>
<SelectItem value="failed"></SelectItem>
<SelectItem value="pending"></SelectItem>
</SelectContent>
</Select>
</div>
</Card>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
'use client';
import React from 'react';
import Link from 'next/link';
export default function MessagePage() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4"></h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Link href="/central-config/message/send" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-gray-600 text-sm"></p>
</Link>
<Link href="/central-config/message/template" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-gray-600 text-sm"></p>
</Link>
<Link href="/central-config/message/log" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-gray-600 text-sm"></p>
</Link>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
'use client';
import { Button } from '@/components/ui/button';
import { Plus, Download } from 'lucide-react';
interface MessageTemplateHeaderProps {
onExport: () => void;
onAdd: () => void;
}
export function MessageTemplateHeader({ onExport, onAdd }: MessageTemplateHeaderProps) {
return (
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800"></h2>
<p className="text-muted-foreground"></p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onExport}>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button onClick={onAdd}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
'use client';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card } from '@/components/ui/card';
import { Search } from 'lucide-react';
interface MessageTemplateSearchProps {
searchKeyword: string;
onSearchChange: (value: string) => void;
typeFilter: string;
onTypeFilterChange: (value: string) => void;
}
export function MessageTemplateSearch({
searchKeyword,
onSearchChange,
typeFilter,
onTypeFilterChange
}: MessageTemplateSearchProps) {
return (
<Card className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索模板名称、编码、内容..."
value={searchKeyword}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<Select value={typeFilter} onValueChange={onTypeFilterChange}>
<SelectTrigger>
<SelectValue placeholder="消息类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="sms"></SelectItem>
<SelectItem value="email"></SelectItem>
<SelectItem value="internal"></SelectItem>
<SelectItem value="push"></SelectItem>
</SelectContent>
</Select>
</div>
</Card>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export { LoginLogStats } from './LoginLogStats'
export { LoginLogFilters } from './LoginLogFilters'
export { LoginLogTable } from './LoginLogTable'
export { LoginLogInfo } from './LoginLogInfo'

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

View File

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

View File

@@ -0,0 +1,62 @@
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Search } from 'lucide-react'
interface NetworkLogFiltersProps {
searchKeyword: string
onSearchChange: (value: string) => void
methodFilter: string
onMethodFilterChange: (value: string) => void
statusFilter: string
onStatusFilterChange: (value: string) => void
}
export function NetworkLogFilters({
searchKeyword,
onSearchChange,
methodFilter,
onMethodFilterChange,
statusFilter,
onStatusFilterChange
}: NetworkLogFiltersProps) {
return (
<Card className="p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索URL、用户名..."
value={searchKeyword}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<Select value={methodFilter} onValueChange={onMethodFilterChange}>
<SelectTrigger>
<SelectValue placeholder="请求方法" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
<SelectTrigger>
<SelectValue placeholder="响应状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="2xx">2xx </SelectItem>
<SelectItem value="4xx">4xx </SelectItem>
<SelectItem value="5xx">5xx </SelectItem>
</SelectContent>
</Select>
</div>
</Card>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
'use client';
import React from 'react';
import Link from 'next/link';
export default function MonitorPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4"></h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Link href="/central-config/monitor/login-log" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-gray-600 text-sm"></p>
</Link>
<Link href="/central-config/monitor/operation-log" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-gray-600 text-sm"></p>
</Link>
<Link href="/central-config/monitor/performance" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-gray-600 text-sm"></p>
</Link>
<Link href="/central-config/monitor/network-log" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-gray-600 text-sm">访</p>
</Link>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
'use client';
import React from 'react';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search } from 'lucide-react';
interface CategoryFiltersProps {
searchKeyword: string;
typeFilter: string;
onSearchChange: (value: string) => void;
onTypeFilterChange: (value: string) => void;
}
export function CategoryFilters({
searchKeyword,
typeFilter,
onSearchChange,
onTypeFilterChange,
}: CategoryFiltersProps) {
return (
<Card className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索分类名称、编码..."
value={searchKeyword}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<Select value={typeFilter} onValueChange={onTypeFilterChange}>
<SelectTrigger>
<SelectValue placeholder="分类类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="industry"></SelectItem>
<SelectItem value="equipment"></SelectItem>
<SelectItem value="crop"></SelectItem>
<SelectItem value="operation"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
</div>
</Card>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { CategoryDictionary, CategoryFormData } from '../types';
interface CategoryFormDialogProps {
open: boolean;
editing?: CategoryDictionary;
parent?: CategoryDictionary | null;
formData: CategoryFormData;
onOpenChange: (open: boolean) => void;
onFormDataChange: (data: Partial<CategoryFormData>) => void;
onSave: () => void;
}
export function CategoryFormDialog({
open,
editing,
parent,
formData,
onOpenChange,
onFormDataChange,
onSave,
}: CategoryFormDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{editing ? '编辑分类' : '新增分类'}
</DialogTitle>
<DialogDescription className="sr-only">
{editing ? '编辑分类信息' : '添加新分类'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{parent && (
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<Label className="text-sm text-blue-900 dark:text-blue-100"></Label>
<p className="mt-1 dark:text-gray-100">{parent.name}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="code"> *</Label>
<Input
id="code"
value={formData.code}
onChange={(e) => onFormDataChange({ code: e.target.value })}
placeholder="IND001"
/>
</div>
<div>
<Label htmlFor="name"> *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => onFormDataChange({ name: e.target.value })}
placeholder="请输入名称"
/>
</div>
</div>
<div>
<Label htmlFor="type"></Label>
<Select
value={formData.type}
onValueChange={(value) => onFormDataChange({ type: value })}
disabled={!!parent}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="industry"></SelectItem>
<SelectItem value="equipment"></SelectItem>
<SelectItem value="crop"></SelectItem>
<SelectItem value="operation"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => onFormDataChange({ description: e.target.value })}
placeholder="请输入描述"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="sortOrder"></Label>
<Input
id="sortOrder"
type="number"
value={formData.sortOrder}
onChange={(e) => onFormDataChange({ sortOrder: parseInt(e.target.value) || 0 })}
/>
</div>
<div className="flex items-center justify-between pt-6">
<Label htmlFor="isActive"></Label>
<Switch
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => onFormDataChange({ isActive: checked })}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={onSave}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

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

View File

@@ -0,0 +1,114 @@
'use client';
import React from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
ChevronRight,
ChevronDown,
Folder,
File,
Plus,
Edit,
Trash2
} from 'lucide-react';
import { CategoryDictionary } from '../types';
interface CategoryTreeProps {
categories: CategoryDictionary[];
expandedIds: Set<string>;
onToggleExpand: (id: string) => void;
onAdd: (parent?: CategoryDictionary) => void;
onEdit: (category: CategoryDictionary) => void;
onDelete: (id: string) => void;
}
export function CategoryTree({
categories,
expandedIds,
onToggleExpand,
onAdd,
onEdit,
onDelete,
}: CategoryTreeProps) {
const renderTree = (nodes: CategoryDictionary[], level: number = 0) => {
return nodes.map(node => (
<div key={node.id} style={{ marginLeft: `${level * 24}px` }}>
<div className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg group">
<div className="flex-1 flex items-center gap-2">
{node.children && node.children.length > 0 ? (
<button
onClick={() => onToggleExpand(node.id)}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
>
{expandedIds.has(node.id) ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
) : (
<div className="w-6" />
)}
{node.children && node.children.length > 0 ? (
<Folder className="w-4 h-4 text-yellow-600 dark:text-yellow-500" />
) : (
<File className="w-4 h-4 text-gray-400 dark:text-gray-500" />
)}
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="dark:text-gray-100">{node.name}</span>
<Badge variant="outline" className="text-xs">{node.code}</Badge>
{!node.isActive && (
<Badge variant="outline" className="text-xs text-red-600 dark:text-red-400"></Badge>
)}
</div>
{node.description && (
<p className="text-xs text-muted-foreground dark:text-gray-400">{node.description}</p>
)}
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={() => onAdd(node)}
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(node)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(node.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{expandedIds.has(node.id) && node.children && renderTree(node.children, level + 1)}
</div>
));
};
return (
<Card className="p-4">
<div className="min-h-[400px]">
{categories.length === 0 ? (
<div className="text-center text-muted-foreground py-12">
</div>
) : (
renderTree(categories)
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,306 @@
'use client';
import React, { useReducer, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Plus } from 'lucide-react';
import { toast } from 'sonner';
import { CategoryDictionary, CategoryAction } from './types';
import { categoryReducer, initialState } from './reducer';
import { CategoryFilters } from './components/CategoryFilters';
import { CategoryTree } from './components/CategoryTree';
import { CategoryFormDialog } from './components/CategoryFormDialog';
import { CategoryInstructions } from './components/CategoryInstructions';
export default function CategoryDictionaryPage() {
const [state, dispatch] = useReducer(categoryReducer, initialState);
// 模拟数据加载
useEffect(() => {
const mockData: CategoryDictionary[] = [
{
id: 'cat-1',
code: 'IND001',
name: '种植业',
type: 'industry',
level: 1,
sortOrder: 1,
description: '农作物种植相关行业',
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'cat-2',
code: 'IND001-01',
name: '粮食作物',
type: 'industry',
parentId: 'cat-1',
level: 2,
sortOrder: 1,
description: '小麦、水稻、玉米等粮食作物',
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'cat-3',
code: 'IND001-02',
name: '经济作物',
type: 'industry',
parentId: 'cat-1',
level: 2,
sortOrder: 2,
description: '棉花、油料、糖料等经济作物',
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'cat-4',
code: 'IND002',
name: '畜牧业',
type: 'industry',
level: 1,
sortOrder: 2,
description: '牲畜饲养相关行业',
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'cat-5',
code: 'EQP001',
name: '动力机械',
type: 'equipment',
level: 1,
sortOrder: 1,
description: '拖拉机等动力设备',
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'cat-6',
code: 'EQP001-01',
name: '轮式拖拉机',
type: 'equipment',
parentId: 'cat-5',
level: 2,
sortOrder: 1,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'cat-7',
code: 'EQP001-02',
name: '履带式拖拉机',
type: 'equipment',
parentId: 'cat-5',
level: 2,
sortOrder: 2,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'cat-8',
code: 'EQP002',
name: '收获机械',
type: 'equipment',
level: 1,
sortOrder: 2,
description: '收割机、采摘机等',
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
];
// 尝试从 localStorage 加载数据
const storedData = localStorage.getItem('smart_agriculture_category_dictionary');
if (storedData) {
try {
const data = JSON.parse(storedData);
dispatch({ type: 'SET_CATEGORIES', payload: data });
} catch (error) {
console.error('Failed to parse stored data:', error);
dispatch({ type: 'SET_CATEGORIES', payload: mockData });
}
} else {
dispatch({ type: 'SET_CATEGORIES', payload: mockData });
}
}, []);
// 保存数据到 localStorage
const saveCategories = (categories: CategoryDictionary[]) => {
localStorage.setItem('smart_agriculture_category_dictionary', JSON.stringify(categories));
dispatch({ type: 'SET_CATEGORIES', payload: categories });
};
// 构建树形结构
const buildTree = (items: CategoryDictionary[]): CategoryDictionary[] => {
const map = new Map<string, CategoryDictionary>();
const roots: CategoryDictionary[] = [];
// 创建映射
items.forEach(item => {
map.set(item.id, { ...item, children: [] });
});
// 构建树
items.forEach(item => {
const node = map.get(item.id)!;
if (item.parentId) {
const parent = map.get(item.parentId);
if (parent) {
parent.children = parent.children || [];
parent.children.push(node);
}
} else {
roots.push(node);
}
});
return roots;
};
// 过滤分类数据
const filteredCategories = state.categories.filter(cat => {
const matchKeyword = !state.searchKeyword ||
cat.name.includes(state.searchKeyword) ||
cat.code.includes(state.searchKeyword);
const matchType = state.typeFilter === 'all' || cat.type === state.typeFilter;
return matchKeyword && matchType;
});
const treeData = buildTree(filteredCategories);
// 处理新增
const handleAdd = (parent?: CategoryDictionary) => {
dispatch({
type: 'SET_DIALOG_STATE',
payload: {
open: true,
editing: undefined,
parent: parent || null,
},
});
};
// 处理编辑
const handleEdit = (category: CategoryDictionary) => {
dispatch({
type: 'SET_DIALOG_STATE',
payload: {
open: true,
editing: category,
parent: undefined,
},
});
};
// 处理删除
const handleDelete = (id: string) => {
// 检查是否有子分类
const hasChildren = state.categories.some(cat => cat.parentId === id);
if (hasChildren) {
toast.error('请先删除子分类');
return;
}
const updated = state.categories.filter(cat => cat.id !== id);
saveCategories(updated);
toast.success('删除成功');
};
// 处理保存
const handleSave = () => {
if (!state.formData.code.trim() || !state.formData.name.trim()) {
toast.error('请填写编码和名称');
return;
}
if (state.dialogState.editing) {
// 编辑
dispatch({
type: 'UPDATE_CATEGORY',
payload: {
id: state.dialogState.editing.id,
updates: state.formData,
},
});
saveCategories(state.categories);
toast.success('更新成功');
} else {
// 新增
const newCategory: CategoryDictionary = {
id: `cat-${Date.now()}`,
...state.formData,
parentId: state.dialogState.parent?.id,
level: state.dialogState.parent ? state.dialogState.parent.level + 1 : 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
dispatch({ type: 'ADD_CATEGORY', payload: newCategory });
saveCategories([...state.categories, newCategory]);
toast.success('添加成功');
}
dispatch({
type: 'SET_DIALOG_STATE',
payload: { open: false, editing: undefined, parent: undefined },
});
};
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-green-800 dark:text-green-600"></h2>
<p className="text-muted-foreground dark:text-gray-400"></p>
</div>
<Button onClick={() => handleAdd()}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
{/* 搜索和筛选 */}
<CategoryFilters
searchKeyword={state.searchKeyword}
typeFilter={state.typeFilter}
onSearchChange={(value) => dispatch({ type: 'SET_SEARCH_KEYWORD', payload: value })}
onTypeFilterChange={(value) => dispatch({ type: 'SET_TYPE_FILTER', payload: value })}
/>
{/* 分类树 */}
<CategoryTree
categories={treeData}
expandedIds={state.expandedIds}
onToggleExpand={(id) => dispatch({ type: 'TOGGLE_EXPAND', payload: id })}
onAdd={handleAdd}
onEdit={handleEdit}
onDelete={handleDelete}
/>
{/* 编辑对话框 */}
<CategoryFormDialog
open={state.dialogState.open}
editing={state.dialogState.editing}
parent={state.dialogState.parent}
formData={state.formData}
onOpenChange={(open) => dispatch({
type: 'SET_DIALOG_STATE',
payload: { open, editing: undefined, parent: undefined },
})}
onFormDataChange={(data) => dispatch({ type: 'SET_FORM_DATA', payload: data })}
onSave={handleSave}
/>
{/* 使用说明 */}
<CategoryInstructions />
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { CategoryDictionary, CategoryAction, CategoryState, CategoryFormData } from './types';
const initialFormData: CategoryFormData = {
code: '',
name: '',
type: 'industry',
description: '',
sortOrder: 0,
isActive: true,
};
export const initialState: CategoryState = {
categories: [],
searchKeyword: '',
typeFilter: 'all',
expandedIds: new Set(),
dialogState: {
open: false,
editing: undefined,
parent: undefined,
},
formData: initialFormData,
};
export function categoryReducer(state: CategoryState, action: CategoryAction): CategoryState {
switch (action.type) {
case 'SET_CATEGORIES':
return { ...state, categories: action.payload };
case 'ADD_CATEGORY':
return { ...state, categories: [...state.categories, action.payload] };
case 'UPDATE_CATEGORY':
return {
...state,
categories: state.categories.map(cat =>
cat.id === action.payload.id
? { ...cat, ...action.payload.updates, updatedAt: new Date().toISOString() }
: cat
),
};
case 'DELETE_CATEGORY':
return {
...state,
categories: state.categories.filter(cat => cat.id !== action.payload),
};
case 'SET_SEARCH_KEYWORD':
return { ...state, searchKeyword: action.payload };
case 'SET_TYPE_FILTER':
return { ...state, typeFilter: action.payload };
case 'TOGGLE_EXPAND':
const newExpanded = new Set(state.expandedIds);
if (newExpanded.has(action.payload)) {
newExpanded.delete(action.payload);
} else {
newExpanded.add(action.payload);
}
return { ...state, expandedIds: newExpanded };
case 'SET_DIALOG_STATE':
return {
...state,
dialogState: action.payload,
formData: action.payload.editing
? {
code: action.payload.editing.code,
name: action.payload.editing.name,
type: action.payload.editing.type,
description: action.payload.editing.description || '',
sortOrder: action.payload.editing.sortOrder,
isActive: action.payload.editing.isActive,
}
: action.payload.parent
? {
...initialFormData,
type: action.payload.parent.type,
}
: initialFormData,
};
case 'SET_FORM_DATA':
return {
...state,
formData: { ...state.formData, ...action.payload },
};
default:
return state;
}
}

View File

@@ -0,0 +1,53 @@
// 分类字典类型定义
export interface CategoryDictionary {
id: string;
code: string;
name: string;
type: string; // 分类类型industry, equipment, crop等
parentId?: string;
level: number;
sortOrder: number;
description?: string;
isActive: boolean;
children?: CategoryDictionary[];
createdAt: string;
updatedAt: string;
}
export type CategoryType = 'industry' | 'equipment' | 'crop' | 'operation' | 'other';
// 分类表单数据
export interface CategoryFormData {
code: string;
name: string;
type: string;
description: string;
sortOrder: number;
isActive: boolean;
}
// 分类操作类型
export type CategoryAction =
| { type: 'SET_CATEGORIES'; payload: CategoryDictionary[] }
| { type: 'ADD_CATEGORY'; payload: CategoryDictionary }
| { type: 'UPDATE_CATEGORY'; payload: { id: string; updates: Partial<CategoryDictionary> } }
| { type: 'DELETE_CATEGORY'; payload: string }
| { type: 'SET_SEARCH_KEYWORD'; payload: string }
| { type: 'SET_TYPE_FILTER'; payload: string }
| { type: 'TOGGLE_EXPAND'; payload: string }
| { type: 'SET_DIALOG_STATE'; payload: { open: boolean; editing?: CategoryDictionary; parent?: CategoryDictionary | null } }
| { type: 'SET_FORM_DATA'; payload: Partial<CategoryFormData> };
// 分类状态
export interface CategoryState {
categories: CategoryDictionary[];
searchKeyword: string;
typeFilter: string;
expandedIds: Set<string>;
dialogState: {
open: boolean;
editing?: CategoryDictionary;
parent?: CategoryDictionary | null;
};
formData: CategoryFormData;
}

View File

@@ -0,0 +1,159 @@
'use client';
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { CategoryFormData, CategoryDictionary } from '../types';
interface CategoryFormProps {
open: boolean;
editing: CategoryDictionary | null;
formData: CategoryFormData;
onFormDataChange: (data: Partial<CategoryFormData>) => void;
onOpenChange: (open: boolean) => void;
onSave: () => void;
}
export function CategoryForm({
open,
editing,
formData,
onFormDataChange,
onOpenChange,
onSave,
}: CategoryFormProps) {
const handleSave = () => {
if (!formData.code.trim() || !formData.name.trim() || !formData.value.trim() || !formData.label.trim()) {
return false;
}
onSave();
return true;
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{editing ? '编辑字典' : '新增字典'}
</DialogTitle>
<DialogDescription className="sr-only">
{editing ? '编辑数据字典' : '添加新数据字典'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={formData.code}
onChange={(e) => onFormDataChange({ code: e.target.value })}
placeholder="GENDER_MALE"
disabled={editing?.isSystem}
/>
</div>
<div>
<Label> *</Label>
<Input
value={formData.name}
onChange={(e) => onFormDataChange({ name: e.target.value })}
placeholder="性别-男"
/>
</div>
</div>
<div>
<Label> *</Label>
<Select
value={formData.category}
onValueChange={(value) => onFormDataChange({ category: value })}
disabled={editing?.isSystem}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gender"></SelectItem>
<SelectItem value="status"></SelectItem>
<SelectItem value="unit"></SelectItem>
<SelectItem value="weather"></SelectItem>
<SelectItem value="soil_type"></SelectItem>
<SelectItem value="irrigation_method"></SelectItem>
<SelectItem value="fertilizer_type"></SelectItem>
<SelectItem value="pesticide_type"></SelectItem>
<SelectItem value="task_status"></SelectItem>
<SelectItem value="task_priority"></SelectItem>
<SelectItem value="approval_status"></SelectItem>
<SelectItem value="operation_type"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={formData.value}
onChange={(e) => onFormDataChange({ value: e.target.value })}
placeholder="male"
disabled={editing?.isSystem}
/>
<p className="text-xs text-muted-foreground mt-1">
使使
</p>
</div>
<div>
<Label> *</Label>
<Input
value={formData.label}
onChange={(e) => onFormDataChange({ label: e.target.value })}
placeholder="男"
/>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
</div>
<div>
<Label></Label>
<Textarea
value={formData.description}
onChange={(e) => onFormDataChange({ description: e.target.value })}
placeholder="请输入描述"
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label></Label>
<Input
type="number"
value={formData.sortOrder}
onChange={(e) => onFormDataChange({ sortOrder: parseInt(e.target.value) || 0 })}
/>
</div>
<div className="flex items-center justify-between pt-6">
<Label></Label>
<Switch
checked={formData.isActive}
onCheckedChange={(checked) => onFormDataChange({ isActive: checked })}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import React from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Search, BookOpen, Edit, Trash2 } from 'lucide-react';
import { CategoryDictionary } from '../types';
interface CategoryListProps {
categories: CategoryDictionary[];
searchKeyword: string;
categoryFilter: string;
onSearchChange: (keyword: string) => void;
onCategoryFilterChange: (category: string) => void;
onEdit: (category: CategoryDictionary) => void;
onDelete: (id: string) => void;
}
export function CategoryList({
categories,
searchKeyword,
categoryFilter,
onSearchChange,
onCategoryFilterChange,
onEdit,
onDelete,
}: CategoryListProps) {
// 过滤字典
const filteredCategories = categories.filter(category => {
const matchKeyword = !searchKeyword ||
category.name.includes(searchKeyword) ||
category.code.includes(searchKeyword) ||
category.label.includes(searchKeyword) ||
category.value.includes(searchKeyword);
const matchCategory = categoryFilter === 'all' || category.category === categoryFilter;
return matchKeyword && matchCategory;
});
// 按分类分组
const groupedCategories = filteredCategories.reduce((acc, category) => {
if (!acc[category.category]) {
acc[category.category] = [];
}
acc[category.category].push(category);
return acc;
}, {} as Record<string, CategoryDictionary[]>);
const getCategoryLabel = (category: string) => {
const labels: Record<string, string> = {
gender: '性别',
status: '状态',
unit: '单位',
weather: '天气',
soil_type: '土壤类型',
irrigation_method: '灌溉方式',
fertilizer_type: '肥料类型',
pesticide_type: '农药类型',
task_status: '任务状态',
task_priority: '任务优先级',
approval_status: '审批状态',
operation_type: '作业类型',
other: '其他',
};
return labels[category] || category;
};
return (
<div className="space-y-6">
{/* 搜索和筛选 */}
<Card className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索编码、名称、标签、值..."
value={searchKeyword}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<Select value={categoryFilter} onValueChange={onCategoryFilterChange}>
<SelectTrigger>
<SelectValue placeholder="字典分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="gender"></SelectItem>
<SelectItem value="status"></SelectItem>
<SelectItem value="unit"></SelectItem>
<SelectItem value="weather"></SelectItem>
<SelectItem value="soil_type"></SelectItem>
<SelectItem value="irrigation_method"></SelectItem>
<SelectItem value="fertilizer_type"></SelectItem>
<SelectItem value="pesticide_type"></SelectItem>
<SelectItem value="task_status"></SelectItem>
<SelectItem value="task_priority"></SelectItem>
<SelectItem value="approval_status"></SelectItem>
<SelectItem value="operation_type"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
</div>
</Card>
{/* 字典列表 */}
{Object.entries(groupedCategories).map(([category, items]) => (
<Card key={category}>
<div className="p-4 border-b bg-muted/50">
<h3 className="flex items-center gap-2">
<BookOpen className="w-5 h-5 text-green-600" />
{getCategoryLabel(category)}
<Badge variant="outline">{items.length}</Badge>
</h3>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.sort((a, b) => a.sortOrder - b.sortOrder).map((category) => (
<TableRow key={category.id}>
<TableCell>
<div className="flex items-center gap-2">
<code className="text-xs bg-muted px-2 py-1 rounded">{category.code}</code>
{category.isSystem && (
<Badge variant="outline" className="text-xs"></Badge>
)}
</div>
</TableCell>
<TableCell>
<div>
<div>{category.name}</div>
{category.description && (
<p className="text-xs text-muted-foreground">{category.description}</p>
)}
</div>
</TableCell>
<TableCell>
<code className="text-xs">{category.value}</code>
</TableCell>
<TableCell>{category.label}</TableCell>
<TableCell>{category.sortOrder}</TableCell>
<TableCell>
{category.isActive ? (
<Badge className="bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"></Badge>
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(category)}
>
<Edit className="w-4 h-4" />
</Button>
{!category.isSystem && (
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(category.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
))}
{filteredCategories.length === 0 && (
<Card className="p-12 text-center text-muted-foreground">
</Card>
)}
{/* 使用说明 */}
<Card className="p-4 bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800">
<h4 className="text-blue-900 dark:text-blue-100 mb-2">
<BookOpen className="w-4 h-4 inline mr-2" />
</h4>
<ul className="space-y-1 text-sm text-blue-800 dark:text-blue-200">
<li> </li>
<li> 使线 GENDER_MALE</li>
<li> valuelabel</li>
<li> </li>
<li> 便</li>
</ul>
</Card>
</div>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertTriangle } from 'lucide-react';
import { CategoryDictionary } from '../types';
interface DeleteConfirmDialogProps {
open: boolean;
category: CategoryDictionary | null;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}
export function DeleteConfirmDialog({
open,
category,
onOpenChange,
onConfirm,
}: DeleteConfirmDialogProps) {
const handleConfirm = () => {
onConfirm();
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-destructive" />
<DialogTitle></DialogTitle>
</div>
<DialogDescription>
"{category?.name}"
</DialogDescription>
</DialogHeader>
<div className="py-4">
{category && (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<code className="text-xs bg-muted px-2 py-1 rounded">{category.code}</code>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span>{category.name}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span>{category.category}</span>
</div>
{category.isSystem && (
<div className="mt-2 p-2 bg-destructive/10 border border-destructive/20 rounded text-destructive text-xs">
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={category?.isSystem}
>
{category?.isSystem ? '系统字典不可删除' : '确认删除'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,385 @@
'use client';
import React, { useReducer, useLayoutEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Plus, Download } from 'lucide-react';
import { toast } from 'sonner';
import { CategoryList } from './components/CategoryList';
import { CategoryForm } from './components/CategoryForm';
import { DeleteConfirmDialog } from './components/DeleteConfirmDialog';
import { CategoryDictionary } from './types';
import { categoryReducer, initialCategoryState } from './reducer';
// 模拟数据
const mockData: CategoryDictionary[] = [
// 性别
{
id: 'dict-1',
code: 'GENDER_MALE',
name: '性别-男',
category: 'gender',
value: 'male',
label: '男',
sortOrder: 1,
isSystem: true,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'dict-2',
code: 'GENDER_FEMALE',
name: '性别-女',
category: 'gender',
value: 'female',
label: '女',
sortOrder: 2,
isSystem: true,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
// 状态
{
id: 'dict-3',
code: 'STATUS_ACTIVE',
name: '状态-激活',
category: 'status',
value: 'active',
label: '激活',
sortOrder: 1,
isSystem: true,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'dict-4',
code: 'STATUS_INACTIVE',
name: '状态-停用',
category: 'status',
value: 'inactive',
label: '停用',
sortOrder: 2,
isSystem: true,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
// 单位类型
{
id: 'dict-5',
code: 'UNIT_AREA_MU',
name: '面积单位-亩',
category: 'unit',
value: 'mu',
label: '亩',
sortOrder: 1,
description: '中国传统面积单位',
isSystem: false,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'dict-6',
code: 'UNIT_AREA_HECTARE',
name: '面积单位-公顷',
category: 'unit',
value: 'hectare',
label: '公顷',
sortOrder: 2,
description: '国际通用面积单位',
isSystem: false,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'dict-7',
code: 'UNIT_WEIGHT_KG',
name: '重量单位-千克',
category: 'unit',
value: 'kg',
label: '千克',
sortOrder: 3,
isSystem: false,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'dict-8',
code: 'UNIT_WEIGHT_TON',
name: '重量单位-吨',
category: 'unit',
value: 'ton',
label: '吨',
sortOrder: 4,
isSystem: false,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
// 天气
{
id: 'dict-9',
code: 'WEATHER_SUNNY',
name: '天气-晴',
category: 'weather',
value: 'sunny',
label: '晴',
sortOrder: 1,
isSystem: false,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'dict-10',
code: 'WEATHER_CLOUDY',
name: '天气-多云',
category: 'weather',
value: 'cloudy',
label: '多云',
sortOrder: 2,
isSystem: false,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'dict-11',
code: 'WEATHER_RAINY',
name: '天气-雨',
category: 'weather',
value: 'rainy',
label: '雨',
sortOrder: 3,
isSystem: false,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
// 土壤类型
{
id: 'dict-12',
code: 'SOIL_SANDY',
name: '土壤-砂土',
category: 'soil_type',
value: 'sandy',
label: '砂土',
sortOrder: 1,
description: '含砂粒较多的土壤',
isSystem: false,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'dict-13',
code: 'SOIL_LOAMY',
name: '土壤-壤土',
category: 'soil_type',
value: 'loamy',
label: '壤土',
sortOrder: 2,
description: '砂粘适中的土壤',
isSystem: false,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
{
id: 'dict-14',
code: 'SOIL_CLAY',
name: '土壤-黏土',
category: 'soil_type',
value: 'clay',
label: '黏土',
sortOrder: 3,
description: '含黏粒较多的土壤',
isSystem: false,
isActive: true,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
},
];
export default function DataDictionaryPage() {
const [state, dispatch] = useReducer(categoryReducer, initialCategoryState);
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
const [categoryToDelete, setCategoryToDelete] = React.useState<CategoryDictionary | null>(null);
// 加载数据
useLayoutEffect(() => {
const data = localStorage.getItem('smart_agriculture_category_dictionary');
if (data) {
try {
const categories = JSON.parse(data);
dispatch({ type: 'SET_CATEGORIES', payload: categories });
} catch (error) {
console.error('Failed to parse category dictionary data:', error);
loadMockData();
}
} else {
loadMockData();
}
}, []);
const loadMockData = () => {
localStorage.setItem('smart_agriculture_category_dictionary', JSON.stringify(mockData));
dispatch({ type: 'SET_CATEGORIES', payload: mockData });
};
const saveCategories = (categories: CategoryDictionary[]) => {
localStorage.setItem('smart_agriculture_category_dictionary', JSON.stringify(categories));
dispatch({ type: 'SET_CATEGORIES', payload: categories });
};
// 处理新增
const handleAdd = () => {
dispatch({ type: 'SET_DIALOG_STATE', payload: { open: true, editing: null } });
};
// 处理编辑
const handleEdit = (category: CategoryDictionary) => {
dispatch({ type: 'SET_DIALOG_STATE', payload: { open: true, editing: category } });
};
// 处理删除
const handleDelete = (id: string) => {
const category = state.categories.find(c => c.id === id);
if (!category) return;
if (category.isSystem) {
toast.error('系统内置字典不能删除');
return;
}
setCategoryToDelete(category);
setDeleteConfirmOpen(true);
};
// 确认删除
const confirmDelete = () => {
if (!categoryToDelete) return;
const updated = state.categories.filter(c => c.id !== categoryToDelete.id);
saveCategories(updated);
toast.success('删除成功');
setCategoryToDelete(null);
};
// 处理保存
const handleSave = () => {
const { formData, dialogState } = state;
if (!formData.code.trim() || !formData.name.trim() || !formData.value.trim() || !formData.label.trim()) {
toast.error('请填写必填项');
return;
}
const now = new Date().toISOString();
if (dialogState.editing) {
// 编辑
const updated = state.categories.map(category =>
category.id === dialogState.editing!.id
? {
...category,
...formData,
updatedAt: now,
}
: category
);
saveCategories(updated);
toast.success('更新成功');
} else {
// 新增
const newCategory: CategoryDictionary = {
id: `dict-${Date.now()}`,
...formData,
isSystem: false,
createdAt: now,
updatedAt: now,
};
saveCategories([...state.categories, newCategory]);
toast.success('添加成功');
}
dispatch({ type: 'SET_DIALOG_STATE', payload: { open: false, editing: null } });
};
// 处理导出
const handleExport = () => {
const filteredCategories = state.categories.filter(category => {
const matchKeyword = !state.searchKeyword ||
category.name.includes(state.searchKeyword) ||
category.code.includes(state.searchKeyword) ||
category.label.includes(state.searchKeyword) ||
category.value.includes(state.searchKeyword);
const matchCategory = state.categoryFilter === 'all' || category.category === state.categoryFilter;
return matchKeyword && matchCategory;
});
const dataStr = JSON.stringify(filteredCategories, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `category_dictionary_${new Date().getTime()}.json`;
link.click();
toast.success('导出成功');
};
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800 dark:text-green-600"></h2>
<p className="text-muted-foreground"></p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleExport}>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleAdd}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 字典列表 */}
<CategoryList
categories={state.categories}
searchKeyword={state.searchKeyword}
categoryFilter={state.categoryFilter}
onSearchChange={(keyword) => dispatch({ type: 'SET_SEARCH_KEYWORD', payload: keyword })}
onCategoryFilterChange={(category) => dispatch({ type: 'SET_CATEGORY_FILTER', payload: category })}
onEdit={handleEdit}
onDelete={handleDelete}
/>
{/* 编辑表单 */}
<CategoryForm
open={state.dialogState.open}
editing={state.dialogState.editing}
formData={state.formData}
onFormDataChange={(data) => dispatch({ type: 'SET_FORM_DATA', payload: data })}
onOpenChange={(open) => dispatch({ type: 'SET_DIALOG_STATE', payload: { open, editing: null } })}
onSave={handleSave}
/>
{/* 删除确认对话框 */}
<DeleteConfirmDialog
open={deleteConfirmOpen}
category={categoryToDelete}
onOpenChange={setDeleteConfirmOpen}
onConfirm={confirmDelete}
/>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import { CategoryState, CategoryAction, CategoryFormData } from './types';
// 初始状态
export const initialCategoryState: CategoryState = {
categories: [],
searchKeyword: '',
categoryFilter: 'all',
dialogState: {
open: false,
editing: null,
},
formData: {
code: '',
name: '',
category: 'other',
value: '',
label: '',
sortOrder: 0,
description: '',
isActive: true,
},
};
// 初始表单数据
export const initialFormData: CategoryFormData = {
code: '',
name: '',
category: 'other',
value: '',
label: '',
sortOrder: 0,
description: '',
isActive: true,
};
// Reducer
export function categoryReducer(state: CategoryState, action: CategoryAction): CategoryState {
switch (action.type) {
case 'SET_CATEGORIES':
return {
...state,
categories: action.payload,
};
case 'ADD_CATEGORY':
return {
...state,
categories: [...state.categories, action.payload],
};
case 'UPDATE_CATEGORY':
return {
...state,
categories: state.categories.map(category =>
category.id === action.payload.id
? { ...category, ...action.payload.updates, updatedAt: new Date().toISOString() }
: category
),
};
case 'DELETE_CATEGORY':
return {
...state,
categories: state.categories.filter(category => category.id !== action.payload),
};
case 'SET_SEARCH_KEYWORD':
return {
...state,
searchKeyword: action.payload,
};
case 'SET_CATEGORY_FILTER':
return {
...state,
categoryFilter: action.payload,
};
case 'SET_DIALOG_STATE':
return {
...state,
dialogState: action.payload,
formData: action.payload.editing
? {
code: action.payload.editing.code,
name: action.payload.editing.name,
category: action.payload.editing.category,
value: action.payload.editing.value,
label: action.payload.editing.label,
sortOrder: action.payload.editing.sortOrder,
description: action.payload.editing.description || '',
isActive: action.payload.editing.isActive,
}
: initialFormData,
};
case 'SET_FORM_DATA':
return {
...state,
formData: {
...state.formData,
...action.payload,
},
};
default:
return state;
}
}

View File

@@ -0,0 +1,67 @@
// 分类字典类型定义
export interface CategoryDictionary {
id: string;
code: string;
name: string;
category: string; // 字典分类
value: string;
label: string;
sortOrder: number;
description?: string;
isSystem: boolean; // 是否系统内置
isActive: boolean;
extendData?: Record<string, any>;
createdAt: string;
updatedAt: string;
}
export type DictionaryCategory =
| 'gender'
| 'status'
| 'unit'
| 'weather'
| 'soil_type'
| 'irrigation_method'
| 'fertilizer_type'
| 'pesticide_type'
| 'task_status'
| 'task_priority'
| 'approval_status'
| 'operation_type'
| 'other';
// 分类表单数据
export interface CategoryFormData {
code: string;
name: string;
category: string;
value: string;
label: string;
sortOrder: number;
description: string;
isActive: boolean;
}
// 分类操作类型
export type CategoryAction =
| { type: 'SET_CATEGORIES'; payload: CategoryDictionary[] }
| { type: 'ADD_CATEGORY'; payload: CategoryDictionary }
| { type: 'UPDATE_CATEGORY'; payload: { id: string; updates: Partial<CategoryDictionary> } }
| { type: 'DELETE_CATEGORY'; payload: string }
| { type: 'SET_SEARCH_KEYWORD'; payload: string }
| { type: 'SET_CATEGORY_FILTER'; payload: string }
| { type: 'SET_DIALOG_STATE'; payload: { open: boolean; editing?: CategoryDictionary | null } }
| { type: 'SET_FORM_DATA'; payload: Partial<CategoryFormData> };
// 分类状态
export interface CategoryState {
categories: CategoryDictionary[];
searchKeyword: string;
categoryFilter: string;
dialogState: {
open: boolean;
editing?: CategoryDictionary | null;
};
formData: CategoryFormData;
}

View File

@@ -0,0 +1,26 @@
'use client';
import React from 'react';
import Link from 'next/link';
export default function SystemPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4"></h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Link href="/central-config/system/settings" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-gray-600 text-sm"></p>
</Link>
<Link href="/central-config/system/category" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-gray-600 text-sm"></p>
</Link>
<Link href="/central-config/system/dictionary" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-gray-600 text-sm"></p>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SystemSettings } from '@/types/system-params'
interface CopyrightInfoCardProps {
settings: SystemSettings
onSettingsChange: (updates: Partial<SystemSettings>) => void
}
export function CopyrightInfoCard({ settings, onSettingsChange }: CopyrightInfoCardProps) {
return (
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>ICP备案号</Label>
<Input
value={settings.icp || ''}
onChange={(e) => onSettingsChange({ icp: e.target.value })}
placeholder="京ICP备12345678号"
/>
</div>
<div>
<Label></Label>
<Input
value={settings.copyright || ''}
onChange={(e) => onSettingsChange({ copyright: e.target.value })}
placeholder="© 2024 公司名称 版权所有"
/>
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,39 @@
import { Card } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { SystemSettings } from '@/types/system-params'
interface FeatureToggleCardProps {
settings: SystemSettings
onSettingsChange: (updates: Partial<SystemSettings>) => void
}
export function FeatureToggleCard({ settings, onSettingsChange }: FeatureToggleCardProps) {
return (
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={settings.enableRegistration}
onCheckedChange={(checked) => onSettingsChange({ enableRegistration: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>访访</Label>
<p className="text-sm text-muted-foreground">访</p>
</div>
<Switch
checked={settings.enableGuestAccess}
onCheckedChange={(checked) => onSettingsChange({ enableGuestAccess: checked })}
/>
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,67 @@
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { SystemSettings } from '@/types/system-params'
interface PasswordPolicyCardProps {
settings: SystemSettings
onSettingsChange: (updates: Partial<SystemSettings>) => void
}
export function PasswordPolicyCard({ settings, onSettingsChange }: PasswordPolicyCardProps) {
const updatePasswordPolicy = (updates: Partial<SystemSettings['passwordPolicy']>) => {
onSettingsChange({
passwordPolicy: { ...settings.passwordPolicy, ...updates }
})
}
return (
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="space-y-4">
<div>
<Label></Label>
<Input
type="number"
value={settings.passwordPolicy.minLength}
onChange={(e) => updatePasswordPolicy({ minLength: parseInt(e.target.value) || 8 })}
min={6}
max={32}
/>
</div>
<div className="space-y-3">
<Label></Label>
<div className="flex items-center justify-between">
<span className="text-sm"></span>
<Switch
checked={settings.passwordPolicy.requireUppercase}
onCheckedChange={(checked) => updatePasswordPolicy({ requireUppercase: checked })}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm"></span>
<Switch
checked={settings.passwordPolicy.requireLowercase}
onCheckedChange={(checked) => updatePasswordPolicy({ requireLowercase: checked })}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm"></span>
<Switch
checked={settings.passwordPolicy.requireNumbers}
onCheckedChange={(checked) => updatePasswordPolicy({ requireNumbers: checked })}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm"></span>
<Switch
checked={settings.passwordPolicy.requireSpecialChars}
onCheckedChange={(checked) => updatePasswordPolicy({ requireSpecialChars: checked })}
/>
</div>
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,60 @@
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SystemSettings } from '@/types/system-params'
interface PlatformInfoCardProps {
settings: SystemSettings
onSettingsChange: (updates: Partial<SystemSettings>) => void
}
export function PlatformInfoCard({ settings, onSettingsChange }: PlatformInfoCardProps) {
return (
<Card className="p-6">
<h3 className="mb-4"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={settings.platformName}
onChange={(e) => onSettingsChange({ platformName: e.target.value })}
placeholder="请输入平台名称"
/>
</div>
<div>
<Label></Label>
<Input
value={settings.companyName || ''}
onChange={(e) => onSettingsChange({ companyName: e.target.value })}
placeholder="请输入公司名称"
/>
</div>
<div>
<Label></Label>
<Input
type="email"
value={settings.contactEmail || ''}
onChange={(e) => onSettingsChange({ contactEmail: e.target.value })}
placeholder="support@example.com"
/>
</div>
<div>
<Label></Label>
<Input
value={settings.contactPhone || ''}
onChange={(e) => onSettingsChange({ contactPhone: e.target.value })}
placeholder="400-888-8888"
/>
</div>
<div className="md:col-span-2">
<Label></Label>
<Input
value={settings.address || ''}
onChange={(e) => onSettingsChange({ address: e.target.value })}
placeholder="请输入公司地址"
/>
</div>
</div>
</Card>
)
}

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