Compare commits
26 Commits
5c783c73e1
...
fetch-test
| Author | SHA1 | Date | |
|---|---|---|---|
| 7615ca9895 | |||
|
|
e784e68404 | ||
|
|
b70922e4d7 | ||
|
|
9452d748aa | ||
|
|
55c1248a8b | ||
|
|
978419fa2c | ||
| a17da68fcd | |||
| 23e881215d | |||
| 19a2025931 | |||
| 3c92cb89f2 | |||
| dbbdf1f2d7 | |||
| d254790901 | |||
| 4f3beb2568 | |||
| ce2510d526 | |||
| f93f9e4d88 | |||
| 28229ce795 | |||
| 83523dad64 | |||
| ed642fc9c7 | |||
|
|
8da01a207d | ||
| 9866a86f32 | |||
| 8ea90d980b | |||
| f1ffcc72fc | |||
| 04d61ae3b9 | |||
| 7a21043dd8 | |||
| 9afc680833 | |||
| 4a5d278d89 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -145,4 +145,5 @@ Thumbs.db
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
temp/
|
||||
nul
|
||||
|
||||
47
Dockerfile.crop-x
Normal file
47
Dockerfile.crop-x
Normal 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
97
crop-x/.dockerignore
Normal 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
|
||||
@@ -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
47
crop-x/Dockerfile
Normal 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
25
crop-x/eslint.config.mjs
Normal 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
25
crop-x/next.config.js
Normal 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
3493
crop-x/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -3,4 +3,4 @@ export default {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
44
crop-x/scripts/deploy.js
Normal file
44
crop-x/scripts/deploy.js
Normal 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);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function AssetLabelingLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <>{children}</>
|
||||
}
|
||||
7
crop-x/src/app/(app)/agricultural-asset/layout.tsx
Normal file
7
crop-x/src/app/(app)/agricultural-asset/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function AgriculturalAssetLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <>{children}</>
|
||||
}
|
||||
7
crop-x/src/app/(app)/agricultural-machinery/layout.tsx
Normal file
7
crop-x/src/app/(app)/agricultural-machinery/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function AgriculturalMachineryLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <>{children}</>
|
||||
}
|
||||
7
crop-x/src/app/(app)/ai-crop-model/layout.tsx
Normal file
7
crop-x/src/app/(app)/ai-crop-model/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function AiCropModelLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <>{children}</>
|
||||
}
|
||||
11
crop-x/src/app/(app)/central-config/layout.tsx
Normal file
11
crop-x/src/app/(app)/central-config/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
import { ReactNode } from 'react'
|
||||
// import {SideBarOld} from '@/components/layouts/SideBar/SideBarOld'
|
||||
|
||||
export default function CentralConfigLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
interface MessageLogFilterProps {
|
||||
searchKeyword: string;
|
||||
typeFilter: string;
|
||||
statusFilter: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onTypeChange: (value: string) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function MessageLogFilter({
|
||||
searchKeyword,
|
||||
typeFilter,
|
||||
statusFilter,
|
||||
onSearchChange,
|
||||
onTypeChange,
|
||||
onStatusChange,
|
||||
}: MessageLogFilterProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索接收人、内容..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={typeFilter} onValueChange={onTypeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="消息类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="sms">短信</SelectItem>
|
||||
<SelectItem value="email">邮件</SelectItem>
|
||||
<SelectItem value="internal">站内信</SelectItem>
|
||||
<SelectItem value="push">推送</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="发送状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="sent">已发送</SelectItem>
|
||||
<SelectItem value="read">已读</SelectItem>
|
||||
<SelectItem value="failed">失败</SelectItem>
|
||||
<SelectItem value="pending">待发送</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { MessageLog } from '@/types/message';
|
||||
|
||||
interface MessageLogStatsProps {
|
||||
logs: MessageLog[];
|
||||
}
|
||||
|
||||
export function MessageLogStats({ logs }: MessageLogStatsProps) {
|
||||
const stats = [
|
||||
{
|
||||
label: '总消息数',
|
||||
value: logs.length,
|
||||
color: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '已发送',
|
||||
value: logs.filter(l => l.status === 'sent' || l.status === 'read').length,
|
||||
color: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '已读',
|
||||
value: logs.filter(l => l.status === 'read').length,
|
||||
color: 'text-purple-600',
|
||||
},
|
||||
{
|
||||
label: '发送失败',
|
||||
value: logs.filter(l => l.status === 'failed').length,
|
||||
color: 'text-red-600',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="text-sm text-muted-foreground">{stat.label}</div>
|
||||
<div className={`mt-2 text-2xl font-bold ${stat.color}`}>{stat.value}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { MessageLog } from '@/types/message';
|
||||
import {
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Smartphone,
|
||||
Bell,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
|
||||
interface MessageLogTableProps {
|
||||
logs: MessageLog[];
|
||||
}
|
||||
|
||||
export function MessageLogTable({ logs }: MessageLogTableProps) {
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'sms': return <Smartphone className="w-4 h-4" />;
|
||||
case 'email': return <Mail className="w-4 h-4" />;
|
||||
case 'internal': return <MessageSquare className="w-4 h-4" />;
|
||||
case 'push': return <Bell className="w-4 h-4" />;
|
||||
default: return <MessageSquare className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
sms: '短信',
|
||||
email: '邮件',
|
||||
internal: '站内信',
|
||||
push: '推送',
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
sms: 'bg-blue-100 text-blue-700',
|
||||
email: 'bg-purple-100 text-purple-700',
|
||||
internal: 'bg-green-100 text-green-700',
|
||||
push: 'bg-orange-100 text-orange-700',
|
||||
};
|
||||
return colors[type] || 'bg-gray-100 text-gray-700';
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'sent':
|
||||
return { icon: <CheckCircle className="w-3 h-3" />, label: '已发送', className: 'bg-green-100 text-green-700' };
|
||||
case 'read':
|
||||
return { icon: <CheckCircle className="w-3 h-3" />, label: '已读', className: 'bg-blue-100 text-blue-700' };
|
||||
case 'failed':
|
||||
return { icon: <XCircle className="w-3 h-3" />, label: '失败', className: 'bg-red-100 text-red-700' };
|
||||
case 'pending':
|
||||
return { icon: <Clock className="w-3 h-3" />, label: '待发送', className: 'bg-yellow-100 text-yellow-700' };
|
||||
default:
|
||||
return { icon: <Clock className="w-3 h-3" />, label: status, className: 'bg-gray-100 text-gray-700' };
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateTime = (dateTime: string | undefined) => {
|
||||
if (!dateTime) return '-';
|
||||
return dateTime;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>发送时间</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>接收人</TableHead>
|
||||
<TableHead>主题/内容</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>重试次数</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||
暂无消息日志
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => {
|
||||
const statusBadge = getStatusBadge(log.status);
|
||||
// 调试信息
|
||||
console.log('Log data:', log);
|
||||
console.log('Type:', log.type, 'Badge color:', getTypeBadge(log.type));
|
||||
return (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatDateTime(log.sentTime)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getTypeBadge(log.type)}`}>
|
||||
{getTypeIcon(log.type)}
|
||||
<span>{getTypeLabel(log.type)}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div>{log.recipientName || '-'}</div>
|
||||
<div className="text-xs text-muted-foreground">{log.recipient}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs">
|
||||
{log.subject && (
|
||||
<div className="mb-1 font-medium">{log.subject}</div>
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{log.content}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${statusBadge.className}`}>
|
||||
{statusBadge.icon}
|
||||
<span>{statusBadge.label}</span>
|
||||
</div>
|
||||
{log.status === 'read' && log.readTime && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatDateTime(log.readTime)}
|
||||
</p>
|
||||
)}
|
||||
{log.status === 'failed' && log.failReason && (
|
||||
<p className="text-xs text-red-600 mt-1">{log.failReason}</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="inline-flex items-center px-2 py-1 rounded-full text-xs border border-gray-300 bg-gray-50">
|
||||
{log.retryCount}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
115
crop-x/src/app/(app)/central-config/message/log/mock/mockData.ts
Normal file
115
crop-x/src/app/(app)/central-config/message/log/mock/mockData.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { MessageLog } from '@/types/message';
|
||||
|
||||
export const mockMessageLogs: MessageLog[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
templateId: 'tpl-1',
|
||||
templateName: '任务分配通知',
|
||||
type: 'internal',
|
||||
recipient: 'user-2',
|
||||
recipientName: '张三',
|
||||
subject: '新任务分配',
|
||||
content: '您好,张三!您有新的作业任务:小麦播种作业,计划执行时间:2024-10-15 08:00。请及时查看并准备。',
|
||||
status: 'sent',
|
||||
sentTime: '2024-10-14 09:30:00',
|
||||
readTime: '2024-10-14 10:15:00',
|
||||
retryCount: 0,
|
||||
variables: {
|
||||
username: '张三',
|
||||
taskName: '小麦播种作业',
|
||||
executeTime: '2024-10-15 08:00',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
templateId: 'tpl-2',
|
||||
templateName: '设备预警通知',
|
||||
type: 'sms',
|
||||
recipient: '13800138000',
|
||||
recipientName: '李四',
|
||||
content: '【智慧农业】设备预警:约翰迪尔拖拉机检测到异常,发动机温度过高,请及时处理。',
|
||||
status: 'sent',
|
||||
sentTime: '2024-10-14 09:30:00',
|
||||
retryCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'msg-3',
|
||||
templateId: 'tpl-3',
|
||||
templateName: '保养提醒',
|
||||
type: 'email',
|
||||
recipient: 'wangwu@example.com',
|
||||
recipientName: '王五',
|
||||
subject: '设备保养提醒',
|
||||
content: '尊敬的用户:\n\n您的设备约翰迪尔拖拉机(编号:JD-001)已使用500小时,建议进行保养维护...',
|
||||
status: 'sent',
|
||||
sentTime: '2024-10-14 09:30:00',
|
||||
retryCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'msg-4',
|
||||
templateId: 'tpl-4',
|
||||
templateName: '任务完成通知',
|
||||
type: 'push',
|
||||
recipient: 'user-2',
|
||||
recipientName: '张三',
|
||||
subject: '任务完成',
|
||||
content: '作业任务小麦播种作业已完成,作业面积:50亩,耗时:3小时。',
|
||||
status: 'read',
|
||||
sentTime: '2024-10-14 09:30:00',
|
||||
readTime: '2024-10-14 10:15:00',
|
||||
retryCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'msg-5',
|
||||
templateId: 'tpl-5',
|
||||
templateName: '验证码',
|
||||
type: 'sms',
|
||||
recipient: '13900139000',
|
||||
recipientName: '赵六',
|
||||
content: '【智慧农业】验证码:123456,有效期5分钟。',
|
||||
status: 'failed',
|
||||
sentTime: '2024-10-14 09:30:00',
|
||||
failReason: '手机号码格式错误',
|
||||
retryCount: 2,
|
||||
},
|
||||
{
|
||||
id: 'msg-6',
|
||||
templateId: 'tpl-6',
|
||||
templateName: '系统维护通知',
|
||||
type: 'internal',
|
||||
recipient: 'user-3',
|
||||
recipientName: '钱七',
|
||||
subject: '系统维护通知',
|
||||
content: '系统将于今晚22:00-24:00进行维护,期间可能影响部分功能使用。',
|
||||
status: 'pending',
|
||||
sentTime: '2024-10-15 14:20:00',
|
||||
retryCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'msg-7',
|
||||
templateId: 'tpl-7',
|
||||
templateName: '天气预警',
|
||||
type: 'push',
|
||||
recipient: 'user-4',
|
||||
recipientName: '孙八',
|
||||
subject: '天气预警',
|
||||
content: '未来24小时将有暴雨,请注意防范,做好农田排水工作。',
|
||||
status: 'sent',
|
||||
sentTime: '2024-10-15 14:20:00',
|
||||
retryCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'msg-8',
|
||||
templateId: 'tpl-8',
|
||||
templateName: '作业报告',
|
||||
type: 'email',
|
||||
recipient: 'manager@example.com',
|
||||
recipientName: '管理员',
|
||||
subject: '每日作业报告',
|
||||
content: '今日完成播种作业100亩,施肥作业50亩,灌溉作业200亩。',
|
||||
status: 'read',
|
||||
sentTime: '2024-10-15 14:20:00',
|
||||
readTime: '2024-10-15 15:30:00',
|
||||
retryCount: 0,
|
||||
}
|
||||
];
|
||||
139
crop-x/src/app/(app)/central-config/message/log/page.tsx
Normal file
139
crop-x/src/app/(app)/central-config/message/log/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Download, MessageSquare, RefreshCw } from 'lucide-react';
|
||||
import { MessageLogStats } from './components/MessageLogStats';
|
||||
import { MessageLogFilter } from './components/MessageLogFilter';
|
||||
import { MessageLogTable } from './components/MessageLogTable';
|
||||
import { MessageLog } from '@/types/message';
|
||||
import { mockMessageLogs } from './mock/mockData';
|
||||
|
||||
export default function MessageLogPage() {
|
||||
const [logs, setLogs] = useState<MessageLog[]>([]);
|
||||
const [filteredLogs, setFilteredLogs] = useState<MessageLog[]>([]);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyFilters();
|
||||
}, [logs, searchKeyword, typeFilter, statusFilter]);
|
||||
|
||||
const loadLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 模拟API延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
setLogs(mockMessageLogs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load message logs:', error);
|
||||
toast.error('加载消息日志失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
let filtered = logs;
|
||||
|
||||
// 调试信息
|
||||
console.log('All logs:', logs);
|
||||
console.log('Filters:', { searchKeyword, typeFilter, statusFilter });
|
||||
|
||||
if (searchKeyword) {
|
||||
filtered = filtered.filter(log =>
|
||||
(log.recipientName && log.recipientName.includes(searchKeyword)) ||
|
||||
log.recipient.includes(searchKeyword) ||
|
||||
log.content.includes(searchKeyword)
|
||||
);
|
||||
}
|
||||
|
||||
if (typeFilter !== 'all') {
|
||||
filtered = filtered.filter(log => log.type === typeFilter);
|
||||
}
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(log => log.status === statusFilter);
|
||||
}
|
||||
|
||||
filtered.sort((a, b) => b.sentTime.localeCompare(a.sentTime));
|
||||
|
||||
setFilteredLogs(filtered);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const dataStr = JSON.stringify(filteredLogs, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `message_logs_${new Date().getTime()}.json`;
|
||||
link.click();
|
||||
toast.success('导出成功');
|
||||
} catch (error) {
|
||||
console.error('Failed to export logs:', error);
|
||||
toast.error('导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadLogs();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800 text-2xl font-bold">消息日志</h2>
|
||||
<p className="text-muted-foreground">完整记录所有通过系统发送的消息流水</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={loading}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={loading}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出日志
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageLogStats logs={filteredLogs} />
|
||||
|
||||
<MessageLogFilter
|
||||
searchKeyword={searchKeyword}
|
||||
typeFilter={typeFilter}
|
||||
statusFilter={statusFilter}
|
||||
onSearchChange={setSearchKeyword}
|
||||
onTypeChange={setTypeFilter}
|
||||
onStatusChange={setStatusFilter}
|
||||
/>
|
||||
|
||||
<MessageLogTable logs={filteredLogs} />
|
||||
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="text-blue-900 mb-2 font-semibold">
|
||||
<MessageSquare className="w-4 h-4 inline mr-2" />
|
||||
消息日志说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800">
|
||||
<li>• 完整记录所有通过系统发送的消息,包括短信、邮件、站内信和推送</li>
|
||||
<li>• 支持按条件搜索历史消息,便于跟踪消息触达情况</li>
|
||||
<li>• 失败消息会记录失败原因,便于排查发送异常</li>
|
||||
<li>• 站内信和推送消息会记录阅读时间</li>
|
||||
<li>• 重试次数用于标识消息的发送尝试次数</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
crop-x/src/app/(app)/central-config/message/page.tsx
Normal file
26
crop-x/src/app/(app)/central-config/message/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function MessagePage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">消息中心</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Link href="/central-config/message/send" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">消息发送</h3>
|
||||
<p className="text-gray-600 text-sm">发送系统消息</p>
|
||||
</Link>
|
||||
<Link href="/central-config/message/template" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">消息模版</h3>
|
||||
<p className="text-gray-600 text-sm">管理消息模版</p>
|
||||
</Link>
|
||||
<Link href="/central-config/message/log" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">消息日志</h3>
|
||||
<p className="text-gray-600 text-sm">查看消息发送记录</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
|
||||
export function MessageInstructions() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="text-blue-900 mb-2">消息发送说明</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800">
|
||||
<li>• 支持发送短信、邮件、站内信、推送四种类型的消息</li>
|
||||
<li>• 实时发送:消息立即发送给接收人</li>
|
||||
<li>• 定时发送:设定未来的日期和时间,系统到时自动发送</li>
|
||||
<li>• 可以使用消息模版,自动填充变量生成个性化内容</li>
|
||||
<li>• 支持批量发送,一次可向多个接收人发送相同消息</li>
|
||||
<li>• 定时消息在未发送前可以取消,已发送的消息可以删除记录</li>
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { MessageSendRecord } from '@/types/message';
|
||||
|
||||
interface MessagePreviewDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
record: MessageSendRecord | null;
|
||||
getTypeIcon: (type: string) => JSX.Element;
|
||||
getTypeLabel: (type: string) => string;
|
||||
getTypeBadge: (type: string) => string;
|
||||
getStatusBadge: (status: string) => JSX.Element;
|
||||
}
|
||||
|
||||
export function MessagePreviewDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
record,
|
||||
getTypeIcon,
|
||||
getTypeLabel,
|
||||
getTypeBadge,
|
||||
getStatusBadge
|
||||
}: MessagePreviewDialogProps) {
|
||||
if (!record) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>消息详情</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
查看消息发送详情
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>消息模版</Label>
|
||||
<div className="field-value-inline">{record.templateName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>消息类型</Label>
|
||||
<div className="mt-2">
|
||||
<Badge className={getTypeBadge(record.type)}>
|
||||
<span className="flex items-center gap-1">
|
||||
{getTypeIcon(record.type)}
|
||||
{getTypeLabel(record.type)}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>发送方式</Label>
|
||||
<div className="field-value-inline">
|
||||
{record.sendType === 'immediate' ? '实时发送' : '定时发送'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>发送状态</Label>
|
||||
<div className="mt-2">
|
||||
{getStatusBadge(record.status)}
|
||||
</div>
|
||||
</div>
|
||||
{record.scheduledTime && (
|
||||
<div>
|
||||
<Label>定时发送时间</Label>
|
||||
<div className="field-value-inline">
|
||||
{format(new Date(record.scheduledTime), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>创建时间</Label>
|
||||
<div className="field-value-inline">
|
||||
{format(new Date(record.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{record.subject && (
|
||||
<div>
|
||||
<Label>消息主题</Label>
|
||||
<div className="field-value-inline">{record.subject}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>接收人列表(共 {record.recipientCount} 人)</Label>
|
||||
<Card className="p-3 bg-gray-50 mt-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{record.recipients.map((recipient, index) => (
|
||||
<Badge key={index} variant="outline">
|
||||
{recipient}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>消息内容</Label>
|
||||
<Card className="p-4 bg-blue-50 border-blue-200 mt-2">
|
||||
<pre className="text-sm whitespace-pre-wrap">
|
||||
{record.content}
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import {
|
||||
Send,
|
||||
Clock,
|
||||
Users,
|
||||
Eye,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Timer
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { MessageSendRecord } from '@/types/message';
|
||||
|
||||
interface MessageSendTableProps {
|
||||
sendRecords: MessageSendRecord[];
|
||||
onPreview: (record: MessageSendRecord) => void;
|
||||
onCancel: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
getTypeIcon: (type: string) => JSX.Element;
|
||||
getTypeLabel: (type: string) => string;
|
||||
getTypeBadge: (type: string) => string;
|
||||
getStatusBadge: (status: string) => JSX.Element;
|
||||
}
|
||||
|
||||
export function MessageSendTable({
|
||||
sendRecords,
|
||||
onPreview,
|
||||
onCancel,
|
||||
onDelete,
|
||||
getTypeIcon,
|
||||
getTypeLabel,
|
||||
getTypeBadge,
|
||||
getStatusBadge
|
||||
}: MessageSendTableProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>消息模版</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>接收人数</TableHead>
|
||||
<TableHead>发送方式</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sendRecords.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||
暂无发送记录
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sendRecords.map((record) => (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell>
|
||||
<div>{record.templateName}</div>
|
||||
{record.subject && (
|
||||
<p className="text-xs text-muted-foreground">{record.subject}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getTypeBadge(record.type)}>
|
||||
<span className="flex items-center gap-1">
|
||||
{getTypeIcon(record.type)}
|
||||
{getTypeLabel(record.type)}
|
||||
</span>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-4 h-4 text-muted-foreground" />
|
||||
<span>{record.recipientCount}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{record.sendType === 'immediate' ? (
|
||||
<Badge variant="outline">
|
||||
<Send className="w-3 h-3 mr-1" />
|
||||
实时发送
|
||||
</Badge>
|
||||
) : (
|
||||
<div>
|
||||
<Badge variant="outline">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
定时发送
|
||||
</Badge>
|
||||
{record.scheduledTime && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{format(new Date(record.scheduledTime), 'MM-dd HH:mm', { locale: zhCN })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(record.status)}
|
||||
{record.status === 'sent' && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
成功 {record.sentCount}/{record.recipientCount}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{format(new Date(record.createdAt), 'MM-dd HH:mm', { locale: zhCN })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onPreview(record)}
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
{record.status === 'pending' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onCancel(record.id)}
|
||||
title="取消发送"
|
||||
>
|
||||
<XCircle className="w-4 h-4 text-orange-600" />
|
||||
</Button>
|
||||
)}
|
||||
{(record.status === 'sent' || record.status === 'cancelled') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(record.id)}
|
||||
title="删除记录"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { MessageSendRecord } from '@/types/message';
|
||||
|
||||
interface MessageStatsCardsProps {
|
||||
sendRecords: MessageSendRecord[];
|
||||
}
|
||||
|
||||
export function MessageStatsCards({ sendRecords }: MessageStatsCardsProps) {
|
||||
const stats = [
|
||||
{
|
||||
label: '总发送数',
|
||||
value: sendRecords.length,
|
||||
color: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '已发送',
|
||||
value: sendRecords.filter(r => r.status === 'sent').length,
|
||||
color: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '待发送',
|
||||
value: sendRecords.filter(r => r.status === 'pending').length,
|
||||
color: 'text-yellow-600',
|
||||
},
|
||||
{
|
||||
label: '已取消',
|
||||
value: sendRecords.filter(r => r.status === 'cancelled').length,
|
||||
color: 'text-gray-600',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="text-sm text-muted-foreground">{stat.label}</div>
|
||||
<div className={`mt-2 text-2xl font-bold ${stat.color}`}>{stat.value}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Send, Clock, CalendarIcon } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { MessageTemplate } from '@/types/message';
|
||||
import { MessageSendFormData } from '../types';
|
||||
|
||||
interface SendMessageDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
templates: MessageTemplate[];
|
||||
formData: MessageSendFormData;
|
||||
onFormDataChange: (data: MessageSendFormData) => void;
|
||||
onSend: () => void;
|
||||
getTypeIcon: (type: string) => JSX.Element;
|
||||
getTypeLabel: (type: string) => string;
|
||||
}
|
||||
|
||||
export function SendMessageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
templates,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
onSend,
|
||||
getTypeIcon,
|
||||
getTypeLabel
|
||||
}: SendMessageDialogProps) {
|
||||
const replaceVariables = (content: string, variables: Record<string, string>): string => {
|
||||
let result = content;
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value || `{{${key}}}`);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleTemplateChange = (templateId: string) => {
|
||||
const template = templates.find(t => t.id === templateId);
|
||||
if (template) {
|
||||
// 初始化变量
|
||||
const vars: Record<string, string> = {};
|
||||
template.variables.forEach(v => {
|
||||
vars[v] = '';
|
||||
});
|
||||
|
||||
onFormDataChange({
|
||||
...formData,
|
||||
templateId,
|
||||
type: template.type,
|
||||
subject: template.subject || '',
|
||||
content: template.content,
|
||||
variables: vars,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTemplate = templates.find(t => t.id === formData.templateId);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Send className="w-5 h-5 text-green-600" />
|
||||
发送消息
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
选择消息模版并发送消息
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* 选择模版 */}
|
||||
<div>
|
||||
<Label>选择消息模版 *</Label>
|
||||
<Select value={formData.templateId} onValueChange={handleTemplateChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择消息模版" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.filter(t => t.isActive).map(template => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getTypeIcon(template.type)}
|
||||
<span>{template.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getTypeLabel(template.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 发送方式 */}
|
||||
<div>
|
||||
<Label>发送方式 *</Label>
|
||||
<Select
|
||||
value={formData.sendType}
|
||||
onValueChange={(value: 'immediate' | 'scheduled') => onFormDataChange({ ...formData, sendType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="immediate">
|
||||
<div className="flex items-center gap-2">
|
||||
<Send className="w-4 h-4" />
|
||||
实时发送
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="scheduled">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
定时发送
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 定时发送设置 */}
|
||||
{formData.sendType === 'scheduled' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>发送日期 *</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<CalendarIcon className="w-4 h-4 mr-2" />
|
||||
{formData.scheduledDate ? (
|
||||
format(formData.scheduledDate, 'yyyy年MM月dd日', { locale: zhCN })
|
||||
) : (
|
||||
'选择日期'
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={formData.scheduledDate}
|
||||
onSelect={(date) => onFormDataChange({ ...formData, scheduledDate: date })}
|
||||
locale={zhCN}
|
||||
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<Label>发送时间 *</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.scheduledTime}
|
||||
onChange={(e) => onFormDataChange({ ...formData, scheduledTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 接收人 */}
|
||||
<div>
|
||||
<Label>接收人 *</Label>
|
||||
<Textarea
|
||||
value={formData.recipients}
|
||||
onChange={(e) => onFormDataChange({ ...formData, recipients: e.target.value })}
|
||||
placeholder={
|
||||
formData.type === 'sms' ? '输入手机号,多个用逗号或换行分隔' :
|
||||
formData.type === 'email' ? '输入邮箱地址,多个用逗号或换行分隔' :
|
||||
formData.type === 'push' ? '输入设备ID或用户ID,多个用逗号或换行分隔' :
|
||||
'输入用户名,多个用逗号或换行分隔'
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
支持多个接收人,使用逗号、分号或换行分隔
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 消息主题(邮件和推送) */}
|
||||
{(formData.type === 'email' || formData.type === 'push') && (
|
||||
<div>
|
||||
<Label>消息主题</Label>
|
||||
<Input
|
||||
value={formData.subject}
|
||||
onChange={(e) => onFormDataChange({ ...formData, subject: e.target.value })}
|
||||
placeholder="输入消息主题"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 变量填写 */}
|
||||
{selectedTemplate && selectedTemplate.variables.length > 0 && (
|
||||
<div>
|
||||
<Label>填写变量 *</Label>
|
||||
<Card className="p-4 bg-gray-50">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedTemplate.variables.map(variable => (
|
||||
<div key={variable}>
|
||||
<Label htmlFor={`var-${variable}`} className="text-xs">
|
||||
{variable}
|
||||
</Label>
|
||||
<Input
|
||||
id={`var-${variable}`}
|
||||
value={formData.variables[variable] || ''}
|
||||
onChange={(e) => onFormDataChange({
|
||||
...formData,
|
||||
variables: {
|
||||
...formData.variables,
|
||||
[variable]: e.target.value,
|
||||
},
|
||||
})}
|
||||
placeholder={`输入 ${variable}`}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息内容预览 */}
|
||||
{formData.content && (
|
||||
<div>
|
||||
<Label>消息内容预览</Label>
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<pre className="text-sm whitespace-pre-wrap">
|
||||
{replaceVariables(formData.content, formData.variables)}
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={onSend} className="bg-green-600 hover:bg-green-700">
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{formData.sendType === 'immediate' ? '立即发送' : '创建定时任务'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { MessageStatsCards } from './MessageStatsCards';
|
||||
export { MessageSendTable } from './MessageSendTable';
|
||||
export { SendMessageDialog } from './SendMessageDialog';
|
||||
export { MessagePreviewDialog } from './MessagePreviewDialog';
|
||||
export { MessageInstructions } from './MessageInstructions';
|
||||
493
crop-x/src/app/(app)/central-config/message/send/page.tsx
Normal file
493
crop-x/src/app/(app)/central-config/message/send/page.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Send, Mail, MessageSquare, Bell, Smartphone, CheckCircle2, XCircle, Timer } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { MessageTemplate, MessageSendRecord } from '@/types/message';
|
||||
import { MessageSendFormData } from './types';
|
||||
import {
|
||||
MessageStatsCards,
|
||||
MessageSendTable,
|
||||
SendMessageDialog,
|
||||
MessagePreviewDialog,
|
||||
MessageInstructions
|
||||
} from './components';
|
||||
|
||||
// API服务函数
|
||||
const messageApi = {
|
||||
// 获取消息模板
|
||||
getTemplates: async (): Promise<MessageTemplate[]> => {
|
||||
try {
|
||||
const response = await fetch('/api/message/templates');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch message templates');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch message templates, using mock data:', error);
|
||||
return getMockTemplates();
|
||||
}
|
||||
},
|
||||
|
||||
// 获取发送记录
|
||||
getSendRecords: async (): Promise<MessageSendRecord[]> => {
|
||||
try {
|
||||
const response = await fetch('/api/message/send-records');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch send records');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch send records, using mock data:', error);
|
||||
return getMockSendRecords();
|
||||
}
|
||||
},
|
||||
|
||||
// 发送消息
|
||||
sendMessage: async (data: MessageSendFormData): Promise<MessageSendRecord> => {
|
||||
try {
|
||||
const response = await fetch('/api/message/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to send message');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn('Failed to send message, using mock response:', error);
|
||||
return createMockSendRecord(data);
|
||||
}
|
||||
},
|
||||
|
||||
// 取消定时消息
|
||||
cancelMessage: async (id: string): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch(`/api/message/send/${id}/cancel`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to cancel message');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to cancel message, updating local state:', error);
|
||||
// 模拟取消操作
|
||||
}
|
||||
},
|
||||
|
||||
// 删除发送记录
|
||||
deleteMessage: async (id: string): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch(`/api/message/send/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete message');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete message, updating local state:', error);
|
||||
// 模拟删除操作
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟数据生成函数
|
||||
function getMockTemplates(): MessageTemplate[] {
|
||||
return [
|
||||
{
|
||||
id: 'tpl-1',
|
||||
code: 'TASK_ASSIGNMENT',
|
||||
name: '任务分配通知',
|
||||
type: 'internal',
|
||||
subject: '新任务分配',
|
||||
content: '您好,{{username}}!您有新的作业任务:{{taskName}},计划执行时间:{{executeTime}}。请及时查看并准备。',
|
||||
variables: ['username', 'taskName', 'executeTime'],
|
||||
isActive: true,
|
||||
description: '向农机操作员分配新任务时发送',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'tpl-2',
|
||||
code: 'EQUIPMENT_WARNING',
|
||||
name: '设备预警通知',
|
||||
type: 'sms',
|
||||
content: '【智慧农业】设备预警:{{equipmentName}}检测到{{warningType}},请及时处理。',
|
||||
variables: ['equipmentName', 'warningType'],
|
||||
isActive: true,
|
||||
description: '设备出现异常时发送预警',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'tpl-3',
|
||||
code: 'MAINTENANCE_REMINDER',
|
||||
name: '保养提醒',
|
||||
type: 'email',
|
||||
subject: '设备保养提醒',
|
||||
content: '尊敬的用户:\n\n您的设备{{equipmentName}}(编号:{{equipmentNo}})已使用{{usageHours}}小时,建议进行保养维护。\n\n保养周期:每{{maintenanceInterval}}小时\n上次保养时间:{{lastMaintenanceTime}}\n\n请及时安排保养,确保设备正常运行。',
|
||||
variables: ['equipmentName', 'equipmentNo', 'usageHours', 'maintenanceInterval', 'lastMaintenanceTime'],
|
||||
isActive: true,
|
||||
description: '设备需要保养时发送提醒',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getMockSendRecords(): MessageSendRecord[] {
|
||||
return [
|
||||
{
|
||||
id: 'send-1',
|
||||
templateId: 'tpl-1',
|
||||
templateName: '任务分配通知',
|
||||
type: 'internal',
|
||||
recipients: ['张三', '李四', '王五'],
|
||||
recipientCount: 3,
|
||||
subject: '新任务分配',
|
||||
content: '您好,张三!您有新的作业任务:冬小麦播种,计划执行时间:2024-10-16 08:00。请及时查看并准备。',
|
||||
sendType: 'immediate',
|
||||
status: 'sent',
|
||||
sentCount: 3,
|
||||
sentAt: '2024-10-15T14:30:00',
|
||||
createdAt: '2024-10-15T14:30:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'send-2',
|
||||
templateId: 'tpl-2',
|
||||
templateName: '设备预警通知',
|
||||
type: 'sms',
|
||||
recipients: ['13800138001', '13900139002'],
|
||||
recipientCount: 2,
|
||||
content: '【智慧农业】设备预警:拖拉机01检测到异常,油温过高,请及时处理。',
|
||||
sendType: 'immediate',
|
||||
status: 'sent',
|
||||
sentCount: 2,
|
||||
sentAt: '2024-10-15T10:15:00',
|
||||
createdAt: '2024-10-15T10:15:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'send-3',
|
||||
templateId: 'tpl-3',
|
||||
templateName: '保养提醒',
|
||||
type: 'email',
|
||||
recipients: ['zhangsan@example.com', 'lisi@example.com'],
|
||||
recipientCount: 2,
|
||||
subject: '设备保养提醒',
|
||||
content: '尊敬的用户:\n\n您的设备拖拉机01(编号:TR001)已使用500小时,建议进行保养维护。\n\n保养周期:每500小时\n上次保养时间:2024-09-01\n\n请及时安排保养,确保设备正常运行。',
|
||||
sendType: 'scheduled',
|
||||
scheduledTime: '2024-10-16T09:00:00',
|
||||
status: 'pending',
|
||||
createdAt: '2024-10-15T15:00:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createMockSendRecord(data: MessageSendFormData): MessageSendRecord {
|
||||
const now = new Date().toISOString();
|
||||
const scheduledDateTime = data.sendType === 'scheduled' && data.scheduledDate
|
||||
? new Date(data.scheduledDate.getFullYear(), data.scheduledDate.getMonth(), data.scheduledDate.getDate(),
|
||||
parseInt(data.scheduledTime.split(':')[0]), parseInt(data.scheduledTime.split(':')[1])).toISOString()
|
||||
: undefined;
|
||||
|
||||
// 解析接收人
|
||||
const recipients = data.recipients.split(/[,,;;\n]/).map(r => r.trim()).filter(r => r);
|
||||
|
||||
// 替换变量生成最终内容
|
||||
const replaceVariables = (content: string, variables: Record<string, string>): string => {
|
||||
let result = content;
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value || `{{${key}}}`);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const finalContent = replaceVariables(data.content, data.variables);
|
||||
const finalSubject = data.subject ? replaceVariables(data.subject, data.variables) : undefined;
|
||||
|
||||
return {
|
||||
id: `send-${Date.now()}`,
|
||||
templateId: data.templateId,
|
||||
templateName: getMockTemplates().find(t => t.id === data.templateId)?.name || '',
|
||||
type: data.type,
|
||||
recipients,
|
||||
recipientCount: recipients.length,
|
||||
subject: finalSubject,
|
||||
content: finalContent,
|
||||
sendType: data.sendType,
|
||||
scheduledTime: scheduledDateTime,
|
||||
status: data.sendType === 'immediate' ? 'sent' : 'pending',
|
||||
sentCount: data.sendType === 'immediate' ? recipients.length : undefined,
|
||||
sentAt: data.sendType === 'immediate' ? now : undefined,
|
||||
createdAt: now,
|
||||
createdBy: 'admin',
|
||||
};
|
||||
}
|
||||
|
||||
export default function MessageSendPage() {
|
||||
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
|
||||
const [sendRecords, setSendRecords] = useState<MessageSendRecord[]>([]);
|
||||
const [showSendDialog, setShowSendDialog] = useState(false);
|
||||
const [showPreviewDialog, setShowPreviewDialog] = useState(false);
|
||||
const [previewRecord, setPreviewRecord] = useState<MessageSendRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<MessageSendFormData>({
|
||||
templateId: '',
|
||||
type: 'internal',
|
||||
recipientType: 'manual',
|
||||
recipients: '',
|
||||
subject: '',
|
||||
content: '',
|
||||
sendType: 'immediate',
|
||||
scheduledDate: undefined,
|
||||
scheduledTime: '09:00',
|
||||
variables: {},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const [templatesData, recordsData] = await Promise.all([
|
||||
messageApi.getTemplates(),
|
||||
messageApi.getSendRecords()
|
||||
]);
|
||||
setTemplates(templatesData);
|
||||
setSendRecords(recordsData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载数据失败');
|
||||
console.error('Failed to load data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSendDialog = () => {
|
||||
setFormData({
|
||||
templateId: '',
|
||||
type: 'internal',
|
||||
recipientType: 'manual',
|
||||
recipients: '',
|
||||
subject: '',
|
||||
content: '',
|
||||
sendType: 'immediate',
|
||||
scheduledDate: undefined,
|
||||
scheduledTime: '09:00',
|
||||
variables: {},
|
||||
});
|
||||
setShowSendDialog(true);
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
try {
|
||||
// 验证
|
||||
if (!formData.templateId) {
|
||||
toast.error('请选择消息模版');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.recipients.trim()) {
|
||||
toast.error('请输入接收人');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查变量是否填写
|
||||
const template = templates.find(t => t.id === formData.templateId);
|
||||
if (template) {
|
||||
const emptyVars = template.variables.filter(v => !formData.variables[v]?.trim());
|
||||
if (emptyVars.length > 0) {
|
||||
toast.error(`请填写变量:${emptyVars.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.sendType === 'scheduled' && !formData.scheduledDate) {
|
||||
toast.error('请选择定时发送日期');
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
const newRecord = await messageApi.sendMessage(formData);
|
||||
setSendRecords([newRecord, ...sendRecords]);
|
||||
|
||||
if (formData.sendType === 'immediate') {
|
||||
toast.success(`消息发送成功!已发送 ${newRecord.recipientCount} 条消息`);
|
||||
} else {
|
||||
toast.success(`定时消息已创建!将于 ${new Date(newRecord.scheduledTime!).toLocaleString('zh-CN')} 发送`);
|
||||
}
|
||||
|
||||
setShowSendDialog(false);
|
||||
} catch (err) {
|
||||
toast.error('发送失败:' + (err instanceof Error ? err.message : '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = (record: MessageSendRecord) => {
|
||||
setPreviewRecord(record);
|
||||
setShowPreviewDialog(true);
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
if (!confirm('确定要取消该定时消息吗?')) return;
|
||||
|
||||
try {
|
||||
await messageApi.cancelMessage(id);
|
||||
setSendRecords(sendRecords.map(r =>
|
||||
r.id === id ? { ...r, status: 'cancelled' as const } : r
|
||||
));
|
||||
toast.success('已取消定时消息');
|
||||
} catch (err) {
|
||||
toast.error('取消失败:' + (err instanceof Error ? err.message : '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除该发送记录吗?')) return;
|
||||
|
||||
try {
|
||||
await messageApi.deleteMessage(id);
|
||||
setSendRecords(sendRecords.filter(r => r.id !== id));
|
||||
toast.success('删除成功');
|
||||
} catch (err) {
|
||||
toast.error('删除失败:' + (err instanceof Error ? err.message : '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'sms': return <Smartphone className="w-4 h-4" />;
|
||||
case 'email': return <Mail className="w-4 h-4" />;
|
||||
case 'internal': return <MessageSquare className="w-4 h-4" />;
|
||||
case 'push': return <Bell className="w-4 h-4" />;
|
||||
default: return <MessageSquare className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
sms: '短信',
|
||||
email: '邮件',
|
||||
internal: '站内信',
|
||||
push: '推送',
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
sms: 'bg-blue-100 text-blue-700',
|
||||
email: 'bg-purple-100 text-purple-700',
|
||||
internal: 'bg-green-100 text-green-700',
|
||||
push: 'bg-orange-100 text-orange-700',
|
||||
};
|
||||
return colors[type] || 'bg-gray-100 text-gray-700';
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const config: Record<string, { label: string; className: string; icon: any }> = {
|
||||
pending: { label: '待发送', className: 'bg-yellow-100 text-yellow-700', icon: Timer },
|
||||
sending: { label: '发送中', className: 'bg-blue-100 text-blue-700', icon: Send },
|
||||
sent: { label: '已发送', className: 'bg-green-100 text-green-700', icon: CheckCircle2 },
|
||||
failed: { label: '发送失败', className: 'bg-red-100 text-red-700', icon: XCircle },
|
||||
cancelled: { label: '已取消', className: 'bg-gray-100 text-gray-700', icon: XCircle },
|
||||
};
|
||||
const { label, className, icon: Icon } = config[status] || config.pending;
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${className}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 border-4 border-green-600 border-t-transparent rounded-full animate-spin mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">正在加载数据...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800">消息发送</h2>
|
||||
<p className="text-muted-foreground">发送短信、邮件、站内信消息,支持实时和定时发送</p>
|
||||
</div>
|
||||
<Button onClick={handleOpenSendDialog} className="bg-green-600 hover:bg-green-700">
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
发送消息
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 border border-yellow-200 bg-yellow-50 rounded-md">
|
||||
<p className="text-yellow-800 text-sm">
|
||||
警告: {error} (当前显示为模拟数据)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<MessageStatsCards sendRecords={sendRecords} />
|
||||
|
||||
{/* 发送记录列表 */}
|
||||
<MessageSendTable
|
||||
sendRecords={sendRecords}
|
||||
onPreview={handlePreview}
|
||||
onCancel={handleCancel}
|
||||
onDelete={handleDelete}
|
||||
getTypeIcon={getTypeIcon}
|
||||
getTypeLabel={getTypeLabel}
|
||||
getTypeBadge={getTypeBadge}
|
||||
getStatusBadge={getStatusBadge}
|
||||
/>
|
||||
|
||||
{/* 发送消息对话框 */}
|
||||
<SendMessageDialog
|
||||
open={showSendDialog}
|
||||
onOpenChange={setShowSendDialog}
|
||||
templates={templates}
|
||||
formData={formData}
|
||||
onFormDataChange={setFormData}
|
||||
onSend={handleSend}
|
||||
getTypeIcon={getTypeIcon}
|
||||
getTypeLabel={getTypeLabel}
|
||||
/>
|
||||
|
||||
{/* 详情预览对话框 */}
|
||||
<MessagePreviewDialog
|
||||
open={showPreviewDialog}
|
||||
onOpenChange={setShowPreviewDialog}
|
||||
record={previewRecord}
|
||||
getTypeIcon={getTypeIcon}
|
||||
getTypeLabel={getTypeLabel}
|
||||
getTypeBadge={getTypeBadge}
|
||||
getStatusBadge={getStatusBadge}
|
||||
/>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<MessageInstructions />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
crop-x/src/app/(app)/central-config/message/send/types.ts
Normal file
12
crop-x/src/app/(app)/central-config/message/send/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface MessageSendFormData {
|
||||
templateId: string;
|
||||
type: 'sms' | 'email' | 'internal' | 'push';
|
||||
recipientType: 'manual' | 'role' | 'all';
|
||||
recipients: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
sendType: 'immediate' | 'scheduled';
|
||||
scheduledDate?: Date;
|
||||
scheduledTime: string;
|
||||
variables: Record<string, string>;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MessageTemplate } from '../types';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
interface MessageTemplateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingTemplate: MessageTemplate | null;
|
||||
onSave: (formData: FormData) => void;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
code: string;
|
||||
name: string;
|
||||
type: 'sms' | 'email' | 'internal' | 'push';
|
||||
subject: string;
|
||||
content: string;
|
||||
variables: string[];
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function MessageTemplateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editingTemplate,
|
||||
onSave
|
||||
}: MessageTemplateDialogProps) {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
code: '',
|
||||
name: '',
|
||||
type: 'internal',
|
||||
subject: '',
|
||||
content: '',
|
||||
variables: [],
|
||||
description: '',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// 使用useEffect来管理表单数据的状态
|
||||
useEffect(() => {
|
||||
if (editingTemplate) {
|
||||
setFormData({
|
||||
code: editingTemplate.code,
|
||||
name: editingTemplate.name,
|
||||
type: editingTemplate.type,
|
||||
subject: editingTemplate.subject || '',
|
||||
content: editingTemplate.content,
|
||||
variables: editingTemplate.variables,
|
||||
description: editingTemplate.description || '',
|
||||
isActive: editingTemplate.isActive,
|
||||
});
|
||||
} else if (open) {
|
||||
// 新增模式时重置表单
|
||||
setFormData({
|
||||
code: '',
|
||||
name: '',
|
||||
type: 'internal',
|
||||
subject: '',
|
||||
content: '',
|
||||
variables: [],
|
||||
description: '',
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
}, [editingTemplate, open]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingTemplate ? '编辑模版' : '新增模版'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{editingTemplate ? '编辑消息模版' : '添加新消息模版'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>模板编码 *</Label>
|
||||
<Input
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||
placeholder="TASK_ASSIGNED"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>模板名称 *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="任务分配通知"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>消息类型 *</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(value: any) => setFormData({ ...formData, type: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sms">短信</SelectItem>
|
||||
<SelectItem value="email">邮件</SelectItem>
|
||||
<SelectItem value="internal">站内信</SelectItem>
|
||||
<SelectItem value="push">推送</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Label>是否启用</Label>
|
||||
<Switch
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(formData.type === 'email' || formData.type === 'push') && (
|
||||
<div>
|
||||
<Label>消息主题</Label>
|
||||
<Input
|
||||
value={formData.subject}
|
||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||
placeholder="输入消息主题"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>消息内容 *</Label>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder="输入消息内容,使用 {{变量名}} 表示变量"
|
||||
rows={6}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
提示:使用 {'{{'} 和 {'}'} 包裹变量名,如 {'{{username}}'}、{'{{taskName}}'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>描述</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="模板用途说明"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, Download } from 'lucide-react';
|
||||
|
||||
interface MessageTemplateHeaderProps {
|
||||
onExport: () => void;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export function MessageTemplateHeader({ onExport, onAdd }: MessageTemplateHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800">消息模版</h2>
|
||||
<p className="text-muted-foreground">集中管理各类系统消息模板</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onExport}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出
|
||||
</Button>
|
||||
<Button onClick={onAdd}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增模版
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
export function MessageTemplateInfo() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="text-blue-900 mb-2">
|
||||
<MessageSquare className="w-4 h-4 inline mr-2" />
|
||||
消息模版说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800">
|
||||
<li>• 支持短信、邮件、站内信、推送四种消息类型</li>
|
||||
<li>• 使用 {'{{变量名}}'} 语法嵌入动态变量,系统会自动提取</li>
|
||||
<li>• 可以发送测试消息验证模板效果</li>
|
||||
<li>• 模板支持导入导出,便于跨环境迁移</li>
|
||||
<li>• 停用的模板不会被系统使用</li>
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { MessageTemplate } from '../types';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Edit, Trash2, Send, Mail, MessageSquare, Smartphone, Bell } from 'lucide-react';
|
||||
|
||||
interface MessageTemplateListProps {
|
||||
templates: MessageTemplate[];
|
||||
onEdit: (template: MessageTemplate) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onTest: (template: MessageTemplate) => void;
|
||||
}
|
||||
|
||||
export function MessageTemplateList({ templates, onEdit, onDelete, onTest }: MessageTemplateListProps) {
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'sms': return <Smartphone className="w-4 h-4" />;
|
||||
case 'email': return <Mail className="w-4 h-4" />;
|
||||
case 'internal': return <MessageSquare className="w-4 h-4" />;
|
||||
case 'push': return <Bell className="w-4 h-4" />;
|
||||
default: return <MessageSquare className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
sms: '短信',
|
||||
email: '邮件',
|
||||
internal: '站内信',
|
||||
push: '推送',
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
sms: 'bg-blue-100 text-blue-700',
|
||||
email: 'bg-purple-100 text-purple-700',
|
||||
internal: 'bg-green-100 text-green-700',
|
||||
push: 'bg-orange-100 text-orange-700',
|
||||
};
|
||||
return colors[type] || 'bg-gray-100 text-gray-700';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>模版编码</TableHead>
|
||||
<TableHead>模版名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>变量</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{templates.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||
暂无模版数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
templates.map((template) => (
|
||||
<TableRow key={template.id}>
|
||||
<TableCell>
|
||||
<code className="text-xs px-2 py-1 rounded">
|
||||
{template.code}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div>{template.name}</div>
|
||||
{template.description && (
|
||||
<p className="text-xs text-muted-foreground">{template.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getTypeBadge(template.type)}>
|
||||
<span className="flex items-center gap-1">
|
||||
{getTypeIcon(template.type)}
|
||||
{getTypeLabel(template.type)}
|
||||
</span>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{template.variables.map((variable, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{variable}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{template.isActive ? (
|
||||
<Badge className="bg-green-100 text-green-700">启用</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">停用</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => onTest(template)}>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => onEdit(template)}>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => onDelete(template.id)}>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
interface MessageTemplateSearchProps {
|
||||
searchKeyword: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
typeFilter: string;
|
||||
onTypeFilterChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function MessageTemplateSearch({
|
||||
searchKeyword,
|
||||
onSearchChange,
|
||||
typeFilter,
|
||||
onTypeFilterChange
|
||||
}: MessageTemplateSearchProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索模板名称、编码、内容..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={typeFilter} onValueChange={onTypeFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="消息类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="sms">短信</SelectItem>
|
||||
<SelectItem value="email">邮件</SelectItem>
|
||||
<SelectItem value="internal">站内信</SelectItem>
|
||||
<SelectItem value="push">推送</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MessageTemplate } from '../types';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Send } from 'lucide-react';
|
||||
|
||||
interface MessageTemplateTestDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
testTemplate: MessageTemplate | null;
|
||||
onSendTest: (testData: TestData) => void;
|
||||
}
|
||||
|
||||
interface TestData {
|
||||
recipient: string;
|
||||
variables: Record<string, string>;
|
||||
}
|
||||
|
||||
export function MessageTemplateTestDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
testTemplate,
|
||||
onSendTest
|
||||
}: MessageTemplateTestDialogProps) {
|
||||
const [testData, setTestData] = useState<TestData>({
|
||||
recipient: '',
|
||||
variables: {},
|
||||
});
|
||||
|
||||
// 使用useEffect来管理测试数据的状态
|
||||
useEffect(() => {
|
||||
if (testTemplate && open) {
|
||||
const varsObj: Record<string, string> = {};
|
||||
testTemplate.variables.forEach(v => {
|
||||
varsObj[v] = '';
|
||||
});
|
||||
setTestData({
|
||||
recipient: '',
|
||||
variables: varsObj,
|
||||
});
|
||||
}
|
||||
}, [testTemplate, open]);
|
||||
|
||||
const handleSendTest = () => {
|
||||
onSendTest(testData);
|
||||
};
|
||||
|
||||
const handleVariableChange = (variable: string, value: string) => {
|
||||
setTestData({
|
||||
...testData,
|
||||
variables: { ...testData.variables, [variable]: value }
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Send className="w-5 h-5 text-green-600" />
|
||||
发送测试消息
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
测试发送消息模版
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{testTemplate && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-900">
|
||||
模板:{testTemplate.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>接收人 *</Label>
|
||||
<Input
|
||||
value={testData.recipient}
|
||||
onChange={(e) => setTestData({ ...testData, recipient: e.target.value })}
|
||||
placeholder={
|
||||
testTemplate.type === 'sms' ? '手机号' :
|
||||
testTemplate.type === 'email' ? '邮箱地址' :
|
||||
'用户ID'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{testTemplate.variables.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<Label>填写变量值</Label>
|
||||
{testTemplate.variables.map(variable => (
|
||||
<div key={variable}>
|
||||
<Label className="text-sm text-muted-foreground">{variable}</Label>
|
||||
<Input
|
||||
value={testData.variables[variable] || ''}
|
||||
onChange={(e) => handleVariableChange(variable, e.target.value)}
|
||||
placeholder={`输入 ${variable} 的值`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSendTest}>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
发送测试
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { MessageTemplateList } from './MessageTemplateList';
|
||||
export { MessageTemplateSearch } from './MessageTemplateSearch';
|
||||
export { MessageTemplateHeader } from './MessageTemplateHeader';
|
||||
export { MessageTemplateDialog } from './MessageTemplateDialog';
|
||||
export { MessageTemplateTestDialog } from './MessageTemplateTestDialog';
|
||||
export { MessageTemplateInfo } from './MessageTemplateInfo';
|
||||
269
crop-x/src/app/(app)/central-config/message/template/page.tsx
Normal file
269
crop-x/src/app/(app)/central-config/message/template/page.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { MessageTemplate as MessageTemplateType } from './types';
|
||||
import {
|
||||
MessageTemplateList,
|
||||
MessageTemplateSearch,
|
||||
MessageTemplateHeader,
|
||||
MessageTemplateDialog,
|
||||
MessageTemplateTestDialog,
|
||||
MessageTemplateInfo
|
||||
} from './components';
|
||||
|
||||
export default function MessageTemplatePage() {
|
||||
const [templates, setTemplates] = useState<MessageTemplateType[]>([]);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [showTestDialog, setShowTestDialog] = useState(false);
|
||||
const [editingTemplate, setEditingTemplate] = useState<MessageTemplateType | null>(null);
|
||||
const [testTemplate, setTestTemplate] = useState<MessageTemplateType | null>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
const loadTemplates = () => {
|
||||
// 清除旧的localStorage数据,确保使用最新的mock数据
|
||||
localStorage.removeItem('smart_agriculture_message_templates');
|
||||
|
||||
const data = localStorage.getItem('smart_agriculture_message_templates');
|
||||
if (data) {
|
||||
try {
|
||||
const parsedData = JSON.parse(data);
|
||||
console.log('Loaded templates from localStorage:', parsedData);
|
||||
// 确保数据是数组格式
|
||||
if (Array.isArray(parsedData)) {
|
||||
setTemplates(parsedData);
|
||||
} else {
|
||||
console.error('Loaded data is not an array:', parsedData);
|
||||
setTemplates([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing templates from localStorage:', error);
|
||||
setTemplates([]);
|
||||
}
|
||||
} else {
|
||||
const mockTemplates: MessageTemplateType[] = [
|
||||
{
|
||||
id: 'tpl-1',
|
||||
code: 'TASK_ASSIGNED',
|
||||
name: '任务分配通知',
|
||||
type: 'internal',
|
||||
subject: '新任务分配',
|
||||
content: '您好,{{username}}!您有新的作业任务:{{taskName}},计划执行时间:{{executeTime}}。请及时查看并准备。',
|
||||
variables: ['username', 'taskName', 'executeTime'],
|
||||
isActive: true,
|
||||
description: '当任务分配给驾驶员时发送',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'tpl-2',
|
||||
code: 'EQUIPMENT_WARNING',
|
||||
name: '设备预警通知',
|
||||
type: 'sms',
|
||||
content: '【智慧农业】设备预警:{{equipmentName}}检测到异常,{{warningType}},请及时处理。',
|
||||
variables: ['equipmentName', 'warningType'],
|
||||
isActive: true,
|
||||
description: '设备出现异常时发送短信通知',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'tpl-3',
|
||||
code: 'MAINTENANCE_REMINDER',
|
||||
name: '保养提醒',
|
||||
type: 'email',
|
||||
subject: '设备保养提醒',
|
||||
content: '尊敬的用户:\n\n您的设备{{equipmentName}}(编号:{{equipmentCode}})已使用{{hours}}小时,建议进行保养维护。\n\n保养周期:{{maintenanceCycle}}\n上次保养时间:{{lastMaintenanceTime}}\n\n请及时安排保养,确保设备正常运行。\n\n智慧农业管理系统',
|
||||
variables: ['equipmentName', 'equipmentCode', 'hours', 'maintenanceCycle', 'lastMaintenanceTime'],
|
||||
isActive: true,
|
||||
description: '设备到达保养周期时发送邮件提醒',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'tpl-4',
|
||||
code: 'TASK_COMPLETED',
|
||||
name: '任务完成通知',
|
||||
type: 'push',
|
||||
subject: '任务完成',
|
||||
content: '作业任务{{taskName}}已完成,作业面积:{{area}}亩,耗时:{{duration}}。',
|
||||
variables: ['taskName', 'area', 'duration'],
|
||||
isActive: true,
|
||||
description: '任务完成后推送通知',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
];
|
||||
console.log('Created mock templates:', mockTemplates);
|
||||
localStorage.setItem('smart_agriculture_message_templates', JSON.stringify(mockTemplates));
|
||||
setTemplates(mockTemplates);
|
||||
}
|
||||
};
|
||||
|
||||
const saveTemplates = (newTemplates: MessageTemplateType[]) => {
|
||||
localStorage.setItem('smart_agriculture_message_templates', JSON.stringify(newTemplates));
|
||||
setTemplates(newTemplates);
|
||||
};
|
||||
|
||||
const filteredTemplates = templates.filter(tpl => {
|
||||
const matchKeyword = !searchKeyword ||
|
||||
tpl.name.includes(searchKeyword) ||
|
||||
tpl.code.includes(searchKeyword) ||
|
||||
tpl.content.includes(searchKeyword);
|
||||
const matchType = typeFilter === 'all' || tpl.type === typeFilter;
|
||||
return matchKeyword && matchType;
|
||||
});
|
||||
|
||||
// 调试日志
|
||||
console.log('Original templates:', templates);
|
||||
console.log('Filtered templates:', filteredTemplates);
|
||||
console.log('Search keyword:', searchKeyword);
|
||||
console.log('Type filter:', typeFilter);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingTemplate(null);
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleEdit = (template: MessageTemplateType) => {
|
||||
setEditingTemplate(template);
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
const updated = templates.filter(t => t.id !== id);
|
||||
saveTemplates(updated);
|
||||
toast.success('删除成功');
|
||||
};
|
||||
|
||||
const handleSave = (formData: any) => {
|
||||
if (!formData.code.trim() || !formData.name.trim() || !formData.content.trim()) {
|
||||
toast.error('请填写必填项');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// 提取变量
|
||||
const variableRegex = /\{\{(\w+)\}\}/g;
|
||||
const matches = formData.content.matchAll(variableRegex);
|
||||
const extractedVars = Array.from(new Set(Array.from(matches, m => m[1])));
|
||||
|
||||
if (editingTemplate) {
|
||||
const updated = templates.map(t =>
|
||||
t.id === editingTemplate.id
|
||||
? {
|
||||
...t,
|
||||
...formData,
|
||||
variables: extractedVars,
|
||||
updatedAt: now,
|
||||
}
|
||||
: t
|
||||
);
|
||||
saveTemplates(updated);
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
const newTemplate: MessageTemplateType = {
|
||||
id: `tpl-${Date.now()}`,
|
||||
...formData,
|
||||
variables: extractedVars,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'admin',
|
||||
};
|
||||
saveTemplates([...templates, newTemplate]);
|
||||
toast.success('添加成功');
|
||||
}
|
||||
|
||||
setShowDialog(false);
|
||||
};
|
||||
|
||||
const handleTest = (template: MessageTemplateType) => {
|
||||
setTestTemplate(template);
|
||||
setShowTestDialog(true);
|
||||
};
|
||||
|
||||
const handleSendTest = (testData: any) => {
|
||||
if (!testData.recipient.trim()) {
|
||||
toast.error('请输入接收人');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查变量是否都填写了
|
||||
const emptyVars = Object.entries(testData.variables).filter(([k, v]) => !v.trim());
|
||||
if (emptyVars.length > 0) {
|
||||
toast.error('请填写变量:' + emptyVars.map(([k]) => k).join(', '));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('测试消息发送成功');
|
||||
setShowTestDialog(false);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const dataStr = JSON.stringify(filteredTemplates, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `message_templates_${new Date().getTime()}.json`;
|
||||
link.click();
|
||||
toast.success('导出成功');
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面头部 */}
|
||||
<MessageTemplateHeader
|
||||
onExport={handleExport}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<MessageTemplateSearch
|
||||
searchKeyword={searchKeyword}
|
||||
onSearchChange={setSearchKeyword}
|
||||
typeFilter={typeFilter}
|
||||
onTypeFilterChange={setTypeFilter}
|
||||
/>
|
||||
|
||||
{/* 模版列表 */}
|
||||
<MessageTemplateList
|
||||
templates={filteredTemplates}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onTest={handleTest}
|
||||
/>
|
||||
|
||||
{/* 编辑对话框 */}
|
||||
<MessageTemplateDialog
|
||||
open={showDialog}
|
||||
onOpenChange={setShowDialog}
|
||||
editingTemplate={editingTemplate}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* 测试对话框 */}
|
||||
<MessageTemplateTestDialog
|
||||
open={showTestDialog}
|
||||
onOpenChange={setShowTestDialog}
|
||||
testTemplate={testTemplate}
|
||||
onSendTest={handleSendTest}
|
||||
/>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<MessageTemplateInfo />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface MessageTemplate {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
type: 'sms' | 'email' | 'internal' | 'push';
|
||||
subject: string;
|
||||
content: string;
|
||||
variables: string[];
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
interface LoginLogFiltersProps {
|
||||
searchKeyword: string
|
||||
onSearchChange: (value: string) => void
|
||||
statusFilter: string
|
||||
onStatusFilterChange: (value: string) => void
|
||||
dateRange: string
|
||||
onDateRangeChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function LoginLogFilters({
|
||||
searchKeyword,
|
||||
onSearchChange,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
dateRange,
|
||||
onDateRangeChange
|
||||
}: LoginLogFiltersProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索用户名、IP地址、位置..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="登录状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="success">成功</SelectItem>
|
||||
<SelectItem value="failed">失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={dateRange} onValueChange={onDateRangeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="时间范围" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部时间</SelectItem>
|
||||
<SelectItem value="today">今天</SelectItem>
|
||||
<SelectItem value="week">近7天</SelectItem>
|
||||
<SelectItem value="month">近30天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Shield } from 'lucide-react'
|
||||
|
||||
export function LoginLogInfo() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="text-blue-900 mb-2">
|
||||
<Shield className="w-4 h-4 inline mr-2" />
|
||||
登录日志说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800">
|
||||
<li>• 记录所有用户的登录行为,包括成功和失败的登录尝试</li>
|
||||
<li>• 支持按用户名、IP地址、位置等多条件检索</li>
|
||||
<li>• 登录失败会记录失败原因,便于安全审计</li>
|
||||
<li>• 可用于识别异常登录行为和未授权访问尝试</li>
|
||||
<li>• 日志数据可导出用于长期归档和合规审计</li>
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { LoginLog } from '@/types/monitor'
|
||||
|
||||
interface LoginLogStatsProps {
|
||||
logs: LoginLog[]
|
||||
}
|
||||
|
||||
export function LoginLogStats({ logs }: LoginLogStatsProps) {
|
||||
const stats = [
|
||||
{
|
||||
label: '总登录次数',
|
||||
value: logs.length,
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-100',
|
||||
},
|
||||
{
|
||||
label: '成功登录',
|
||||
value: logs.filter(l => l.status === 'success').length,
|
||||
color: 'text-green-600',
|
||||
bg: 'bg-green-100',
|
||||
},
|
||||
{
|
||||
label: '失败登录',
|
||||
value: logs.filter(l => l.status === 'failed').length,
|
||||
color: 'text-red-600',
|
||||
bg: 'bg-red-100',
|
||||
},
|
||||
{
|
||||
label: '今日登录',
|
||||
value: logs.filter(l => {
|
||||
const logDate = new Date(l.loginTime)
|
||||
const today = new Date()
|
||||
return logDate.toDateString() === today.toDateString()
|
||||
}).length,
|
||||
color: 'text-purple-600',
|
||||
bg: 'bg-purple-100',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="text-sm text-muted-foreground">{stat.label}</div>
|
||||
<div className={`mt-2 ${stat.color}`}>{stat.value}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { LoginLog } from '@/types/monitor'
|
||||
import { Shield, Monitor, MapPin } from 'lucide-react'
|
||||
|
||||
interface LoginLogTableProps {
|
||||
logs: LoginLog[]
|
||||
}
|
||||
|
||||
export function LoginLogTable({ logs }: LoginLogTableProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>用户名</TableHead>
|
||||
<TableHead>登录时间</TableHead>
|
||||
<TableHead>IP地址</TableHead>
|
||||
<TableHead>设备/浏览器</TableHead>
|
||||
<TableHead>位置</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||
暂无登录日志
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-gray-400" />
|
||||
{log.username}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{new Date(log.loginTime).toLocaleString('zh-CN')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs px-2 py-1 rounded">
|
||||
{log.ipAddress}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-gray-400" />
|
||||
<div className="text-sm">
|
||||
<div>{log.device}</div>
|
||||
{log.browser && (
|
||||
<div className="text-xs text-muted-foreground">{log.browser}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.location && (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<MapPin className="w-3 h-3 text-gray-400" />
|
||||
{log.location}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.status === 'success' ? (
|
||||
<Badge className="bg-green-100 text-green-700">成功</Badge>
|
||||
) : (
|
||||
<div>
|
||||
<Badge className="bg-red-100 text-red-700">失败</Badge>
|
||||
{log.failReason && (
|
||||
<p className="text-xs text-red-600 mt-1">{log.failReason}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { LoginLogStats } from './LoginLogStats'
|
||||
export { LoginLogFilters } from './LoginLogFilters'
|
||||
export { LoginLogTable } from './LoginLogTable'
|
||||
export { LoginLogInfo } from './LoginLogInfo'
|
||||
202
crop-x/src/app/(app)/central-config/monitor/login-log/page.tsx
Normal file
202
crop-x/src/app/(app)/central-config/monitor/login-log/page.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LoginLog } from '@/types/monitor'
|
||||
import { Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// Import modular components
|
||||
import {
|
||||
LoginLogStats,
|
||||
LoginLogFilters,
|
||||
LoginLogTable,
|
||||
LoginLogInfo
|
||||
} from './components'
|
||||
|
||||
export default function LoginLogPage() {
|
||||
const [logs, setLogs] = useState<LoginLog[]>([])
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [dateRange, setDateRange] = useState<string>('all')
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [])
|
||||
|
||||
const loadLogs = () => {
|
||||
// 强制重新加载mock数据以解决显示问题
|
||||
localStorage.removeItem('smart_agriculture_login_logs')
|
||||
|
||||
const mockLogs: LoginLog[] = [
|
||||
{
|
||||
id: 'log-1',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
loginTime: '2024-10-21T09:30:00',
|
||||
ipAddress: '192.168.1.100',
|
||||
device: 'Windows 11',
|
||||
browser: 'Chrome 118',
|
||||
os: 'Windows',
|
||||
location: '北京市海淀区',
|
||||
status: 'success',
|
||||
sessionId: 'sess-001',
|
||||
},
|
||||
{
|
||||
id: 'log-2',
|
||||
userId: 'user-2',
|
||||
username: 'zhangsan',
|
||||
loginTime: '2024-10-21T10:15:00',
|
||||
ipAddress: '192.168.1.101',
|
||||
device: 'macOS 14',
|
||||
browser: 'Safari 17',
|
||||
os: 'macOS',
|
||||
location: '上海市浦东新区',
|
||||
status: 'success',
|
||||
sessionId: 'sess-002',
|
||||
},
|
||||
{
|
||||
id: 'log-3',
|
||||
userId: 'user-3',
|
||||
username: 'lisi',
|
||||
loginTime: '2024-10-21T11:20:00',
|
||||
ipAddress: '192.168.1.102',
|
||||
device: 'Android 13',
|
||||
browser: 'Chrome Mobile 118',
|
||||
os: 'Android',
|
||||
location: '广州市天河区',
|
||||
status: 'failed',
|
||||
failReason: '密码错误',
|
||||
},
|
||||
{
|
||||
id: 'log-4',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
loginTime: '2024-10-21T14:45:00',
|
||||
ipAddress: '192.168.1.100',
|
||||
device: 'Windows 11',
|
||||
browser: 'Chrome 118',
|
||||
os: 'Windows',
|
||||
location: '北京市海淀区',
|
||||
status: 'success',
|
||||
sessionId: 'sess-003',
|
||||
},
|
||||
{
|
||||
id: 'log-5',
|
||||
userId: 'user-4',
|
||||
username: 'wangwu',
|
||||
loginTime: '2024-10-21T15:30:00',
|
||||
ipAddress: '192.168.1.103',
|
||||
device: 'iOS 17',
|
||||
browser: 'Safari 17',
|
||||
os: 'iOS',
|
||||
location: '深圳市南山区',
|
||||
status: 'failed',
|
||||
failReason: '账号被锁定',
|
||||
},
|
||||
{
|
||||
id: 'log-6',
|
||||
userId: 'user-5',
|
||||
username: 'zhaoliu',
|
||||
loginTime: '2024-10-21T16:20:00',
|
||||
ipAddress: '192.168.1.104',
|
||||
device: 'Windows 10',
|
||||
browser: 'Edge 118',
|
||||
os: 'Windows',
|
||||
location: '杭州市西湖区',
|
||||
status: 'success',
|
||||
sessionId: 'sess-004',
|
||||
},
|
||||
{
|
||||
id: 'log-7',
|
||||
userId: 'user-6',
|
||||
username: 'chenqi',
|
||||
loginTime: '2024-10-21T17:10:00',
|
||||
ipAddress: '192.168.1.105',
|
||||
device: 'Ubuntu 22.04',
|
||||
browser: 'Firefox 119',
|
||||
os: 'Linux',
|
||||
location: '成都市武侯区',
|
||||
status: 'success',
|
||||
sessionId: 'sess-005',
|
||||
},
|
||||
]
|
||||
localStorage.setItem('smart_agriculture_login_logs', JSON.stringify(mockLogs))
|
||||
setLogs(mockLogs)
|
||||
}
|
||||
|
||||
const filteredLogs = logs.filter(log => {
|
||||
const matchKeyword = !searchKeyword ||
|
||||
log.username.includes(searchKeyword) ||
|
||||
log.ipAddress.includes(searchKeyword) ||
|
||||
(log.location && log.location.includes(searchKeyword))
|
||||
|
||||
const matchStatus = statusFilter === 'all' || log.status === statusFilter
|
||||
|
||||
let matchDate = true
|
||||
if (dateRange !== 'all') {
|
||||
const logDate = new Date(log.loginTime)
|
||||
const now = new Date()
|
||||
const diffDays = Math.floor((now.getTime() - logDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
switch (dateRange) {
|
||||
case 'today':
|
||||
matchDate = diffDays === 0
|
||||
break
|
||||
case 'week':
|
||||
matchDate = diffDays <= 7
|
||||
break
|
||||
case 'month':
|
||||
matchDate = diffDays <= 30
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return matchKeyword && matchStatus && matchDate
|
||||
})
|
||||
|
||||
const handleExport = () => {
|
||||
const dataStr = JSON.stringify(filteredLogs, null, 2)
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(dataBlob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `login_logs_${new Date().getTime()}.json`
|
||||
link.click()
|
||||
toast.success('导出成功')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800">登录日志</h2>
|
||||
<p className="text-muted-foreground">全面记录所有用户的登录行为</p>
|
||||
</div>
|
||||
<Button onClick={handleExport}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出日志
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<LoginLogStats logs={logs} />
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<LoginLogFilters
|
||||
searchKeyword={searchKeyword}
|
||||
onSearchChange={setSearchKeyword}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
dateRange={dateRange}
|
||||
onDateRangeChange={setDateRange}
|
||||
/>
|
||||
|
||||
{/* 日志列表 */}
|
||||
<LoginLogTable logs={filteredLogs} />
|
||||
|
||||
{/* 使用说明 */}
|
||||
<LoginLogInfo />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { NetworkLog } from '@/types/monitor'
|
||||
import { Globe } from 'lucide-react'
|
||||
|
||||
interface NetworkLogDetailDialogProps {
|
||||
log: NetworkLog | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function NetworkLogDetailDialog({
|
||||
log,
|
||||
isOpen,
|
||||
onClose,
|
||||
isLoading = false
|
||||
}: NetworkLogDetailDialogProps) {
|
||||
const getMethodBadge = (method: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
GET: 'bg-blue-100 text-blue-700',
|
||||
POST: 'bg-green-100 text-green-700',
|
||||
PUT: 'bg-yellow-100 text-yellow-700',
|
||||
DELETE: 'bg-red-100 text-red-700',
|
||||
PATCH: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
return colors[method] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: number) => {
|
||||
if (status >= 200 && status < 300) {
|
||||
return 'bg-green-100 text-green-700'
|
||||
} else if (status >= 400 && status < 500) {
|
||||
return 'bg-yellow-100 text-yellow-700'
|
||||
} else if (status >= 500) {
|
||||
return 'bg-red-100 text-red-700'
|
||||
}
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-green-600" />
|
||||
网络请求详情
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="bg-gray-200 h-4 w-24 rounded mb-2"></div>
|
||||
<div className="bg-gray-200 h-6 w-40 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : log ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求时间</p>
|
||||
<p className="mt-1">{new Date(log.timestamp).toLocaleString('zh-CN')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求方法</p>
|
||||
<p className="mt-1">
|
||||
<Badge className={getMethodBadge(log.method)}>
|
||||
{log.method}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-sm text-muted-foreground">请求URL</p>
|
||||
<p className="mt-1">
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded block break-all">
|
||||
{log.url}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">客户端IP</p>
|
||||
<p className="mt-1">
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{log.clientIp}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">用户</p>
|
||||
<p className="mt-1">{log.username || '未登录'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">响应状态</p>
|
||||
<p className="mt-1">
|
||||
<Badge className={getStatusBadge(log.responseStatus)}>
|
||||
{log.responseStatus}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">响应时间</p>
|
||||
<p className="mt-1">{log.responseTime}ms</p>
|
||||
</div>
|
||||
{log.responseSize && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">响应大小</p>
|
||||
<p className="mt-1">{formatBytes(log.responseSize)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{log.requestParams && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求参数</p>
|
||||
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto">
|
||||
{log.requestParams}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.requestHeaders && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求头</p>
|
||||
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(log.requestHeaders), null, 2)
|
||||
} catch {
|
||||
return log.requestHeaders
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.requestBody && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求体</p>
|
||||
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto">
|
||||
{log.requestBody}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.responseBody && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">响应体</p>
|
||||
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto max-h-40 overflow-y-auto">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(log.responseBody), null, 2)
|
||||
} catch {
|
||||
return log.responseBody
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.userAgent && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">User Agent</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground break-all">
|
||||
{log.userAgent}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
interface NetworkLogFiltersProps {
|
||||
searchKeyword: string
|
||||
onSearchChange: (value: string) => void
|
||||
methodFilter: string
|
||||
onMethodFilterChange: (value: string) => void
|
||||
statusFilter: string
|
||||
onStatusFilterChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function NetworkLogFilters({
|
||||
searchKeyword,
|
||||
onSearchChange,
|
||||
methodFilter,
|
||||
onMethodFilterChange,
|
||||
statusFilter,
|
||||
onStatusFilterChange
|
||||
}: NetworkLogFiltersProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索URL、用户名..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={methodFilter} onValueChange={onMethodFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请求方法" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部方法</SelectItem>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="响应状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="2xx">2xx 成功</SelectItem>
|
||||
<SelectItem value="4xx">4xx 客户端错误</SelectItem>
|
||||
<SelectItem value="5xx">5xx 服务器错误</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Globe } from 'lucide-react'
|
||||
|
||||
export function NetworkLogInfo() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="text-blue-900 mb-2">
|
||||
<Globe className="w-4 h-4 inline mr-2" />
|
||||
网络日志说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800">
|
||||
<li>• 记录所有HTTP请求的详细信息,包括请求地址、参数、响应状态等</li>
|
||||
<li>• 响应时间超过1秒的请求需要关注,可能存在性能问题</li>
|
||||
<li>• 4xx状态码通常表示客户端错误,5xx表示服务器错误</li>
|
||||
<li>• 可用于接口调试、性能分析和异常排查</li>
|
||||
<li>• 建议定期清理历史日志,避免占用过多存储空间</li>
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { NetworkLog } from '@/types/monitor'
|
||||
import { ApiResponse, PaginatedResponse, PaginationParams } from '@/types'
|
||||
|
||||
export interface NetworkLogFilters {
|
||||
searchKeyword?: string
|
||||
method?: string
|
||||
status?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
minResponseTime?: number
|
||||
maxResponseTime?: number
|
||||
}
|
||||
|
||||
export interface NetworkLogListParams extends PaginationParams {
|
||||
filters?: NetworkLogFilters
|
||||
}
|
||||
|
||||
export class NetworkLogService {
|
||||
private static baseUrl = '/api/monitor/network-logs'
|
||||
|
||||
/**
|
||||
* 获取网络日志列表
|
||||
*/
|
||||
static async getNetworkLogs(params: NetworkLogListParams = {}): Promise<PaginatedResponse<NetworkLog>> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
// 添加分页参数
|
||||
if (params.page) queryParams.append('page', params.page.toString())
|
||||
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString())
|
||||
|
||||
// 添加筛选参数
|
||||
if (params.filters) {
|
||||
if (params.filters.searchKeyword) queryParams.append('searchKeyword', params.filters.searchKeyword)
|
||||
if (params.filters.method && params.filters.method !== 'all') queryParams.append('method', params.filters.method)
|
||||
if (params.filters.status && params.filters.status !== 'all') queryParams.append('status', params.filters.status)
|
||||
if (params.filters.startDate) queryParams.append('startDate', params.filters.startDate)
|
||||
if (params.filters.endDate) queryParams.append('endDate', params.filters.endDate)
|
||||
if (params.filters.minResponseTime) queryParams.append('minResponseTime', params.filters.minResponseTime.toString())
|
||||
if (params.filters.maxResponseTime) queryParams.append('maxResponseTime', params.filters.maxResponseTime.toString())
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}?${queryParams}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch network logs:', error)
|
||||
// 降级处理:返回mock数据
|
||||
return this.getMockData(params)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网络日志详情
|
||||
*/
|
||||
static async getNetworkLogDetail(id: string): Promise<ApiResponse<NetworkLog>> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/${id}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch network log detail:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出网络日志
|
||||
*/
|
||||
static async exportNetworkLogs(filters?: NetworkLogFilters): Promise<Blob> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
if (filters) {
|
||||
if (filters.searchKeyword) queryParams.append('searchKeyword', filters.searchKeyword)
|
||||
if (filters.method && filters.method !== 'all') queryParams.append('method', filters.method)
|
||||
if (filters.status && filters.status !== 'all') queryParams.append('status', filters.status)
|
||||
if (filters.startDate) queryParams.append('startDate', filters.startDate)
|
||||
if (filters.endDate) queryParams.append('endDate', filters.endDate)
|
||||
if (filters.minResponseTime) queryParams.append('minResponseTime', filters.minResponseTime.toString())
|
||||
if (filters.maxResponseTime) queryParams.append('maxResponseTime', filters.maxResponseTime.toString())
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/export?${queryParams}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.blob()
|
||||
} catch (error) {
|
||||
console.error('Failed to export network logs:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网络日志统计信息
|
||||
*/
|
||||
static async getNetworkLogStats(filters?: NetworkLogFilters): Promise<ApiResponse<{
|
||||
total: number
|
||||
success: number
|
||||
clientError: number
|
||||
serverError: number
|
||||
averageResponseTime: number
|
||||
totalResponseSize: number
|
||||
methodStats: Array<{ method: string, count: number }>
|
||||
statusStats: Array<{ status: number, count: number }>
|
||||
topSlowRequests: Array<{ url: string, responseTime: number, count: number }>
|
||||
}>> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
if (filters) {
|
||||
if (filters.method && filters.method !== 'all') queryParams.append('method', filters.method)
|
||||
if (filters.status && filters.status !== 'all') queryParams.append('status', filters.status)
|
||||
if (filters.startDate) queryParams.append('startDate', filters.startDate)
|
||||
if (filters.endDate) queryParams.append('endDate', filters.endDate)
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/stats?${queryParams}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch network log stats:', error)
|
||||
// 降级处理:返回mock统计数据
|
||||
return this.getMockStats()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mock数据 - 用于降级处理
|
||||
*/
|
||||
private static getMockData(params: NetworkLogListParams): PaginatedResponse<NetworkLog> {
|
||||
const mockLogs: NetworkLog[] = [
|
||||
{
|
||||
id: 'net-1',
|
||||
timestamp: '2024-10-21T09:35:00',
|
||||
method: 'POST',
|
||||
url: '/api/users',
|
||||
requestParams: 'username=zhangsan&name=张三',
|
||||
responseStatus: 200,
|
||||
responseTime: 150,
|
||||
responseSize: 256,
|
||||
clientIp: '192.168.1.100',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/118.0.0.0',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'net-2',
|
||||
timestamp: '2024-10-21T10:20:00',
|
||||
method: 'GET',
|
||||
url: '/api/machinery/list',
|
||||
requestParams: 'page=1&size=10',
|
||||
responseStatus: 200,
|
||||
responseTime: 89,
|
||||
responseSize: 4096,
|
||||
clientIp: '192.168.1.101',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17.0',
|
||||
userId: 'user-2',
|
||||
username: 'zhangsan',
|
||||
},
|
||||
{
|
||||
id: 'net-3',
|
||||
timestamp: '2024-10-21T11:25:00',
|
||||
method: 'DELETE',
|
||||
url: '/api/roles/456',
|
||||
responseStatus: 400,
|
||||
responseTime: 120,
|
||||
responseSize: 128,
|
||||
clientIp: '192.168.1.102',
|
||||
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/118.0.0.0',
|
||||
userId: 'user-3',
|
||||
username: 'lisi',
|
||||
},
|
||||
{
|
||||
id: 'net-4',
|
||||
timestamp: '2024-10-21T14:50:00',
|
||||
method: 'PUT',
|
||||
url: '/api/system/settings',
|
||||
requestParams: 'sessionTimeout=30',
|
||||
responseStatus: 200,
|
||||
responseTime: 95,
|
||||
responseSize: 512,
|
||||
clientIp: '192.168.1.100',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/118.0.0.0',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'net-5',
|
||||
timestamp: '2024-10-21T15:35:00',
|
||||
method: 'POST',
|
||||
url: '/api/tasks',
|
||||
responseStatus: 201,
|
||||
responseTime: 180,
|
||||
responseSize: 1024,
|
||||
clientIp: '192.168.1.101',
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/17.0',
|
||||
userId: 'user-2',
|
||||
username: 'zhangsan',
|
||||
},
|
||||
{
|
||||
id: 'net-6',
|
||||
timestamp: '2024-10-21T16:15:00',
|
||||
method: 'GET',
|
||||
url: '/api/users/export',
|
||||
requestParams: 'format=excel',
|
||||
responseStatus: 200,
|
||||
responseTime: 1250,
|
||||
responseSize: 102400,
|
||||
clientIp: '192.168.1.100',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/118.0.0.0',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
},
|
||||
]
|
||||
|
||||
// 应用筛选器
|
||||
let filteredLogs = mockLogs.filter(log => {
|
||||
if (params.filters?.searchKeyword) {
|
||||
const keyword = params.filters.searchKeyword.toLowerCase()
|
||||
if (!log.url.toLowerCase().includes(keyword) &&
|
||||
!(log.username && log.username.toLowerCase().includes(keyword))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (params.filters?.method && params.filters.method !== 'all') {
|
||||
if (log.method !== params.filters.method) return false
|
||||
}
|
||||
|
||||
if (params.filters?.status && params.filters.status !== 'all') {
|
||||
const status = log.responseStatus
|
||||
switch (params.filters.status) {
|
||||
case '2xx':
|
||||
if (status < 200 || status >= 300) return false
|
||||
break
|
||||
case '4xx':
|
||||
if (status < 400 || status >= 500) return false
|
||||
break
|
||||
case '5xx':
|
||||
if (status < 500) return false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (params.filters?.minResponseTime && log.responseTime < params.filters.minResponseTime) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (params.filters?.maxResponseTime && log.responseTime > params.filters.maxResponseTime) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// 应用分页
|
||||
const page = params.page || 1
|
||||
const pageSize = params.pageSize || 10
|
||||
const startIndex = (page - 1) * pageSize
|
||||
const endIndex = startIndex + pageSize
|
||||
const paginatedLogs = filteredLogs.slice(startIndex, endIndex)
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'success',
|
||||
success: true,
|
||||
data: paginatedLogs,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total: filteredLogs.length,
|
||||
totalPages: Math.ceil(filteredLogs.length / pageSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static getMockStats(): ApiResponse<{
|
||||
total: number
|
||||
success: number
|
||||
clientError: number
|
||||
serverError: number
|
||||
averageResponseTime: number
|
||||
totalResponseSize: number
|
||||
methodStats: Array<{ method: string, count: number }>
|
||||
statusStats: Array<{ status: number, count: number }>
|
||||
topSlowRequests: Array<{ url: string, responseTime: number, count: number }>
|
||||
}> {
|
||||
return {
|
||||
code: 200,
|
||||
message: 'success',
|
||||
success: true,
|
||||
data: {
|
||||
total: 6,
|
||||
success: 4,
|
||||
clientError: 1,
|
||||
serverError: 1,
|
||||
averageResponseTime: 314,
|
||||
totalResponseSize: 108416,
|
||||
methodStats: [
|
||||
{ method: 'GET', count: 2 },
|
||||
{ method: 'POST', count: 2 },
|
||||
{ method: 'PUT', count: 1 },
|
||||
{ method: 'DELETE', count: 1 }
|
||||
],
|
||||
statusStats: [
|
||||
{ status: 200, count: 3 },
|
||||
{ status: 201, count: 1 },
|
||||
{ status: 400, count: 1 },
|
||||
{ status: 500, count: 1 }
|
||||
],
|
||||
topSlowRequests: [
|
||||
{ url: '/api/users/export', responseTime: 1250, count: 1 },
|
||||
{ url: '/api/tasks', responseTime: 180, count: 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { NetworkLog } from '@/types/monitor'
|
||||
|
||||
interface NetworkLogStatsProps {
|
||||
logs: NetworkLog[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function NetworkLogStats({ logs, isLoading = false }: NetworkLogStatsProps) {
|
||||
const stats = [
|
||||
{
|
||||
label: '总请求数',
|
||||
value: logs.length,
|
||||
color: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '成功请求',
|
||||
value: logs.filter(l => l.responseStatus >= 200 && l.responseStatus < 300).length,
|
||||
color: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '失败请求',
|
||||
value: logs.filter(l => l.responseStatus >= 400).length,
|
||||
color: 'text-red-600',
|
||||
},
|
||||
{
|
||||
label: '平均响应时间',
|
||||
value: logs.length > 0
|
||||
? Math.round(logs.reduce((sum, l) => sum + l.responseTime, 0) / logs.length) + 'ms'
|
||||
: '0ms',
|
||||
color: 'text-purple-600',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="text-sm text-muted-foreground">{stat.label}</div>
|
||||
<div className={`mt-2 text-2xl font-semibold ${stat.color}`}>
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse bg-gray-200 h-8 w-16 rounded"></div>
|
||||
) : (
|
||||
stat.value
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { NetworkLog } from '@/types/monitor'
|
||||
import { Eye, Clock } from 'lucide-react'
|
||||
|
||||
interface NetworkLogTableProps {
|
||||
logs: NetworkLog[]
|
||||
isLoading?: boolean
|
||||
onViewDetail: (log: NetworkLog) => void
|
||||
}
|
||||
|
||||
export function NetworkLogTable({ logs, isLoading = false, onViewDetail }: NetworkLogTableProps) {
|
||||
const getMethodBadge = (method: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
GET: 'bg-blue-100 text-blue-700',
|
||||
POST: 'bg-green-100 text-green-700',
|
||||
PUT: 'bg-yellow-100 text-yellow-700',
|
||||
DELETE: 'bg-red-100 text-red-700',
|
||||
PATCH: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
return colors[method] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: number) => {
|
||||
if (status >= 200 && status < 300) {
|
||||
return 'bg-green-100 text-green-700'
|
||||
} else if (status >= 400 && status < 500) {
|
||||
return 'bg-yellow-100 text-yellow-700'
|
||||
} else if (status >= 500) {
|
||||
return 'bg-red-100 text-red-700'
|
||||
}
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-8 space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="flex space-x-4">
|
||||
<div className="bg-gray-200 h-4 w-20 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-16 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-32 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-20 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-16 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-20 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-16 rounded"></div>
|
||||
<div className="bg-gray-200 h-8 w-8 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>方法</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead>用户</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>耗时</TableHead>
|
||||
<TableHead>大小</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
|
||||
暂无网络日志
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(log.timestamp).toLocaleTimeString('zh-CN')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getMethodBadge(log.method)}>
|
||||
{log.method}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate text-sm" title={log.url}>
|
||||
{log.url}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{log.username || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getStatusBadge(log.responseStatus)}>
|
||||
{log.responseStatus}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-gray-400" />
|
||||
{log.responseTime}ms
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{log.responseSize ? formatBytes(log.responseSize) : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onViewDetail(log)}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { NetworkLogService } from './NetworkLogService'
|
||||
export { NetworkLogStats } from './NetworkLogStats'
|
||||
export { NetworkLogFilters } from './NetworkLogFilters'
|
||||
export { NetworkLogTable } from './NetworkLogTable'
|
||||
export { NetworkLogDetailDialog } from './NetworkLogDetailDialog'
|
||||
export { NetworkLogInfo } from './NetworkLogInfo'
|
||||
|
||||
export type { NetworkLogFilters as NetworkLogFiltersType, NetworkLogListParams } from './NetworkLogService'
|
||||
139
crop-x/src/app/(app)/central-config/monitor/network-log/page.tsx
Normal file
139
crop-x/src/app/(app)/central-config/monitor/network-log/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { NetworkLog } from '@/types/monitor'
|
||||
import { Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// Import modular components
|
||||
import {
|
||||
NetworkLogService,
|
||||
NetworkLogStats,
|
||||
NetworkLogFilters,
|
||||
NetworkLogTable,
|
||||
NetworkLogDetailDialog,
|
||||
NetworkLogInfo
|
||||
} from './components'
|
||||
|
||||
export default function NetworkLogPage() {
|
||||
const [logs, setLogs] = useState<NetworkLog[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [methodFilter, setMethodFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [showDetailDialog, setShowDetailDialog] = useState(false)
|
||||
const [selectedLog, setSelectedLog] = useState<NetworkLog | null>(null)
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [searchKeyword, methodFilter, statusFilter])
|
||||
|
||||
const loadLogs = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await NetworkLogService.getNetworkLogs({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
filters: {
|
||||
searchKeyword,
|
||||
method: methodFilter,
|
||||
status: statusFilter
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
setLogs(response.data)
|
||||
} else {
|
||||
throw new Error(response.message || '加载网络日志失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load network logs:', error)
|
||||
toast.error('加载网络日志失败,请稍后重试')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDetail = async (log: NetworkLog) => {
|
||||
setSelectedLog(log)
|
||||
setShowDetailDialog(true)
|
||||
|
||||
setIsDetailLoading(true)
|
||||
try {
|
||||
const response = await NetworkLogService.getNetworkLogDetail(log.id)
|
||||
if (response.success) {
|
||||
setSelectedLog(response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch log detail:', error)
|
||||
} finally {
|
||||
setIsDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const blob = await NetworkLogService.exportNetworkLogs({
|
||||
searchKeyword,
|
||||
method: methodFilter,
|
||||
status: statusFilter
|
||||
})
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `network_logs_${new Date().getTime()}.json`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
toast.success('导出成功')
|
||||
} catch (error) {
|
||||
console.error('Failed to export logs:', error)
|
||||
toast.error('导出失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800">网络日志</h2>
|
||||
<p className="text-muted-foreground">记录系统接收与发送的所有网络请求信息</p>
|
||||
</div>
|
||||
<Button onClick={handleExport} disabled={isLoading || logs.length === 0}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出日志
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<NetworkLogStats logs={logs} isLoading={isLoading} />
|
||||
|
||||
<NetworkLogFilters
|
||||
searchKeyword={searchKeyword}
|
||||
onSearchChange={setSearchKeyword}
|
||||
methodFilter={methodFilter}
|
||||
onMethodFilterChange={setMethodFilter}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
/>
|
||||
|
||||
<NetworkLogTable
|
||||
logs={logs}
|
||||
isLoading={isLoading}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
|
||||
<NetworkLogDetailDialog
|
||||
log={selectedLog}
|
||||
isOpen={showDetailDialog}
|
||||
onClose={() => setShowDetailDialog(false)}
|
||||
isLoading={isDetailLoading}
|
||||
/>
|
||||
|
||||
<NetworkLogInfo />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { OperationLog } from '@/types/monitor'
|
||||
import { FileText } from 'lucide-react'
|
||||
|
||||
interface OperationLogDetailDialogProps {
|
||||
log: OperationLog | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function OperationLogDetailDialog({
|
||||
log,
|
||||
isOpen,
|
||||
onClose,
|
||||
isLoading = false
|
||||
}: OperationLogDetailDialogProps) {
|
||||
const getModuleLabel = (module: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
user: '用户管理',
|
||||
role: '角色管理',
|
||||
permission: '权限管理',
|
||||
machinery: '农机管理',
|
||||
driver: '驾驶员管理',
|
||||
task: '任务管理',
|
||||
system: '系统配置',
|
||||
other: '其他',
|
||||
}
|
||||
return labels[module] || module
|
||||
}
|
||||
|
||||
const getActionLabel = (action: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
create: '新增',
|
||||
update: '修改',
|
||||
delete: '删除',
|
||||
view: '查看',
|
||||
export: '导出',
|
||||
import: '导入',
|
||||
login: '登录',
|
||||
logout: '登出',
|
||||
}
|
||||
return labels[action] || action
|
||||
}
|
||||
|
||||
const getActionBadge = (action: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
create: 'bg-green-100 text-green-700',
|
||||
update: 'bg-blue-100 text-blue-700',
|
||||
delete: 'bg-red-100 text-red-700',
|
||||
view: 'bg-gray-100 text-gray-700',
|
||||
export: 'bg-purple-100 text-purple-700',
|
||||
import: 'bg-yellow-100 text-yellow-700',
|
||||
}
|
||||
return colors[action] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-green-600" />
|
||||
操作日志详情
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="bg-gray-200 h-4 w-24 rounded mb-2"></div>
|
||||
<div className="bg-gray-200 h-6 w-40 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : log ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">操作人</p>
|
||||
<p className="mt-1 font-medium">{log.username}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">操作时间</p>
|
||||
<p className="mt-1">{new Date(log.operationTime).toLocaleString('zh-CN')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">操作模块</p>
|
||||
<p className="mt-1">
|
||||
<Badge variant="outline">{getModuleLabel(log.module)}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">操作类型</p>
|
||||
<p className="mt-1">
|
||||
<Badge className={getActionBadge(log.action)}>
|
||||
{getActionLabel(log.action)}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">IP地址</p>
|
||||
<p className="mt-1">
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{log.ipAddress}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">耗时</p>
|
||||
<p className="mt-1">{log.duration ? `${log.duration}ms` : '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">操作描述</p>
|
||||
<p className="mt-1">{log.description}</p>
|
||||
</div>
|
||||
|
||||
{log.requestUrl && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求URL</p>
|
||||
<p className="mt-1">
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{log.requestMethod} {log.requestUrl}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.requestParams && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求参数</p>
|
||||
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(log.requestParams), null, 2)
|
||||
} catch {
|
||||
return log.requestParams
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.responseData && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">响应数据</p>
|
||||
<pre className="mt-1 p-3 bg-gray-50 rounded text-xs overflow-x-auto">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(log.responseData), null, 2)
|
||||
} catch {
|
||||
return log.responseData
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.errorMessage && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">错误信息</p>
|
||||
<p className="mt-1 text-red-600 bg-red-50 p-3 rounded">{log.errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
interface OperationLogFiltersProps {
|
||||
searchKeyword: string
|
||||
onSearchChange: (value: string) => void
|
||||
moduleFilter: string
|
||||
onModuleFilterChange: (value: string) => void
|
||||
actionFilter: string
|
||||
onActionFilterChange: (value: string) => void
|
||||
statusFilter: string
|
||||
onStatusFilterChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function OperationLogFilters({
|
||||
searchKeyword,
|
||||
onSearchChange,
|
||||
moduleFilter,
|
||||
onModuleFilterChange,
|
||||
actionFilter,
|
||||
onActionFilterChange,
|
||||
statusFilter,
|
||||
onStatusFilterChange
|
||||
}: OperationLogFiltersProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索用户名、操作描述..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={moduleFilter} onValueChange={onModuleFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="操作模块" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部模块</SelectItem>
|
||||
<SelectItem value="user">用户管理</SelectItem>
|
||||
<SelectItem value="role">角色管理</SelectItem>
|
||||
<SelectItem value="permission">权限管理</SelectItem>
|
||||
<SelectItem value="machinery">农机管理</SelectItem>
|
||||
<SelectItem value="driver">驾驶员管理</SelectItem>
|
||||
<SelectItem value="task">任务管理</SelectItem>
|
||||
<SelectItem value="system">系统配置</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={actionFilter} onValueChange={onActionFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="操作类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="create">新增</SelectItem>
|
||||
<SelectItem value="update">修改</SelectItem>
|
||||
<SelectItem value="delete">删除</SelectItem>
|
||||
<SelectItem value="view">查看</SelectItem>
|
||||
<SelectItem value="export">导出</SelectItem>
|
||||
<SelectItem value="import">导入</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="操作状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="success">成功</SelectItem>
|
||||
<SelectItem value="failed">失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Activity } from 'lucide-react'
|
||||
|
||||
export function OperationLogInfo() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="text-blue-900 mb-2">
|
||||
<Activity className="w-4 h-4 inline mr-2" />
|
||||
操作日志说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800">
|
||||
<li>• 记录用户在系统中的所有关键操作,包括数据修改、流程触发、配置变更等</li>
|
||||
<li>• 详细记录操作人、时间、模块、动作及请求详情</li>
|
||||
<li>• 支持按多个维度进行复杂条件搜索和过滤</li>
|
||||
<li>• 可查看原始请求信息,满足运维审计需求</li>
|
||||
<li>• 失败操作会记录错误信息,便于事故追溯</li>
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { OperationLog } from '@/types/monitor'
|
||||
import { ApiResponse, PaginatedResponse, PaginationParams } from '@/types'
|
||||
|
||||
export interface OperationLogFilters {
|
||||
searchKeyword?: string
|
||||
module?: string
|
||||
action?: string
|
||||
status?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export interface OperationLogListParams extends PaginationParams {
|
||||
filters?: OperationLogFilters
|
||||
}
|
||||
|
||||
export class OperationLogService {
|
||||
private static baseUrl = '/api/monitor/operation-logs'
|
||||
|
||||
/**
|
||||
* 获取操作日志列表
|
||||
*/
|
||||
static async getOperationLogs(params: OperationLogListParams = {}): Promise<PaginatedResponse<OperationLog>> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
// 添加分页参数
|
||||
if (params.page) queryParams.append('page', params.page.toString())
|
||||
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString())
|
||||
|
||||
// 添加筛选参数
|
||||
if (params.filters) {
|
||||
if (params.filters.searchKeyword) queryParams.append('searchKeyword', params.filters.searchKeyword)
|
||||
if (params.filters.module && params.filters.module !== 'all') queryParams.append('module', params.filters.module)
|
||||
if (params.filters.action && params.filters.action !== 'all') queryParams.append('action', params.filters.action)
|
||||
if (params.filters.status && params.filters.status !== 'all') queryParams.append('status', params.filters.status)
|
||||
if (params.filters.startDate) queryParams.append('startDate', params.filters.startDate)
|
||||
if (params.filters.endDate) queryParams.append('endDate', params.filters.endDate)
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}?${queryParams}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch operation logs:', error)
|
||||
// 降级处理:返回mock数据
|
||||
return this.getMockData(params)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作日志详情
|
||||
*/
|
||||
static async getOperationLogDetail(id: string): Promise<ApiResponse<OperationLog>> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/${id}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch operation log detail:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出操作日志
|
||||
*/
|
||||
static async exportOperationLogs(filters?: OperationLogFilters): Promise<Blob> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
if (filters) {
|
||||
if (filters.searchKeyword) queryParams.append('searchKeyword', filters.searchKeyword)
|
||||
if (filters.module && filters.module !== 'all') queryParams.append('module', filters.module)
|
||||
if (filters.action && filters.action !== 'all') queryParams.append('action', filters.action)
|
||||
if (filters.status && filters.status !== 'all') queryParams.append('status', filters.status)
|
||||
if (filters.startDate) queryParams.append('startDate', filters.startDate)
|
||||
if (filters.endDate) queryParams.append('endDate', filters.endDate)
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/export?${queryParams}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.blob()
|
||||
} catch (error) {
|
||||
console.error('Failed to export operation logs:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作日志统计信息
|
||||
*/
|
||||
static async getOperationLogStats(filters?: OperationLogFilters): Promise<ApiResponse<{
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
averageDuration: number
|
||||
moduleStats: Array<{ module: string, count: number }>
|
||||
actionStats: Array<{ action: string, count: number }>
|
||||
}>> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
if (filters) {
|
||||
if (filters.module && filters.module !== 'all') queryParams.append('module', filters.module)
|
||||
if (filters.action && filters.action !== 'all') queryParams.append('action', filters.action)
|
||||
if (filters.status && filters.status !== 'all') queryParams.append('status', filters.status)
|
||||
if (filters.startDate) queryParams.append('startDate', filters.startDate)
|
||||
if (filters.endDate) queryParams.append('endDate', filters.endDate)
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/stats?${queryParams}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch operation log stats:', error)
|
||||
// 降级处理:返回mock统计数据
|
||||
return this.getMockStats()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock数据 - 用于降级处理
|
||||
*/
|
||||
private static getMockData(params: OperationLogListParams): PaginatedResponse<OperationLog> {
|
||||
const mockLogs: OperationLog[] = [
|
||||
{
|
||||
id: 'op-1',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
operationTime: '2024-10-21T09:35:00',
|
||||
module: 'user',
|
||||
action: 'create',
|
||||
description: '创建用户账号: zhangsan',
|
||||
ipAddress: '192.168.1.100',
|
||||
requestUrl: '/api/users',
|
||||
requestMethod: 'POST',
|
||||
requestParams: JSON.stringify({ username: 'zhangsan', name: '张三' }),
|
||||
duration: 150,
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'op-2',
|
||||
userId: 'user-2',
|
||||
username: 'zhangsan',
|
||||
operationTime: '2024-10-21T10:20:00',
|
||||
module: 'machinery',
|
||||
action: 'update',
|
||||
description: '更新农机信息: 约翰迪尔拖拉机',
|
||||
ipAddress: '192.168.1.101',
|
||||
requestUrl: '/api/machinery/123',
|
||||
requestMethod: 'PUT',
|
||||
duration: 89,
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'op-3',
|
||||
userId: 'user-3',
|
||||
username: 'lisi',
|
||||
operationTime: '2024-10-21T11:25:00',
|
||||
module: 'role',
|
||||
action: 'delete',
|
||||
description: '删除角色: 临时操作员',
|
||||
ipAddress: '192.168.1.102',
|
||||
requestUrl: '/api/roles/456',
|
||||
requestMethod: 'DELETE',
|
||||
duration: 120,
|
||||
status: 'failed',
|
||||
errorMessage: '该角色下仍有关联用户,无法删除',
|
||||
},
|
||||
{
|
||||
id: 'op-4',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
operationTime: '2024-10-21T14:50:00',
|
||||
module: 'system',
|
||||
action: 'update',
|
||||
description: '修改系统配置: 会话超时时间',
|
||||
ipAddress: '192.168.1.100',
|
||||
requestUrl: '/api/system/settings',
|
||||
requestMethod: 'PUT',
|
||||
duration: 95,
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'op-5',
|
||||
userId: 'user-2',
|
||||
username: 'zhangsan',
|
||||
operationTime: '2024-10-21T15:35:00',
|
||||
module: 'task',
|
||||
action: 'create',
|
||||
description: '创建作业任务: 小麦播种作业',
|
||||
ipAddress: '192.168.1.101',
|
||||
requestUrl: '/api/tasks',
|
||||
requestMethod: 'POST',
|
||||
duration: 180,
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'op-6',
|
||||
userId: 'user-1',
|
||||
username: 'admin',
|
||||
operationTime: '2024-10-21T16:15:00',
|
||||
module: 'user',
|
||||
action: 'export',
|
||||
description: '导出用户列表数据',
|
||||
ipAddress: '192.168.1.100',
|
||||
requestUrl: '/api/users/export',
|
||||
requestMethod: 'GET',
|
||||
duration: 1250,
|
||||
status: 'success',
|
||||
},
|
||||
]
|
||||
|
||||
// 应用筛选器
|
||||
let filteredLogs = mockLogs.filter(log => {
|
||||
if (params.filters?.searchKeyword) {
|
||||
const keyword = params.filters.searchKeyword.toLowerCase()
|
||||
if (!log.username.toLowerCase().includes(keyword) &&
|
||||
!log.description.toLowerCase().includes(keyword) &&
|
||||
!log.module.toLowerCase().includes(keyword)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (params.filters?.module && params.filters.module !== 'all') {
|
||||
if (log.module !== params.filters.module) return false
|
||||
}
|
||||
|
||||
if (params.filters?.action && params.filters.action !== 'all') {
|
||||
if (log.action !== params.filters.action) return false
|
||||
}
|
||||
|
||||
if (params.filters?.status && params.filters.status !== 'all') {
|
||||
if (log.status !== params.filters.status) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// 应用分页
|
||||
const page = params.page || 1
|
||||
const pageSize = params.pageSize || 10
|
||||
const startIndex = (page - 1) * pageSize
|
||||
const endIndex = startIndex + pageSize
|
||||
const paginatedLogs = filteredLogs.slice(startIndex, endIndex)
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'success',
|
||||
success: true,
|
||||
data: paginatedLogs,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total: filteredLogs.length,
|
||||
totalPages: Math.ceil(filteredLogs.length / pageSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static getMockStats(): ApiResponse<{
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
averageDuration: number
|
||||
moduleStats: Array<{ module: string, count: number }>
|
||||
actionStats: Array<{ action: string, count: number }>
|
||||
}> {
|
||||
return {
|
||||
code: 200,
|
||||
message: 'success',
|
||||
success: true,
|
||||
data: {
|
||||
total: 6,
|
||||
success: 5,
|
||||
failed: 1,
|
||||
averageDuration: 314,
|
||||
moduleStats: [
|
||||
{ module: 'user', count: 2 },
|
||||
{ module: 'machinery', count: 1 },
|
||||
{ module: 'role', count: 1 },
|
||||
{ module: 'system', count: 1 },
|
||||
{ module: 'task', count: 1 }
|
||||
],
|
||||
actionStats: [
|
||||
{ action: 'create', count: 2 },
|
||||
{ action: 'update', count: 2 },
|
||||
{ action: 'delete', count: 1 },
|
||||
{ action: 'export', count: 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { OperationLog } from '@/types/monitor'
|
||||
|
||||
interface OperationLogStatsProps {
|
||||
logs: OperationLog[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function OperationLogStats({ logs, isLoading = false }: OperationLogStatsProps) {
|
||||
const stats = [
|
||||
{
|
||||
label: '总操作数',
|
||||
value: logs.length,
|
||||
color: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '成功操作',
|
||||
value: logs.filter(l => l.status === 'success').length,
|
||||
color: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '失败操作',
|
||||
value: logs.filter(l => l.status === 'failed').length,
|
||||
color: 'text-red-600',
|
||||
},
|
||||
{
|
||||
label: '平均耗时',
|
||||
value: logs.length > 0
|
||||
? Math.round(logs.reduce((sum, l) => sum + (l.duration || 0), 0) / logs.length) + 'ms'
|
||||
: '0ms',
|
||||
color: 'text-purple-600',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="text-sm text-muted-foreground">{stat.label}</div>
|
||||
<div className={`mt-2 text-2xl font-semibold ${stat.color}`}>
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse bg-gray-200 h-8 w-16 rounded"></div>
|
||||
) : (
|
||||
stat.value
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { OperationLog } from '@/types/monitor'
|
||||
import { Eye } from 'lucide-react'
|
||||
|
||||
interface OperationLogTableProps {
|
||||
logs: OperationLog[]
|
||||
isLoading?: boolean
|
||||
onViewDetail: (log: OperationLog) => void
|
||||
}
|
||||
|
||||
export function OperationLogTable({ logs, isLoading = false, onViewDetail }: OperationLogTableProps) {
|
||||
const getModuleLabel = (module: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
user: '用户管理',
|
||||
role: '角色管理',
|
||||
permission: '权限管理',
|
||||
machinery: '农机管理',
|
||||
driver: '驾驶员管理',
|
||||
task: '任务管理',
|
||||
system: '系统配置',
|
||||
other: '其他',
|
||||
}
|
||||
return labels[module] || module
|
||||
}
|
||||
|
||||
const getActionLabel = (action: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
create: '新增',
|
||||
update: '修改',
|
||||
delete: '删除',
|
||||
view: '查看',
|
||||
export: '导出',
|
||||
import: '导入',
|
||||
login: '登录',
|
||||
logout: '登出',
|
||||
}
|
||||
return labels[action] || action
|
||||
}
|
||||
|
||||
const getActionBadge = (action: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
create: 'bg-green-100 text-green-700',
|
||||
update: 'bg-blue-100 text-blue-700',
|
||||
delete: 'bg-red-100 text-red-700',
|
||||
view: 'bg-gray-100 text-gray-700',
|
||||
export: 'bg-purple-100 text-purple-700',
|
||||
import: 'bg-yellow-100 text-yellow-700',
|
||||
}
|
||||
return colors[action] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-8 space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="flex space-x-4">
|
||||
<div className="bg-gray-200 h-4 w-20 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-32 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-16 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-24 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-40 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-16 rounded"></div>
|
||||
<div className="bg-gray-200 h-4 w-16 rounded"></div>
|
||||
<div className="bg-gray-200 h-8 w-8 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>操作人</TableHead>
|
||||
<TableHead>操作时间</TableHead>
|
||||
<TableHead>模块</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>耗时</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
|
||||
暂无操作日志
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>{log.username}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{new Date(log.operationTime).toLocaleString('zh-CN')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{getModuleLabel(log.module)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getActionBadge(log.action)}>
|
||||
{getActionLabel(log.action)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate" title={log.description}>
|
||||
{log.description}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{log.duration ? `${log.duration}ms` : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.status === 'success' ? (
|
||||
<Badge className="bg-green-100 text-green-700">成功</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-100 text-red-700">失败</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onViewDetail(log)}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { OperationLogService } from './OperationLogService'
|
||||
export { OperationLogStats } from './OperationLogStats'
|
||||
export { OperationLogFilters } from './OperationLogFilters'
|
||||
export { OperationLogTable } from './OperationLogTable'
|
||||
export { OperationLogDetailDialog } from './OperationLogDetailDialog'
|
||||
export { OperationLogInfo } from './OperationLogInfo'
|
||||
|
||||
export type { OperationLogListParams } from './OperationLogService'
|
||||
export type { OperationLogFilters as OperationLogFiltersType } from './OperationLogService'
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { OperationLog } from '@/types/monitor'
|
||||
import { Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// Import modular components
|
||||
import {
|
||||
OperationLogService,
|
||||
OperationLogStats,
|
||||
OperationLogFilters,
|
||||
OperationLogTable,
|
||||
OperationLogDetailDialog,
|
||||
OperationLogInfo
|
||||
} from './components'
|
||||
|
||||
export default function OperationLogPage() {
|
||||
const [logs, setLogs] = useState<OperationLog[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [moduleFilter, setModuleFilter] = useState<string>('all')
|
||||
const [actionFilter, setActionFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [showDetailDialog, setShowDetailDialog] = useState(false)
|
||||
const [selectedLog, setSelectedLog] = useState<OperationLog | null>(null)
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [searchKeyword, moduleFilter, actionFilter, statusFilter])
|
||||
|
||||
const loadLogs = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await OperationLogService.getOperationLogs({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
filters: {
|
||||
searchKeyword,
|
||||
module: moduleFilter,
|
||||
action: actionFilter,
|
||||
status: statusFilter
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
setLogs(response.data)
|
||||
} else {
|
||||
throw new Error(response.message || '加载操作日志失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load operation logs:', error)
|
||||
toast.error('加载操作日志失败,请稍后重试')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDetail = async (log: OperationLog) => {
|
||||
setSelectedLog(log)
|
||||
setShowDetailDialog(true)
|
||||
|
||||
setIsDetailLoading(true)
|
||||
try {
|
||||
const response = await OperationLogService.getOperationLogDetail(log.id)
|
||||
if (response.success) {
|
||||
setSelectedLog(response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch log detail:', error)
|
||||
} finally {
|
||||
setIsDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const blob = await OperationLogService.exportOperationLogs({
|
||||
searchKeyword,
|
||||
module: moduleFilter,
|
||||
action: actionFilter,
|
||||
status: statusFilter
|
||||
})
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `operation_logs_${new Date().getTime()}.json`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
toast.success('导出成功')
|
||||
} catch (error) {
|
||||
console.error('Failed to export logs:', error)
|
||||
toast.error('导出失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800">操作日志</h2>
|
||||
<p className="text-muted-foreground">详细追踪用户在系统中的关键操作行为</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isLoading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={isLoading || logs.length === 0}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出日志
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OperationLogStats logs={logs} isLoading={isLoading} />
|
||||
|
||||
<OperationLogFilters
|
||||
searchKeyword={searchKeyword}
|
||||
onSearchChange={setSearchKeyword}
|
||||
moduleFilter={moduleFilter}
|
||||
onModuleFilterChange={setModuleFilter}
|
||||
actionFilter={actionFilter}
|
||||
onActionFilterChange={setActionFilter}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
/>
|
||||
|
||||
<OperationLogTable
|
||||
logs={logs}
|
||||
isLoading={isLoading}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
|
||||
<OperationLogDetailDialog
|
||||
log={selectedLog}
|
||||
isOpen={showDetailDialog}
|
||||
onClose={() => setShowDetailDialog(false)}
|
||||
isLoading={isDetailLoading}
|
||||
/>
|
||||
|
||||
<OperationLogInfo />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
crop-x/src/app/(app)/central-config/monitor/page.tsx
Normal file
30
crop-x/src/app/(app)/central-config/monitor/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function MonitorPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">系统监控</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Link href="/central-config/monitor/login-log" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">登录日志</h3>
|
||||
<p className="text-gray-600 text-sm">查看用户登录记录</p>
|
||||
</Link>
|
||||
<Link href="/central-config/monitor/operation-log" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">操作日志</h3>
|
||||
<p className="text-gray-600 text-sm">查看系统操作记录</p>
|
||||
</Link>
|
||||
<Link href="/central-config/monitor/performance" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">性能监控</h3>
|
||||
<p className="text-gray-600 text-sm">监控系统性能</p>
|
||||
</Link>
|
||||
<Link href="/central-config/monitor/network-log" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">网络日志</h3>
|
||||
<p className="text-gray-600 text-sm">查看网络访问日志</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import { SystemPerformance } from '@/types/monitor';
|
||||
|
||||
interface CpuMetricCardProps {
|
||||
performance: SystemPerformance;
|
||||
getUsageColor: (usage: number) => string;
|
||||
getUsageStatus: (usage: number) => string;
|
||||
}
|
||||
|
||||
export function CpuMetricCard({ performance, getUsageColor, getUsageStatus }: CpuMetricCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-blue-600" />
|
||||
<h3>CPU使用率</h3>
|
||||
</div>
|
||||
<Badge className={getUsageColor(performance.cpu.usage)}>
|
||||
{getUsageStatus(performance.cpu.usage)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">使用率</span>
|
||||
<span className={`${getUsageColor(performance.cpu.usage)}`}>
|
||||
{performance.cpu.usage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={performance.cpu.usage} className="h-2" />
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">核心数</span>
|
||||
<span>{performance.cpu.cores} 核</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { HardDrive } from 'lucide-react';
|
||||
import { SystemPerformance } from '@/types/monitor';
|
||||
|
||||
interface DiskMetricCardProps {
|
||||
performance: SystemPerformance;
|
||||
getUsageColor: (usage: number) => string;
|
||||
getUsageStatus: (usage: number) => string;
|
||||
formatBytes: (bytes: number) => string;
|
||||
}
|
||||
|
||||
export function DiskMetricCard({ performance, getUsageColor, getUsageStatus, formatBytes }: DiskMetricCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="w-5 h-5 text-purple-600" />
|
||||
<h3>磁盘使用率</h3>
|
||||
</div>
|
||||
<Badge className={getUsageColor(performance.disk.usage)}>
|
||||
{getUsageStatus(performance.disk.usage)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">使用率</span>
|
||||
<span className={getUsageColor(performance.disk.usage)}>
|
||||
{performance.disk.usage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={performance.disk.usage} className="h-2" />
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">已用</span>
|
||||
<p>{formatBytes(performance.disk.used)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">可用</span>
|
||||
<p>{formatBytes(performance.disk.free)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">总量</span>
|
||||
<p>{formatBytes(performance.disk.total)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Server } from 'lucide-react';
|
||||
import { SystemPerformance } from '@/types/monitor';
|
||||
|
||||
interface JvmInfoCardProps {
|
||||
performance: SystemPerformance;
|
||||
formatBytes: (bytes: number) => string;
|
||||
}
|
||||
|
||||
export function JvmInfoCard({ performance, formatBytes }: JvmInfoCardProps) {
|
||||
if (!performance.jvm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Server className="w-5 h-5 text-orange-600" />
|
||||
<h3>JVM信息</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">堆内存使用</p>
|
||||
<p className="mt-1">{performance.jvm.heapUsage.toFixed(1)}%</p>
|
||||
<Progress value={performance.jvm.heapUsage} className="h-1 mt-2" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">堆内存</p>
|
||||
<p className="mt-1">
|
||||
{formatBytes(performance.jvm.heapUsed)} / {formatBytes(performance.jvm.heapMax)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">非堆内存</p>
|
||||
<p className="mt-1">{formatBytes(performance.jvm.nonHeapUsed)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">线程数</p>
|
||||
<p className="mt-1">{performance.jvm.threadCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">GC次数</p>
|
||||
<p className="mt-1">{performance.jvm.gcCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">GC耗时</p>
|
||||
<p className="mt-1">{performance.jvm.gcTime}ms</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { MemoryStick } from 'lucide-react';
|
||||
import { SystemPerformance } from '@/types/monitor';
|
||||
|
||||
interface MemoryMetricCardProps {
|
||||
performance: SystemPerformance;
|
||||
getUsageColor: (usage: number) => string;
|
||||
getUsageStatus: (usage: number) => string;
|
||||
formatBytes: (bytes: number) => string;
|
||||
}
|
||||
|
||||
export function MemoryMetricCard({ performance, getUsageColor, getUsageStatus, formatBytes }: MemoryMetricCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MemoryStick className="w-5 h-5 text-green-600" />
|
||||
<h3>内存使用率</h3>
|
||||
</div>
|
||||
<Badge className={getUsageColor(performance.memory.usage)}>
|
||||
{getUsageStatus(performance.memory.usage)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">使用率</span>
|
||||
<span className={getUsageColor(performance.memory.usage)}>
|
||||
{performance.memory.usage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={performance.memory.usage} className="h-2" />
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">已用</span>
|
||||
<p>{formatBytes(performance.memory.used)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">总量</span>
|
||||
<p>{formatBytes(performance.memory.total)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Activity } from 'lucide-react';
|
||||
|
||||
export function PerformanceInstructions() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="text-blue-900 mb-2">
|
||||
<Activity className="w-4 h-4 inline mr-2" />
|
||||
性能监控说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800">
|
||||
<li>• 系统每5秒自动刷新一次性能数据</li>
|
||||
<li>• 使用率超过60%显示警告,超过80%显示危险</li>
|
||||
<li>• 趋势图显示最近20次的性能数据变化</li>
|
||||
<li>• 建议在性能使用率持续偏高时进行系统优化</li>
|
||||
<li>• JVM和Tomcat信息仅在Java环境下可用</li>
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { SystemPerformance } from '@/types/monitor';
|
||||
|
||||
interface PerformanceTrendChartProps {
|
||||
history: SystemPerformance[];
|
||||
}
|
||||
|
||||
export function PerformanceTrendChart({ history }: PerformanceTrendChartProps) {
|
||||
if (history.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chartData = history.map((item) => ({
|
||||
time: new Date(item.timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}),
|
||||
CPU: item.cpu.usage.toFixed(1),
|
||||
内存: item.memory.usage.toFixed(1),
|
||||
磁盘: item.disk.usage.toFixed(1),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">性能趋势</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="CPU" stroke="#3b82f6" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="内存" stroke="#10b981" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="磁盘" stroke="#a855f7" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { SystemPerformance } from '@/types/monitor';
|
||||
|
||||
interface TomcatInfoCardProps {
|
||||
performance: SystemPerformance;
|
||||
}
|
||||
|
||||
export function TomcatInfoCard({ performance }: TomcatInfoCardProps) {
|
||||
if (!performance.tomcat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity className="w-5 h-5 text-red-600" />
|
||||
<h3>Tomcat信息</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">线程数</p>
|
||||
<p className="mt-1">
|
||||
{performance.tomcat.threadCount} / {performance.tomcat.maxThreads}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">连接数</p>
|
||||
<p className="mt-1">{performance.tomcat.connectionCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">请求数</p>
|
||||
<p className="mt-1">{performance.tomcat.requestCount.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">错误数</p>
|
||||
<p className="mt-1 text-red-600">{performance.tomcat.errorCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">错误率</p>
|
||||
<p className="mt-1">
|
||||
{((performance.tomcat.errorCount / performance.tomcat.requestCount) * 100).toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { CpuMetricCard } from './CpuMetricCard';
|
||||
export { MemoryMetricCard } from './MemoryMetricCard';
|
||||
export { DiskMetricCard } from './DiskMetricCard';
|
||||
export { JvmInfoCard } from './JvmInfoCard';
|
||||
export { TomcatInfoCard } from './TomcatInfoCard';
|
||||
export { PerformanceTrendChart } from './PerformanceTrendChart';
|
||||
export { PerformanceInstructions } from './PerformanceInstructions';
|
||||
274
crop-x/src/app/(app)/central-config/monitor/performance/page.tsx
Normal file
274
crop-x/src/app/(app)/central-config/monitor/performance/page.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { SystemPerformance } from '@/types/monitor';
|
||||
import {
|
||||
CpuMetricCard,
|
||||
MemoryMetricCard,
|
||||
DiskMetricCard,
|
||||
JvmInfoCard,
|
||||
TomcatInfoCard,
|
||||
PerformanceTrendChart,
|
||||
PerformanceInstructions
|
||||
} from './components';
|
||||
|
||||
// API服务函数
|
||||
const performanceApi = {
|
||||
// 获取当前性能数据
|
||||
getCurrentPerformance: async (): Promise<SystemPerformance> => {
|
||||
try {
|
||||
const response = await fetch('/api/monitor/performance/current');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch performance data');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch performance data, using mock data:', error);
|
||||
// 如果API调用失败,返回模拟数据
|
||||
return getMockPerformanceData();
|
||||
}
|
||||
},
|
||||
|
||||
// 获取历史性能数据
|
||||
getPerformanceHistory: async (limit: number = 20): Promise<SystemPerformance[]> => {
|
||||
try {
|
||||
const response = await fetch(`/api/monitor/performance/history?limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch performance history');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch performance history, using mock data:', error);
|
||||
// 如果API调用失败,返回模拟历史数据
|
||||
return Array.from({ length: Math.min(5, limit) }, (_, i) => {
|
||||
const mockData = getMockPerformanceData();
|
||||
mockData.timestamp = new Date(Date.now() - (4 - i) * 5000).toISOString();
|
||||
return mockData;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟数据生成函数
|
||||
function getMockPerformanceData(): SystemPerformance {
|
||||
const mockData: SystemPerformance = {
|
||||
timestamp: new Date().toISOString(),
|
||||
cpu: {
|
||||
usage: Math.random() * 60 + 20, // 20-80%
|
||||
cores: 8,
|
||||
},
|
||||
memory: {
|
||||
total: 16384, // 16GB
|
||||
used: Math.random() * 8192 + 4096, // 4-12GB
|
||||
free: 0,
|
||||
usage: 0,
|
||||
},
|
||||
disk: {
|
||||
total: 512, // 512GB
|
||||
used: Math.random() * 102 + 204, // 204-306GB
|
||||
free: 0,
|
||||
usage: 0,
|
||||
},
|
||||
jvm: {
|
||||
heapUsed: Math.random() * 1024 + 512, // 512-1536MB
|
||||
heapMax: 2048, // 2GB
|
||||
heapUsage: 0,
|
||||
nonHeapUsed: Math.random() * 100 + 50,
|
||||
threadCount: Math.floor(Math.random() * 50 + 100),
|
||||
gcCount: Math.floor(Math.random() * 10 + 50),
|
||||
gcTime: Math.floor(Math.random() * 200 + 100),
|
||||
},
|
||||
tomcat: {
|
||||
threadCount: Math.floor(Math.random() * 50 + 50),
|
||||
maxThreads: 200,
|
||||
connectionCount: Math.floor(Math.random() * 100 + 50),
|
||||
requestCount: Math.floor(Math.random() * 10000 + 50000),
|
||||
errorCount: Math.floor(Math.random() * 10),
|
||||
},
|
||||
};
|
||||
|
||||
// 计算百分比
|
||||
mockData.memory.free = mockData.memory.total - mockData.memory.used;
|
||||
mockData.memory.usage = (mockData.memory.used / mockData.memory.total) * 100;
|
||||
mockData.disk.free = mockData.disk.total - mockData.disk.used;
|
||||
mockData.disk.usage = (mockData.disk.used / mockData.disk.total) * 100;
|
||||
if (mockData.jvm) {
|
||||
mockData.jvm.heapUsage = (mockData.jvm.heapUsed / mockData.jvm.heapMax) * 100;
|
||||
}
|
||||
|
||||
return mockData;
|
||||
}
|
||||
|
||||
export default function PerformanceMonitorPage() {
|
||||
const [performance, setPerformance] = useState<SystemPerformance | null>(null);
|
||||
const [history, setHistory] = useState<SystemPerformance[]>([]);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadPerformance();
|
||||
const interval = setInterval(() => {
|
||||
if (autoRefresh) {
|
||||
loadPerformance();
|
||||
}
|
||||
}, 5000); // 每5秒刷新一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh]);
|
||||
|
||||
const loadPerformance = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
// 获取当前数据
|
||||
const currentData = await performanceApi.getCurrentPerformance();
|
||||
|
||||
setPerformance(currentData);
|
||||
|
||||
// 保存历史数据(最多保留20条)
|
||||
setHistory(prev => {
|
||||
const newHistory = [...prev, currentData].slice(-20);
|
||||
return newHistory;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载性能数据失败');
|
||||
console.error('Failed to load performance data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUsageColor = (usage: number) => {
|
||||
if (usage < 60) return 'text-green-600';
|
||||
if (usage < 80) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
const getUsageStatus = (usage: number) => {
|
||||
if (usage < 60) return '正常';
|
||||
if (usage < 80) return '警告';
|
||||
return '危险';
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes.toFixed(2)} MB`;
|
||||
return `${(bytes / 1024).toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
if (loading && !performance) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-green-600 mx-auto mb-2" />
|
||||
<p className="text-muted-foreground">正在加载性能数据...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !performance) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 mb-4">加载失败: {error}</p>
|
||||
<Button onClick={loadPerformance} variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!performance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题和控制按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800">性能监控</h2>
|
||||
<p className="text-muted-foreground">实时监控系统运行健康状态</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={autoRefresh ? 'default' : 'outline'}>
|
||||
{autoRefresh ? '自动刷新' : '已暂停'}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
>
|
||||
{autoRefresh ? '暂停' : '启动'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadPerformance}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 border border-yellow-200 bg-yellow-50 rounded-md">
|
||||
<p className="text-yellow-800 text-sm">
|
||||
警告: {error} (当前显示为模拟数据)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CPU和内存卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<CpuMetricCard
|
||||
performance={performance}
|
||||
getUsageColor={getUsageColor}
|
||||
getUsageStatus={getUsageStatus}
|
||||
/>
|
||||
<MemoryMetricCard
|
||||
performance={performance}
|
||||
getUsageColor={getUsageColor}
|
||||
getUsageStatus={getUsageStatus}
|
||||
formatBytes={formatBytes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 磁盘卡片 */}
|
||||
<DiskMetricCard
|
||||
performance={performance}
|
||||
getUsageColor={getUsageColor}
|
||||
getUsageStatus={getUsageStatus}
|
||||
formatBytes={formatBytes}
|
||||
/>
|
||||
|
||||
{/* JVM信息卡片 */}
|
||||
<JvmInfoCard
|
||||
performance={performance}
|
||||
formatBytes={formatBytes}
|
||||
/>
|
||||
|
||||
{/* Tomcat信息卡片 */}
|
||||
<TomcatInfoCard
|
||||
performance={performance}
|
||||
/>
|
||||
|
||||
{/* 性能趋势图 */}
|
||||
<PerformanceTrendChart
|
||||
history={history}
|
||||
/>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<PerformanceInstructions />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
crop-x/src/app/(app)/central-config/page.tsx
Normal file
13
crop-x/src/app/(app)/central-config/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import Link from 'next/link'
|
||||
import { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '中心配置管理 - Crop-X 智慧农业管理系统',
|
||||
description: '中心配置管理系统主页面',
|
||||
}
|
||||
|
||||
export default function CentralConfigPage() {
|
||||
return (
|
||||
<div>一级菜单不具备功能,请移步三级菜单使用!谢谢!</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
interface CategoryFiltersProps {
|
||||
searchKeyword: string;
|
||||
typeFilter: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onTypeFilterChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function CategoryFilters({
|
||||
searchKeyword,
|
||||
typeFilter,
|
||||
onSearchChange,
|
||||
onTypeFilterChange,
|
||||
}: CategoryFiltersProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索分类名称、编码..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={typeFilter} onValueChange={onTypeFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="分类类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="industry">行业类型</SelectItem>
|
||||
<SelectItem value="equipment">设备类型</SelectItem>
|
||||
<SelectItem value="crop">作物类型</SelectItem>
|
||||
<SelectItem value="operation">作业类型</SelectItem>
|
||||
<SelectItem value="other">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { CategoryDictionary, CategoryFormData } from '../types';
|
||||
|
||||
interface CategoryFormDialogProps {
|
||||
open: boolean;
|
||||
editing?: CategoryDictionary;
|
||||
parent?: CategoryDictionary | null;
|
||||
formData: CategoryFormData;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onFormDataChange: (data: Partial<CategoryFormData>) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export function CategoryFormDialog({
|
||||
open,
|
||||
editing,
|
||||
parent,
|
||||
formData,
|
||||
onOpenChange,
|
||||
onFormDataChange,
|
||||
onSave,
|
||||
}: CategoryFormDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? '编辑分类' : '新增分类'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{editing ? '编辑分类信息' : '添加新分类'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{parent && (
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<Label className="text-sm text-blue-900 dark:text-blue-100">上级分类</Label>
|
||||
<p className="mt-1 dark:text-gray-100">{parent.name}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="code">分类编码 *</Label>
|
||||
<Input
|
||||
id="code"
|
||||
value={formData.code}
|
||||
onChange={(e) => onFormDataChange({ code: e.target.value })}
|
||||
placeholder="IND001"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="name">分类名称 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => onFormDataChange({ name: e.target.value })}
|
||||
placeholder="请输入名称"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="type">分类类型</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(value) => onFormDataChange({ type: value })}
|
||||
disabled={!!parent}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="industry">行业类型</SelectItem>
|
||||
<SelectItem value="equipment">设备类型</SelectItem>
|
||||
<SelectItem value="crop">作物类型</SelectItem>
|
||||
<SelectItem value="operation">作业类型</SelectItem>
|
||||
<SelectItem value="other">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => onFormDataChange({ description: e.target.value })}
|
||||
placeholder="请输入描述"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="sortOrder">排序</Label>
|
||||
<Input
|
||||
id="sortOrder"
|
||||
type="number"
|
||||
value={formData.sortOrder}
|
||||
onChange={(e) => onFormDataChange({ sortOrder: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Label htmlFor="isActive">是否启用</Label>
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => onFormDataChange({ isActive: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={onSave}>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { FolderTree } from 'lucide-react';
|
||||
|
||||
export function CategoryInstructions() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
|
||||
<h4 className="text-blue-900 dark:text-blue-100 mb-2">
|
||||
<FolderTree className="w-4 h-4 inline mr-2" />
|
||||
分类字典说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800 dark:text-blue-200">
|
||||
<li>• 支持多级树形结构,可无限级嵌套</li>
|
||||
<li>• 点击文件夹图标可展开/收起子分类</li>
|
||||
<li>• 鼠标悬停在分类上可显示操作按钮</li>
|
||||
<li>• 删除前需先删除所有子分类</li>
|
||||
<li>• 分类编码建议使用层级编码规则(如 IND001-01)</li>
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
File,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { CategoryDictionary } from '../types';
|
||||
|
||||
interface CategoryTreeProps {
|
||||
categories: CategoryDictionary[];
|
||||
expandedIds: Set<string>;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onAdd: (parent?: CategoryDictionary) => void;
|
||||
onEdit: (category: CategoryDictionary) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function CategoryTree({
|
||||
categories,
|
||||
expandedIds,
|
||||
onToggleExpand,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CategoryTreeProps) {
|
||||
const renderTree = (nodes: CategoryDictionary[], level: number = 0) => {
|
||||
return nodes.map(node => (
|
||||
<div key={node.id} style={{ marginLeft: `${level * 24}px` }}>
|
||||
<div className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg group">
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{node.children && node.children.length > 0 ? (
|
||||
<button
|
||||
onClick={() => onToggleExpand(node.id)}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
{expandedIds.has(node.id) ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6" />
|
||||
)}
|
||||
{node.children && node.children.length > 0 ? (
|
||||
<Folder className="w-4 h-4 text-yellow-600 dark:text-yellow-500" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="dark:text-gray-100">{node.name}</span>
|
||||
<Badge variant="outline" className="text-xs">{node.code}</Badge>
|
||||
{!node.isActive && (
|
||||
<Badge variant="outline" className="text-xs text-red-600 dark:text-red-400">已停用</Badge>
|
||||
)}
|
||||
</div>
|
||||
{node.description && (
|
||||
<p className="text-xs text-muted-foreground dark:text-gray-400">{node.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onAdd(node)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(node)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(node.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{expandedIds.has(node.id) && node.children && renderTree(node.children, level + 1)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="min-h-[400px]">
|
||||
{categories.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
暂无分类数据
|
||||
</div>
|
||||
) : (
|
||||
renderTree(categories)
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
306
crop-x/src/app/(app)/central-config/system/category/page.tsx
Normal file
306
crop-x/src/app/(app)/central-config/system/category/page.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
'use client';
|
||||
|
||||
import React, { useReducer, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { CategoryDictionary, CategoryAction } from './types';
|
||||
import { categoryReducer, initialState } from './reducer';
|
||||
import { CategoryFilters } from './components/CategoryFilters';
|
||||
import { CategoryTree } from './components/CategoryTree';
|
||||
import { CategoryFormDialog } from './components/CategoryFormDialog';
|
||||
import { CategoryInstructions } from './components/CategoryInstructions';
|
||||
|
||||
export default function CategoryDictionaryPage() {
|
||||
const [state, dispatch] = useReducer(categoryReducer, initialState);
|
||||
|
||||
// 模拟数据加载
|
||||
useEffect(() => {
|
||||
const mockData: CategoryDictionary[] = [
|
||||
{
|
||||
id: 'cat-1',
|
||||
code: 'IND001',
|
||||
name: '种植业',
|
||||
type: 'industry',
|
||||
level: 1,
|
||||
sortOrder: 1,
|
||||
description: '农作物种植相关行业',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-2',
|
||||
code: 'IND001-01',
|
||||
name: '粮食作物',
|
||||
type: 'industry',
|
||||
parentId: 'cat-1',
|
||||
level: 2,
|
||||
sortOrder: 1,
|
||||
description: '小麦、水稻、玉米等粮食作物',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-3',
|
||||
code: 'IND001-02',
|
||||
name: '经济作物',
|
||||
type: 'industry',
|
||||
parentId: 'cat-1',
|
||||
level: 2,
|
||||
sortOrder: 2,
|
||||
description: '棉花、油料、糖料等经济作物',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-4',
|
||||
code: 'IND002',
|
||||
name: '畜牧业',
|
||||
type: 'industry',
|
||||
level: 1,
|
||||
sortOrder: 2,
|
||||
description: '牲畜饲养相关行业',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-5',
|
||||
code: 'EQP001',
|
||||
name: '动力机械',
|
||||
type: 'equipment',
|
||||
level: 1,
|
||||
sortOrder: 1,
|
||||
description: '拖拉机等动力设备',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-6',
|
||||
code: 'EQP001-01',
|
||||
name: '轮式拖拉机',
|
||||
type: 'equipment',
|
||||
parentId: 'cat-5',
|
||||
level: 2,
|
||||
sortOrder: 1,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-7',
|
||||
code: 'EQP001-02',
|
||||
name: '履带式拖拉机',
|
||||
type: 'equipment',
|
||||
parentId: 'cat-5',
|
||||
level: 2,
|
||||
sortOrder: 2,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cat-8',
|
||||
code: 'EQP002',
|
||||
name: '收获机械',
|
||||
type: 'equipment',
|
||||
level: 1,
|
||||
sortOrder: 2,
|
||||
description: '收割机、采摘机等',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
];
|
||||
|
||||
// 尝试从 localStorage 加载数据
|
||||
const storedData = localStorage.getItem('smart_agriculture_category_dictionary');
|
||||
if (storedData) {
|
||||
try {
|
||||
const data = JSON.parse(storedData);
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: data });
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored data:', error);
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: mockData });
|
||||
}
|
||||
} else {
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: mockData });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 保存数据到 localStorage
|
||||
const saveCategories = (categories: CategoryDictionary[]) => {
|
||||
localStorage.setItem('smart_agriculture_category_dictionary', JSON.stringify(categories));
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: categories });
|
||||
};
|
||||
|
||||
// 构建树形结构
|
||||
const buildTree = (items: CategoryDictionary[]): CategoryDictionary[] => {
|
||||
const map = new Map<string, CategoryDictionary>();
|
||||
const roots: CategoryDictionary[] = [];
|
||||
|
||||
// 创建映射
|
||||
items.forEach(item => {
|
||||
map.set(item.id, { ...item, children: [] });
|
||||
});
|
||||
|
||||
// 构建树
|
||||
items.forEach(item => {
|
||||
const node = map.get(item.id)!;
|
||||
if (item.parentId) {
|
||||
const parent = map.get(item.parentId);
|
||||
if (parent) {
|
||||
parent.children = parent.children || [];
|
||||
parent.children.push(node);
|
||||
}
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
return roots;
|
||||
};
|
||||
|
||||
// 过滤分类数据
|
||||
const filteredCategories = state.categories.filter(cat => {
|
||||
const matchKeyword = !state.searchKeyword ||
|
||||
cat.name.includes(state.searchKeyword) ||
|
||||
cat.code.includes(state.searchKeyword);
|
||||
const matchType = state.typeFilter === 'all' || cat.type === state.typeFilter;
|
||||
return matchKeyword && matchType;
|
||||
});
|
||||
|
||||
const treeData = buildTree(filteredCategories);
|
||||
|
||||
// 处理新增
|
||||
const handleAdd = (parent?: CategoryDictionary) => {
|
||||
dispatch({
|
||||
type: 'SET_DIALOG_STATE',
|
||||
payload: {
|
||||
open: true,
|
||||
editing: undefined,
|
||||
parent: parent || null,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = (category: CategoryDictionary) => {
|
||||
dispatch({
|
||||
type: 'SET_DIALOG_STATE',
|
||||
payload: {
|
||||
open: true,
|
||||
editing: category,
|
||||
parent: undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 处理删除
|
||||
const handleDelete = (id: string) => {
|
||||
// 检查是否有子分类
|
||||
const hasChildren = state.categories.some(cat => cat.parentId === id);
|
||||
if (hasChildren) {
|
||||
toast.error('请先删除子分类');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = state.categories.filter(cat => cat.id !== id);
|
||||
saveCategories(updated);
|
||||
toast.success('删除成功');
|
||||
};
|
||||
|
||||
// 处理保存
|
||||
const handleSave = () => {
|
||||
if (!state.formData.code.trim() || !state.formData.name.trim()) {
|
||||
toast.error('请填写编码和名称');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.dialogState.editing) {
|
||||
// 编辑
|
||||
dispatch({
|
||||
type: 'UPDATE_CATEGORY',
|
||||
payload: {
|
||||
id: state.dialogState.editing.id,
|
||||
updates: state.formData,
|
||||
},
|
||||
});
|
||||
saveCategories(state.categories);
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
// 新增
|
||||
const newCategory: CategoryDictionary = {
|
||||
id: `cat-${Date.now()}`,
|
||||
...state.formData,
|
||||
parentId: state.dialogState.parent?.id,
|
||||
level: state.dialogState.parent ? state.dialogState.parent.level + 1 : 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
dispatch({ type: 'ADD_CATEGORY', payload: newCategory });
|
||||
saveCategories([...state.categories, newCategory]);
|
||||
toast.success('添加成功');
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'SET_DIALOG_STATE',
|
||||
payload: { open: false, editing: undefined, parent: undefined },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-green-800 dark:text-green-600">分类字典</h2>
|
||||
<p className="text-muted-foreground dark:text-gray-400">树形结构管理系统常用分类数据</p>
|
||||
</div>
|
||||
<Button onClick={() => handleAdd()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增分类
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<CategoryFilters
|
||||
searchKeyword={state.searchKeyword}
|
||||
typeFilter={state.typeFilter}
|
||||
onSearchChange={(value) => dispatch({ type: 'SET_SEARCH_KEYWORD', payload: value })}
|
||||
onTypeFilterChange={(value) => dispatch({ type: 'SET_TYPE_FILTER', payload: value })}
|
||||
/>
|
||||
|
||||
{/* 分类树 */}
|
||||
<CategoryTree
|
||||
categories={treeData}
|
||||
expandedIds={state.expandedIds}
|
||||
onToggleExpand={(id) => dispatch({ type: 'TOGGLE_EXPAND', payload: id })}
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* 编辑对话框 */}
|
||||
<CategoryFormDialog
|
||||
open={state.dialogState.open}
|
||||
editing={state.dialogState.editing}
|
||||
parent={state.dialogState.parent}
|
||||
formData={state.formData}
|
||||
onOpenChange={(open) => dispatch({
|
||||
type: 'SET_DIALOG_STATE',
|
||||
payload: { open, editing: undefined, parent: undefined },
|
||||
})}
|
||||
onFormDataChange={(data) => dispatch({ type: 'SET_FORM_DATA', payload: data })}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<CategoryInstructions />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { CategoryDictionary, CategoryAction, CategoryState, CategoryFormData } from './types';
|
||||
|
||||
const initialFormData: CategoryFormData = {
|
||||
code: '',
|
||||
name: '',
|
||||
type: 'industry',
|
||||
description: '',
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
export const initialState: CategoryState = {
|
||||
categories: [],
|
||||
searchKeyword: '',
|
||||
typeFilter: 'all',
|
||||
expandedIds: new Set(),
|
||||
dialogState: {
|
||||
open: false,
|
||||
editing: undefined,
|
||||
parent: undefined,
|
||||
},
|
||||
formData: initialFormData,
|
||||
};
|
||||
|
||||
export function categoryReducer(state: CategoryState, action: CategoryAction): CategoryState {
|
||||
switch (action.type) {
|
||||
case 'SET_CATEGORIES':
|
||||
return { ...state, categories: action.payload };
|
||||
|
||||
case 'ADD_CATEGORY':
|
||||
return { ...state, categories: [...state.categories, action.payload] };
|
||||
|
||||
case 'UPDATE_CATEGORY':
|
||||
return {
|
||||
...state,
|
||||
categories: state.categories.map(cat =>
|
||||
cat.id === action.payload.id
|
||||
? { ...cat, ...action.payload.updates, updatedAt: new Date().toISOString() }
|
||||
: cat
|
||||
),
|
||||
};
|
||||
|
||||
case 'DELETE_CATEGORY':
|
||||
return {
|
||||
...state,
|
||||
categories: state.categories.filter(cat => cat.id !== action.payload),
|
||||
};
|
||||
|
||||
case 'SET_SEARCH_KEYWORD':
|
||||
return { ...state, searchKeyword: action.payload };
|
||||
|
||||
case 'SET_TYPE_FILTER':
|
||||
return { ...state, typeFilter: action.payload };
|
||||
|
||||
case 'TOGGLE_EXPAND':
|
||||
const newExpanded = new Set(state.expandedIds);
|
||||
if (newExpanded.has(action.payload)) {
|
||||
newExpanded.delete(action.payload);
|
||||
} else {
|
||||
newExpanded.add(action.payload);
|
||||
}
|
||||
return { ...state, expandedIds: newExpanded };
|
||||
|
||||
case 'SET_DIALOG_STATE':
|
||||
return {
|
||||
...state,
|
||||
dialogState: action.payload,
|
||||
formData: action.payload.editing
|
||||
? {
|
||||
code: action.payload.editing.code,
|
||||
name: action.payload.editing.name,
|
||||
type: action.payload.editing.type,
|
||||
description: action.payload.editing.description || '',
|
||||
sortOrder: action.payload.editing.sortOrder,
|
||||
isActive: action.payload.editing.isActive,
|
||||
}
|
||||
: action.payload.parent
|
||||
? {
|
||||
...initialFormData,
|
||||
type: action.payload.parent.type,
|
||||
}
|
||||
: initialFormData,
|
||||
};
|
||||
|
||||
case 'SET_FORM_DATA':
|
||||
return {
|
||||
...state,
|
||||
formData: { ...state.formData, ...action.payload },
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
53
crop-x/src/app/(app)/central-config/system/category/types.ts
Normal file
53
crop-x/src/app/(app)/central-config/system/category/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// 分类字典类型定义
|
||||
export interface CategoryDictionary {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
type: string; // 分类类型:industry, equipment, crop等
|
||||
parentId?: string;
|
||||
level: number;
|
||||
sortOrder: number;
|
||||
description?: string;
|
||||
isActive: boolean;
|
||||
children?: CategoryDictionary[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type CategoryType = 'industry' | 'equipment' | 'crop' | 'operation' | 'other';
|
||||
|
||||
// 分类表单数据
|
||||
export interface CategoryFormData {
|
||||
code: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
// 分类操作类型
|
||||
export type CategoryAction =
|
||||
| { type: 'SET_CATEGORIES'; payload: CategoryDictionary[] }
|
||||
| { type: 'ADD_CATEGORY'; payload: CategoryDictionary }
|
||||
| { type: 'UPDATE_CATEGORY'; payload: { id: string; updates: Partial<CategoryDictionary> } }
|
||||
| { type: 'DELETE_CATEGORY'; payload: string }
|
||||
| { type: 'SET_SEARCH_KEYWORD'; payload: string }
|
||||
| { type: 'SET_TYPE_FILTER'; payload: string }
|
||||
| { type: 'TOGGLE_EXPAND'; payload: string }
|
||||
| { type: 'SET_DIALOG_STATE'; payload: { open: boolean; editing?: CategoryDictionary; parent?: CategoryDictionary | null } }
|
||||
| { type: 'SET_FORM_DATA'; payload: Partial<CategoryFormData> };
|
||||
|
||||
// 分类状态
|
||||
export interface CategoryState {
|
||||
categories: CategoryDictionary[];
|
||||
searchKeyword: string;
|
||||
typeFilter: string;
|
||||
expandedIds: Set<string>;
|
||||
dialogState: {
|
||||
open: boolean;
|
||||
editing?: CategoryDictionary;
|
||||
parent?: CategoryDictionary | null;
|
||||
};
|
||||
formData: CategoryFormData;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { CategoryFormData, CategoryDictionary } from '../types';
|
||||
|
||||
interface CategoryFormProps {
|
||||
open: boolean;
|
||||
editing: CategoryDictionary | null;
|
||||
formData: CategoryFormData;
|
||||
onFormDataChange: (data: Partial<CategoryFormData>) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export function CategoryForm({
|
||||
open,
|
||||
editing,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: CategoryFormProps) {
|
||||
const handleSave = () => {
|
||||
if (!formData.code.trim() || !formData.name.trim() || !formData.value.trim() || !formData.label.trim()) {
|
||||
return false;
|
||||
}
|
||||
onSave();
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? '编辑字典' : '新增字典'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{editing ? '编辑数据字典' : '添加新数据字典'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>编码 *</Label>
|
||||
<Input
|
||||
value={formData.code}
|
||||
onChange={(e) => onFormDataChange({ code: e.target.value })}
|
||||
placeholder="GENDER_MALE"
|
||||
disabled={editing?.isSystem}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>名称 *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => onFormDataChange({ name: e.target.value })}
|
||||
placeholder="性别-男"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>字典分类 *</Label>
|
||||
<Select
|
||||
value={formData.category}
|
||||
onValueChange={(value) => onFormDataChange({ category: value })}
|
||||
disabled={editing?.isSystem}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="gender">性别</SelectItem>
|
||||
<SelectItem value="status">状态</SelectItem>
|
||||
<SelectItem value="unit">单位</SelectItem>
|
||||
<SelectItem value="weather">天气</SelectItem>
|
||||
<SelectItem value="soil_type">土壤类型</SelectItem>
|
||||
<SelectItem value="irrigation_method">灌溉方式</SelectItem>
|
||||
<SelectItem value="fertilizer_type">肥料类型</SelectItem>
|
||||
<SelectItem value="pesticide_type">农药类型</SelectItem>
|
||||
<SelectItem value="task_status">任务状态</SelectItem>
|
||||
<SelectItem value="task_priority">任务优先级</SelectItem>
|
||||
<SelectItem value="approval_status">审批状态</SelectItem>
|
||||
<SelectItem value="operation_type">作业类型</SelectItem>
|
||||
<SelectItem value="other">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>值 *</Label>
|
||||
<Input
|
||||
value={formData.value}
|
||||
onChange={(e) => onFormDataChange({ value: e.target.value })}
|
||||
placeholder="male"
|
||||
disabled={editing?.isSystem}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
程序中使用的值,建议使用英文
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>标签 *</Label>
|
||||
<Input
|
||||
value={formData.label}
|
||||
onChange={(e) => onFormDataChange({ label: e.target.value })}
|
||||
placeholder="男"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
界面上显示的文本
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>描述</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => onFormDataChange({ description: e.target.value })}
|
||||
placeholder="请输入描述"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>排序</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.sortOrder}
|
||||
onChange={(e) => onFormDataChange({ sortOrder: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<Label>是否启用</Label>
|
||||
<Switch
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => onFormDataChange({ isActive: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Search, BookOpen, Edit, Trash2 } from 'lucide-react';
|
||||
import { CategoryDictionary } from '../types';
|
||||
|
||||
interface CategoryListProps {
|
||||
categories: CategoryDictionary[];
|
||||
searchKeyword: string;
|
||||
categoryFilter: string;
|
||||
onSearchChange: (keyword: string) => void;
|
||||
onCategoryFilterChange: (category: string) => void;
|
||||
onEdit: (category: CategoryDictionary) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function CategoryList({
|
||||
categories,
|
||||
searchKeyword,
|
||||
categoryFilter,
|
||||
onSearchChange,
|
||||
onCategoryFilterChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CategoryListProps) {
|
||||
// 过滤字典
|
||||
const filteredCategories = categories.filter(category => {
|
||||
const matchKeyword = !searchKeyword ||
|
||||
category.name.includes(searchKeyword) ||
|
||||
category.code.includes(searchKeyword) ||
|
||||
category.label.includes(searchKeyword) ||
|
||||
category.value.includes(searchKeyword);
|
||||
const matchCategory = categoryFilter === 'all' || category.category === categoryFilter;
|
||||
return matchKeyword && matchCategory;
|
||||
});
|
||||
|
||||
// 按分类分组
|
||||
const groupedCategories = filteredCategories.reduce((acc, category) => {
|
||||
if (!acc[category.category]) {
|
||||
acc[category.category] = [];
|
||||
}
|
||||
acc[category.category].push(category);
|
||||
return acc;
|
||||
}, {} as Record<string, CategoryDictionary[]>);
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
gender: '性别',
|
||||
status: '状态',
|
||||
unit: '单位',
|
||||
weather: '天气',
|
||||
soil_type: '土壤类型',
|
||||
irrigation_method: '灌溉方式',
|
||||
fertilizer_type: '肥料类型',
|
||||
pesticide_type: '农药类型',
|
||||
task_status: '任务状态',
|
||||
task_priority: '任务优先级',
|
||||
approval_status: '审批状态',
|
||||
operation_type: '作业类型',
|
||||
other: '其他',
|
||||
};
|
||||
return labels[category] || category;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 搜索和筛选 */}
|
||||
<Card className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索编码、名称、标签、值..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={categoryFilter} onValueChange={onCategoryFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="字典分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部分类</SelectItem>
|
||||
<SelectItem value="gender">性别</SelectItem>
|
||||
<SelectItem value="status">状态</SelectItem>
|
||||
<SelectItem value="unit">单位</SelectItem>
|
||||
<SelectItem value="weather">天气</SelectItem>
|
||||
<SelectItem value="soil_type">土壤类型</SelectItem>
|
||||
<SelectItem value="irrigation_method">灌溉方式</SelectItem>
|
||||
<SelectItem value="fertilizer_type">肥料类型</SelectItem>
|
||||
<SelectItem value="pesticide_type">农药类型</SelectItem>
|
||||
<SelectItem value="task_status">任务状态</SelectItem>
|
||||
<SelectItem value="task_priority">任务优先级</SelectItem>
|
||||
<SelectItem value="approval_status">审批状态</SelectItem>
|
||||
<SelectItem value="operation_type">作业类型</SelectItem>
|
||||
<SelectItem value="other">其他</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 字典列表 */}
|
||||
{Object.entries(groupedCategories).map(([category, items]) => (
|
||||
<Card key={category}>
|
||||
<div className="p-4 border-b bg-muted/50">
|
||||
<h3 className="flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5 text-green-600" />
|
||||
{getCategoryLabel(category)}
|
||||
<Badge variant="outline">{items.length}</Badge>
|
||||
</h3>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>编码</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>值</TableHead>
|
||||
<TableHead>标签</TableHead>
|
||||
<TableHead>排序</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.sort((a, b) => a.sortOrder - b.sortOrder).map((category) => (
|
||||
<TableRow key={category.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded">{category.code}</code>
|
||||
{category.isSystem && (
|
||||
<Badge variant="outline" className="text-xs">系统</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div>{category.name}</div>
|
||||
{category.description && (
|
||||
<p className="text-xs text-muted-foreground">{category.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs">{category.value}</code>
|
||||
</TableCell>
|
||||
<TableCell>{category.label}</TableCell>
|
||||
<TableCell>{category.sortOrder}</TableCell>
|
||||
<TableCell>
|
||||
{category.isActive ? (
|
||||
<Badge className="bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">启用</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">停用</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(category)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{!category.isSystem && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(category.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{filteredCategories.length === 0 && (
|
||||
<Card className="p-12 text-center text-muted-foreground">
|
||||
暂无字典数据
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 使用说明 */}
|
||||
<Card className="p-4 bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800">
|
||||
<h4 className="text-blue-900 dark:text-blue-100 mb-2">
|
||||
<BookOpen className="w-4 h-4 inline mr-2" />
|
||||
数据字典说明
|
||||
</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800 dark:text-blue-200">
|
||||
<li>• 数据字典用于存储系统中的枚举值和下拉选项</li>
|
||||
<li>• 编码应遵循命名规范,使用大写字母和下划线(如 GENDER_MALE)</li>
|
||||
<li>• 值(value)用于程序逻辑,标签(label)用于界面显示</li>
|
||||
<li>• 系统内置字典不可删除,但可以编辑标签和状态</li>
|
||||
<li>• 支持按分类分组展示,便于管理和查找</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { CategoryDictionary } from '../types';
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
open: boolean;
|
||||
category: CategoryDictionary | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteConfirmDialog({
|
||||
open,
|
||||
category,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: DeleteConfirmDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
确定要删除字典项"{category?.name}"吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{category && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">编码:</span>
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded">{category.code}</code>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">名称:</span>
|
||||
<span>{category.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">分类:</span>
|
||||
<span>{category.category}</span>
|
||||
</div>
|
||||
{category.isSystem && (
|
||||
<div className="mt-2 p-2 bg-destructive/10 border border-destructive/20 rounded text-destructive text-xs">
|
||||
⚠️ 这是系统内置字典,通常不建议删除
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
disabled={category?.isSystem}
|
||||
>
|
||||
{category?.isSystem ? '系统字典不可删除' : '确认删除'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
385
crop-x/src/app/(app)/central-config/system/dictionary/page.tsx
Normal file
385
crop-x/src/app/(app)/central-config/system/dictionary/page.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
'use client';
|
||||
|
||||
import React, { useReducer, useLayoutEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, Download } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { CategoryList } from './components/CategoryList';
|
||||
import { CategoryForm } from './components/CategoryForm';
|
||||
import { DeleteConfirmDialog } from './components/DeleteConfirmDialog';
|
||||
import { CategoryDictionary } from './types';
|
||||
import { categoryReducer, initialCategoryState } from './reducer';
|
||||
|
||||
// 模拟数据
|
||||
const mockData: CategoryDictionary[] = [
|
||||
// 性别
|
||||
{
|
||||
id: 'dict-1',
|
||||
code: 'GENDER_MALE',
|
||||
name: '性别-男',
|
||||
category: 'gender',
|
||||
value: 'male',
|
||||
label: '男',
|
||||
sortOrder: 1,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-2',
|
||||
code: 'GENDER_FEMALE',
|
||||
name: '性别-女',
|
||||
category: 'gender',
|
||||
value: 'female',
|
||||
label: '女',
|
||||
sortOrder: 2,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
// 状态
|
||||
{
|
||||
id: 'dict-3',
|
||||
code: 'STATUS_ACTIVE',
|
||||
name: '状态-激活',
|
||||
category: 'status',
|
||||
value: 'active',
|
||||
label: '激活',
|
||||
sortOrder: 1,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-4',
|
||||
code: 'STATUS_INACTIVE',
|
||||
name: '状态-停用',
|
||||
category: 'status',
|
||||
value: 'inactive',
|
||||
label: '停用',
|
||||
sortOrder: 2,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
// 单位类型
|
||||
{
|
||||
id: 'dict-5',
|
||||
code: 'UNIT_AREA_MU',
|
||||
name: '面积单位-亩',
|
||||
category: 'unit',
|
||||
value: 'mu',
|
||||
label: '亩',
|
||||
sortOrder: 1,
|
||||
description: '中国传统面积单位',
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-6',
|
||||
code: 'UNIT_AREA_HECTARE',
|
||||
name: '面积单位-公顷',
|
||||
category: 'unit',
|
||||
value: 'hectare',
|
||||
label: '公顷',
|
||||
sortOrder: 2,
|
||||
description: '国际通用面积单位',
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-7',
|
||||
code: 'UNIT_WEIGHT_KG',
|
||||
name: '重量单位-千克',
|
||||
category: 'unit',
|
||||
value: 'kg',
|
||||
label: '千克',
|
||||
sortOrder: 3,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-8',
|
||||
code: 'UNIT_WEIGHT_TON',
|
||||
name: '重量单位-吨',
|
||||
category: 'unit',
|
||||
value: 'ton',
|
||||
label: '吨',
|
||||
sortOrder: 4,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
// 天气
|
||||
{
|
||||
id: 'dict-9',
|
||||
code: 'WEATHER_SUNNY',
|
||||
name: '天气-晴',
|
||||
category: 'weather',
|
||||
value: 'sunny',
|
||||
label: '晴',
|
||||
sortOrder: 1,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-10',
|
||||
code: 'WEATHER_CLOUDY',
|
||||
name: '天气-多云',
|
||||
category: 'weather',
|
||||
value: 'cloudy',
|
||||
label: '多云',
|
||||
sortOrder: 2,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-11',
|
||||
code: 'WEATHER_RAINY',
|
||||
name: '天气-雨',
|
||||
category: 'weather',
|
||||
value: 'rainy',
|
||||
label: '雨',
|
||||
sortOrder: 3,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
// 土壤类型
|
||||
{
|
||||
id: 'dict-12',
|
||||
code: 'SOIL_SANDY',
|
||||
name: '土壤-砂土',
|
||||
category: 'soil_type',
|
||||
value: 'sandy',
|
||||
label: '砂土',
|
||||
sortOrder: 1,
|
||||
description: '含砂粒较多的土壤',
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-13',
|
||||
code: 'SOIL_LOAMY',
|
||||
name: '土壤-壤土',
|
||||
category: 'soil_type',
|
||||
value: 'loamy',
|
||||
label: '壤土',
|
||||
sortOrder: 2,
|
||||
description: '砂粘适中的土壤',
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dict-14',
|
||||
code: 'SOIL_CLAY',
|
||||
name: '土壤-黏土',
|
||||
category: 'soil_type',
|
||||
value: 'clay',
|
||||
label: '黏土',
|
||||
sortOrder: 3,
|
||||
description: '含黏粒较多的土壤',
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DataDictionaryPage() {
|
||||
const [state, dispatch] = useReducer(categoryReducer, initialCategoryState);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
|
||||
const [categoryToDelete, setCategoryToDelete] = React.useState<CategoryDictionary | null>(null);
|
||||
|
||||
// 加载数据
|
||||
useLayoutEffect(() => {
|
||||
const data = localStorage.getItem('smart_agriculture_category_dictionary');
|
||||
if (data) {
|
||||
try {
|
||||
const categories = JSON.parse(data);
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: categories });
|
||||
} catch (error) {
|
||||
console.error('Failed to parse category dictionary data:', error);
|
||||
loadMockData();
|
||||
}
|
||||
} else {
|
||||
loadMockData();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMockData = () => {
|
||||
localStorage.setItem('smart_agriculture_category_dictionary', JSON.stringify(mockData));
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: mockData });
|
||||
};
|
||||
|
||||
const saveCategories = (categories: CategoryDictionary[]) => {
|
||||
localStorage.setItem('smart_agriculture_category_dictionary', JSON.stringify(categories));
|
||||
dispatch({ type: 'SET_CATEGORIES', payload: categories });
|
||||
};
|
||||
|
||||
// 处理新增
|
||||
const handleAdd = () => {
|
||||
dispatch({ type: 'SET_DIALOG_STATE', payload: { open: true, editing: null } });
|
||||
};
|
||||
|
||||
// 处理编辑
|
||||
const handleEdit = (category: CategoryDictionary) => {
|
||||
dispatch({ type: 'SET_DIALOG_STATE', payload: { open: true, editing: category } });
|
||||
};
|
||||
|
||||
// 处理删除
|
||||
const handleDelete = (id: string) => {
|
||||
const category = state.categories.find(c => c.id === id);
|
||||
if (!category) return;
|
||||
|
||||
if (category.isSystem) {
|
||||
toast.error('系统内置字典不能删除');
|
||||
return;
|
||||
}
|
||||
|
||||
setCategoryToDelete(category);
|
||||
setDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = () => {
|
||||
if (!categoryToDelete) return;
|
||||
|
||||
const updated = state.categories.filter(c => c.id !== categoryToDelete.id);
|
||||
saveCategories(updated);
|
||||
toast.success('删除成功');
|
||||
setCategoryToDelete(null);
|
||||
};
|
||||
|
||||
// 处理保存
|
||||
const handleSave = () => {
|
||||
const { formData, dialogState } = state;
|
||||
|
||||
if (!formData.code.trim() || !formData.name.trim() || !formData.value.trim() || !formData.label.trim()) {
|
||||
toast.error('请填写必填项');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (dialogState.editing) {
|
||||
// 编辑
|
||||
const updated = state.categories.map(category =>
|
||||
category.id === dialogState.editing!.id
|
||||
? {
|
||||
...category,
|
||||
...formData,
|
||||
updatedAt: now,
|
||||
}
|
||||
: category
|
||||
);
|
||||
saveCategories(updated);
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
// 新增
|
||||
const newCategory: CategoryDictionary = {
|
||||
id: `dict-${Date.now()}`,
|
||||
...formData,
|
||||
isSystem: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
saveCategories([...state.categories, newCategory]);
|
||||
toast.success('添加成功');
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_DIALOG_STATE', payload: { open: false, editing: null } });
|
||||
};
|
||||
|
||||
// 处理导出
|
||||
const handleExport = () => {
|
||||
const filteredCategories = state.categories.filter(category => {
|
||||
const matchKeyword = !state.searchKeyword ||
|
||||
category.name.includes(state.searchKeyword) ||
|
||||
category.code.includes(state.searchKeyword) ||
|
||||
category.label.includes(state.searchKeyword) ||
|
||||
category.value.includes(state.searchKeyword);
|
||||
const matchCategory = state.categoryFilter === 'all' || category.category === state.categoryFilter;
|
||||
return matchKeyword && matchCategory;
|
||||
});
|
||||
|
||||
const dataStr = JSON.stringify(filteredCategories, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `category_dictionary_${new Date().getTime()}.json`;
|
||||
link.click();
|
||||
toast.success('导出成功');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800 dark:text-green-600">分类字典</h2>
|
||||
<p className="text-muted-foreground">集中管理系统内所有基础字典项</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleExport}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出
|
||||
</Button>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增字典
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字典列表 */}
|
||||
<CategoryList
|
||||
categories={state.categories}
|
||||
searchKeyword={state.searchKeyword}
|
||||
categoryFilter={state.categoryFilter}
|
||||
onSearchChange={(keyword) => dispatch({ type: 'SET_SEARCH_KEYWORD', payload: keyword })}
|
||||
onCategoryFilterChange={(category) => dispatch({ type: 'SET_CATEGORY_FILTER', payload: category })}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* 编辑表单 */}
|
||||
<CategoryForm
|
||||
open={state.dialogState.open}
|
||||
editing={state.dialogState.editing}
|
||||
formData={state.formData}
|
||||
onFormDataChange={(data) => dispatch({ type: 'SET_FORM_DATA', payload: data })}
|
||||
onOpenChange={(open) => dispatch({ type: 'SET_DIALOG_STATE', payload: { open, editing: null } })}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<DeleteConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
category={categoryToDelete}
|
||||
onOpenChange={setDeleteConfirmOpen}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
crop-x/src/app/(app)/central-config/system/dictionary/reducer.ts
Normal file
109
crop-x/src/app/(app)/central-config/system/dictionary/reducer.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { CategoryState, CategoryAction, CategoryFormData } from './types';
|
||||
|
||||
// 初始状态
|
||||
export const initialCategoryState: CategoryState = {
|
||||
categories: [],
|
||||
searchKeyword: '',
|
||||
categoryFilter: 'all',
|
||||
dialogState: {
|
||||
open: false,
|
||||
editing: null,
|
||||
},
|
||||
formData: {
|
||||
code: '',
|
||||
name: '',
|
||||
category: 'other',
|
||||
value: '',
|
||||
label: '',
|
||||
sortOrder: 0,
|
||||
description: '',
|
||||
isActive: true,
|
||||
},
|
||||
};
|
||||
|
||||
// 初始表单数据
|
||||
export const initialFormData: CategoryFormData = {
|
||||
code: '',
|
||||
name: '',
|
||||
category: 'other',
|
||||
value: '',
|
||||
label: '',
|
||||
sortOrder: 0,
|
||||
description: '',
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
// Reducer
|
||||
export function categoryReducer(state: CategoryState, action: CategoryAction): CategoryState {
|
||||
switch (action.type) {
|
||||
case 'SET_CATEGORIES':
|
||||
return {
|
||||
...state,
|
||||
categories: action.payload,
|
||||
};
|
||||
|
||||
case 'ADD_CATEGORY':
|
||||
return {
|
||||
...state,
|
||||
categories: [...state.categories, action.payload],
|
||||
};
|
||||
|
||||
case 'UPDATE_CATEGORY':
|
||||
return {
|
||||
...state,
|
||||
categories: state.categories.map(category =>
|
||||
category.id === action.payload.id
|
||||
? { ...category, ...action.payload.updates, updatedAt: new Date().toISOString() }
|
||||
: category
|
||||
),
|
||||
};
|
||||
|
||||
case 'DELETE_CATEGORY':
|
||||
return {
|
||||
...state,
|
||||
categories: state.categories.filter(category => category.id !== action.payload),
|
||||
};
|
||||
|
||||
case 'SET_SEARCH_KEYWORD':
|
||||
return {
|
||||
...state,
|
||||
searchKeyword: action.payload,
|
||||
};
|
||||
|
||||
case 'SET_CATEGORY_FILTER':
|
||||
return {
|
||||
...state,
|
||||
categoryFilter: action.payload,
|
||||
};
|
||||
|
||||
case 'SET_DIALOG_STATE':
|
||||
return {
|
||||
...state,
|
||||
dialogState: action.payload,
|
||||
formData: action.payload.editing
|
||||
? {
|
||||
code: action.payload.editing.code,
|
||||
name: action.payload.editing.name,
|
||||
category: action.payload.editing.category,
|
||||
value: action.payload.editing.value,
|
||||
label: action.payload.editing.label,
|
||||
sortOrder: action.payload.editing.sortOrder,
|
||||
description: action.payload.editing.description || '',
|
||||
isActive: action.payload.editing.isActive,
|
||||
}
|
||||
: initialFormData,
|
||||
};
|
||||
|
||||
case 'SET_FORM_DATA':
|
||||
return {
|
||||
...state,
|
||||
formData: {
|
||||
...state.formData,
|
||||
...action.payload,
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// 分类字典类型定义
|
||||
|
||||
export interface CategoryDictionary {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
category: string; // 字典分类
|
||||
value: string;
|
||||
label: string;
|
||||
sortOrder: number;
|
||||
description?: string;
|
||||
isSystem: boolean; // 是否系统内置
|
||||
isActive: boolean;
|
||||
extendData?: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type DictionaryCategory =
|
||||
| 'gender'
|
||||
| 'status'
|
||||
| 'unit'
|
||||
| 'weather'
|
||||
| 'soil_type'
|
||||
| 'irrigation_method'
|
||||
| 'fertilizer_type'
|
||||
| 'pesticide_type'
|
||||
| 'task_status'
|
||||
| 'task_priority'
|
||||
| 'approval_status'
|
||||
| 'operation_type'
|
||||
| 'other';
|
||||
|
||||
// 分类表单数据
|
||||
export interface CategoryFormData {
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
value: string;
|
||||
label: string;
|
||||
sortOrder: number;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
// 分类操作类型
|
||||
export type CategoryAction =
|
||||
| { type: 'SET_CATEGORIES'; payload: CategoryDictionary[] }
|
||||
| { type: 'ADD_CATEGORY'; payload: CategoryDictionary }
|
||||
| { type: 'UPDATE_CATEGORY'; payload: { id: string; updates: Partial<CategoryDictionary> } }
|
||||
| { type: 'DELETE_CATEGORY'; payload: string }
|
||||
| { type: 'SET_SEARCH_KEYWORD'; payload: string }
|
||||
| { type: 'SET_CATEGORY_FILTER'; payload: string }
|
||||
| { type: 'SET_DIALOG_STATE'; payload: { open: boolean; editing?: CategoryDictionary | null } }
|
||||
| { type: 'SET_FORM_DATA'; payload: Partial<CategoryFormData> };
|
||||
|
||||
// 分类状态
|
||||
export interface CategoryState {
|
||||
categories: CategoryDictionary[];
|
||||
searchKeyword: string;
|
||||
categoryFilter: string;
|
||||
dialogState: {
|
||||
open: boolean;
|
||||
editing?: CategoryDictionary | null;
|
||||
};
|
||||
formData: CategoryFormData;
|
||||
}
|
||||
26
crop-x/src/app/(app)/central-config/system/page.tsx
Normal file
26
crop-x/src/app/(app)/central-config/system/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function SystemPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">系统参数</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Link href="/central-config/system/settings" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">系统设置</h3>
|
||||
<p className="text-gray-600 text-sm">配置系统基本参数</p>
|
||||
</Link>
|
||||
<Link href="/central-config/system/category" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">分类字典</h3>
|
||||
<p className="text-gray-600 text-sm">管理分类字典</p>
|
||||
</Link>
|
||||
<Link href="/central-config/system/dictionary" className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-semibold mb-2">数据字典</h3>
|
||||
<p className="text-gray-600 text-sm">管理数据字典</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface CopyrightInfoCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function CopyrightInfoCard({ settings, onSettingsChange }: CopyrightInfoCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">版权信息</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>ICP备案号</Label>
|
||||
<Input
|
||||
value={settings.icp || ''}
|
||||
onChange={(e) => onSettingsChange({ icp: e.target.value })}
|
||||
placeholder="京ICP备12345678号"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>版权声明</Label>
|
||||
<Input
|
||||
value={settings.copyright || ''}
|
||||
onChange={(e) => onSettingsChange({ copyright: e.target.value })}
|
||||
placeholder="© 2024 公司名称 版权所有"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface FeatureToggleCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function FeatureToggleCard({ settings, onSettingsChange }: FeatureToggleCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">功能开关</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>允许用户注册</Label>
|
||||
<p className="text-sm text-muted-foreground">开启后允许新用户自主注册账号</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.enableRegistration}
|
||||
onCheckedChange={(checked) => onSettingsChange({ enableRegistration: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>允许访客访问</Label>
|
||||
<p className="text-sm text-muted-foreground">开启后允许未登录用户访问部分公开内容</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.enableGuestAccess}
|
||||
onCheckedChange={(checked) => onSettingsChange({ enableGuestAccess: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface PasswordPolicyCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function PasswordPolicyCard({ settings, onSettingsChange }: PasswordPolicyCardProps) {
|
||||
const updatePasswordPolicy = (updates: Partial<SystemSettings['passwordPolicy']>) => {
|
||||
onSettingsChange({
|
||||
passwordPolicy: { ...settings.passwordPolicy, ...updates }
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">密码策略</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>最小密码长度</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.passwordPolicy.minLength}
|
||||
onChange={(e) => updatePasswordPolicy({ minLength: parseInt(e.target.value) || 8 })}
|
||||
min={6}
|
||||
max={32}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label>密码复杂度要求</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">要求包含大写字母</span>
|
||||
<Switch
|
||||
checked={settings.passwordPolicy.requireUppercase}
|
||||
onCheckedChange={(checked) => updatePasswordPolicy({ requireUppercase: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">要求包含小写字母</span>
|
||||
<Switch
|
||||
checked={settings.passwordPolicy.requireLowercase}
|
||||
onCheckedChange={(checked) => updatePasswordPolicy({ requireLowercase: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">要求包含数字</span>
|
||||
<Switch
|
||||
checked={settings.passwordPolicy.requireNumbers}
|
||||
onCheckedChange={(checked) => updatePasswordPolicy({ requireNumbers: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">要求包含特殊字符</span>
|
||||
<Switch
|
||||
checked={settings.passwordPolicy.requireSpecialChars}
|
||||
onCheckedChange={(checked) => updatePasswordPolicy({ requireSpecialChars: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { SystemSettings } from '@/types/system-params'
|
||||
|
||||
interface PlatformInfoCardProps {
|
||||
settings: SystemSettings
|
||||
onSettingsChange: (updates: Partial<SystemSettings>) => void
|
||||
}
|
||||
|
||||
export function PlatformInfoCard({ settings, onSettingsChange }: PlatformInfoCardProps) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4">平台信息</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>平台名称 *</Label>
|
||||
<Input
|
||||
value={settings.platformName}
|
||||
onChange={(e) => onSettingsChange({ platformName: e.target.value })}
|
||||
placeholder="请输入平台名称"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>公司名称</Label>
|
||||
<Input
|
||||
value={settings.companyName || ''}
|
||||
onChange={(e) => onSettingsChange({ companyName: e.target.value })}
|
||||
placeholder="请输入公司名称"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>联系邮箱</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={settings.contactEmail || ''}
|
||||
onChange={(e) => onSettingsChange({ contactEmail: e.target.value })}
|
||||
placeholder="support@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>联系电话</Label>
|
||||
<Input
|
||||
value={settings.contactPhone || ''}
|
||||
onChange={(e) => onSettingsChange({ contactPhone: e.target.value })}
|
||||
placeholder="400-888-8888"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label>公司地址</Label>
|
||||
<Input
|
||||
value={settings.address || ''}
|
||||
onChange={(e) => onSettingsChange({ address: e.target.value })}
|
||||
placeholder="请输入公司地址"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user