|
@@ -0,0 +1,1646 @@
|
|
|
|
|
+"use client"
|
|
|
|
|
+
|
|
|
|
|
+import "antd/dist/reset.css"
|
|
|
|
|
+
|
|
|
|
|
+import {useEffect, useMemo, useRef, useState} from "react"
|
|
|
|
|
+import dynamic from "next/dynamic"
|
|
|
|
|
+import type {MenuProps} from "antd"
|
|
|
|
|
+import {
|
|
|
|
|
+ Badge,
|
|
|
|
|
+ Button,
|
|
|
|
|
+ Card,
|
|
|
|
|
+ Col,
|
|
|
|
|
+ Collapse,
|
|
|
|
|
+ Descriptions,
|
|
|
|
|
+ Divider,
|
|
|
|
|
+ Drawer,
|
|
|
|
|
+ Form,
|
|
|
|
|
+ Input,
|
|
|
|
|
+ Layout,
|
|
|
|
|
+ Menu,
|
|
|
|
|
+ Modal,
|
|
|
|
|
+ Popover,
|
|
|
|
|
+ Progress,
|
|
|
|
|
+ Row,
|
|
|
|
|
+ Segmented,
|
|
|
|
|
+ Select,
|
|
|
|
|
+ Slider,
|
|
|
|
|
+ Space,
|
|
|
|
|
+ Statistic,
|
|
|
|
|
+ Steps,
|
|
|
|
|
+ Switch,
|
|
|
|
|
+ Table,
|
|
|
|
|
+ Tabs,
|
|
|
|
|
+ Tag,
|
|
|
|
|
+ Timeline,
|
|
|
|
|
+ Typography,
|
|
|
|
|
+ Upload,
|
|
|
|
|
+} from "antd"
|
|
|
|
|
+import {
|
|
|
|
|
+ Activity,
|
|
|
|
|
+ Bell,
|
|
|
|
|
+ Box,
|
|
|
|
|
+ Boxes,
|
|
|
|
|
+ Check,
|
|
|
|
|
+ CheckCircle2,
|
|
|
|
|
+ CloudUpload,
|
|
|
|
|
+ Cog,
|
|
|
|
|
+ Database,
|
|
|
|
|
+ Factory,
|
|
|
|
|
+ FileJson2,
|
|
|
|
|
+ Globe,
|
|
|
|
|
+ Layers,
|
|
|
|
|
+ LinkIcon,
|
|
|
|
|
+ Lock,
|
|
|
|
|
+ Network,
|
|
|
|
|
+ Package,
|
|
|
|
|
+ Radar,
|
|
|
|
|
+ RefreshCw,
|
|
|
|
|
+ Send,
|
|
|
|
|
+ Server,
|
|
|
|
|
+ Settings,
|
|
|
|
|
+ ShieldCheck,
|
|
|
|
|
+ SignalHigh,
|
|
|
|
|
+ Sprout,
|
|
|
|
|
+ UploadIcon,
|
|
|
|
|
+ Users,
|
|
|
|
|
+} from "lucide-react"
|
|
|
|
|
+import globalMessage from "@/app/_modules/globalMessage";
|
|
|
|
|
+
|
|
|
|
|
+const { Header, Sider, Content } = Layout
|
|
|
|
|
+const { TextArea } = Input
|
|
|
|
|
+const { Title, Text } = Typography
|
|
|
|
|
+
|
|
|
|
|
+// Lazy components that require browser APIs
|
|
|
|
|
+const DeviceMap = dynamic(() => import("./components/device-map"), { ssr: false })
|
|
|
|
|
+const RealtimeLine = dynamic(() => import("./components/realtime-line"), { ssr: false })
|
|
|
|
|
+const QueueGauge = dynamic(() => import("./components/queue-gauge"), { ssr: false })
|
|
|
|
|
+
|
|
|
|
|
+type Project = {
|
|
|
|
|
+ id: string
|
|
|
|
|
+ name: string
|
|
|
|
|
+ description?: string
|
|
|
|
|
+ createdAt: string
|
|
|
|
|
+}
|
|
|
|
|
+type Product = {
|
|
|
|
|
+ id: string
|
|
|
|
|
+ name: string
|
|
|
|
|
+ protocol: "TCP" | "UDP" | "LWM2M" | "MQTTS" | "HTTP/HTTPS"
|
|
|
|
|
+ projectId: string
|
|
|
|
|
+ thirdParty: boolean
|
|
|
|
|
+ authToken?: string
|
|
|
|
|
+}
|
|
|
|
|
+type ThingModel = {
|
|
|
|
|
+ id: string
|
|
|
|
|
+ productId: string
|
|
|
|
|
+ json: string
|
|
|
|
|
+}
|
|
|
|
|
+type Device = {
|
|
|
|
|
+ id: string
|
|
|
|
|
+ name: string
|
|
|
|
|
+ productId: string
|
|
|
|
|
+ projectId: string
|
|
|
|
|
+ status: "online" | "offline"
|
|
|
|
|
+ lastSeen?: string
|
|
|
|
|
+ lat: number
|
|
|
|
|
+ lng: number
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type Telemetry = {
|
|
|
|
|
+ ts: number
|
|
|
|
|
+ value: number
|
|
|
|
|
+ deviceId: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const initialProjects: Project[] = [
|
|
|
|
|
+ { id: "p1", name: "城市燃气", description: "管网与阀井监测", createdAt: new Date().toISOString() },
|
|
|
|
|
+ { id: "p2", name: "城市水务", description: "供排水管线监测", createdAt: new Date().toISOString() },
|
|
|
|
|
+]
|
|
|
|
|
+const initialProducts: Product[] = [
|
|
|
|
|
+ { id: "prod1", name: "压力传感器A", protocol: "MQTTS", projectId: "p1", thirdParty: false },
|
|
|
|
|
+ { id: "prod2", name: "井盖监测B", protocol: "HTTP/HTTPS", projectId: "p1", thirdParty: true, authToken: "TP-XXXX" },
|
|
|
|
|
+ { id: "prod3", name: "流量计C", protocol: "LWM2M", projectId: "p2", thirdParty: false },
|
|
|
|
|
+]
|
|
|
|
|
+const initialThingModels: ThingModel[] = [
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "tm1",
|
|
|
|
|
+ productId: "prod1",
|
|
|
|
|
+ json: JSON.stringify(
|
|
|
|
|
+ {
|
|
|
|
|
+ properties: [
|
|
|
|
|
+ { id: "pressure", name: "压力", type: "number", unit: "kPa" },
|
|
|
|
|
+ { id: "battery", name: "电量", type: "number", unit: "%" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ events: [{ id: "alarm", name: "告警", level: "high" }],
|
|
|
|
|
+ services: [{ id: "reset", name: "复位", in: {}, out: {} }],
|
|
|
|
|
+ },
|
|
|
|
|
+ null,
|
|
|
|
|
+ 2,
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+]
|
|
|
|
|
+const initialDevices: Device[] = [
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "dev-001",
|
|
|
|
|
+ name: "压力A-001",
|
|
|
|
|
+ productId: "prod1",
|
|
|
|
|
+ projectId: "p1",
|
|
|
|
|
+ status: "online",
|
|
|
|
|
+ lastSeen: new Date().toISOString(),
|
|
|
|
|
+ lat: 31.231706,
|
|
|
|
|
+ lng: 121.472644,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "dev-002",
|
|
|
|
|
+ name: "井盖B-002",
|
|
|
|
|
+ productId: "prod2",
|
|
|
|
|
+ projectId: "p1",
|
|
|
|
|
+ status: "offline",
|
|
|
|
|
+ lat: 31.220706,
|
|
|
|
|
+ lng: 121.462644,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "dev-003",
|
|
|
|
|
+ name: "流量C-003",
|
|
|
|
|
+ productId: "prod3",
|
|
|
|
|
+ projectId: "p2",
|
|
|
|
|
+ status: "online",
|
|
|
|
|
+ lastSeen: new Date().toISOString(),
|
|
|
|
|
+ lat: 31.241706,
|
|
|
|
|
+ lng: 121.482644,
|
|
|
|
|
+ },
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+function useInterval(callback: () => void, delay: number | null) {
|
|
|
|
|
+ const savedRef = useRef<() => void>(null)
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ savedRef.current = callback
|
|
|
|
|
+ }, [callback])
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (delay === null) return
|
|
|
|
|
+ const id = setInterval(() => savedRef.current && savedRef.current(), delay)
|
|
|
|
|
+ return () => clearInterval(id)
|
|
|
|
|
+ }, [delay])
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export default function Page() {
|
|
|
|
|
+ const [collapsed, setCollapsed] = useState(false)
|
|
|
|
|
+ const [activeKey, setActiveKey] = useState<string>("overview")
|
|
|
|
|
+
|
|
|
|
|
+ // Core states
|
|
|
|
|
+ const [projects, setProjects] = useState<Project[]>(initialProjects)
|
|
|
|
|
+ const [products, setProducts] = useState<Product[]>(initialProducts)
|
|
|
|
|
+ const [thingModels, setThingModels] = useState<ThingModel[]>(initialThingModels)
|
|
|
|
|
+ const [devices, setDevices] = useState<Device[]>(initialDevices)
|
|
|
|
|
+
|
|
|
|
|
+ const [telemetry, setTelemetry] = useState<Telemetry[]>([])
|
|
|
|
|
+ const [backlog, setBacklog] = useState<number>(8)
|
|
|
|
|
+ const [throughput, setThroughput] = useState<number>(120)
|
|
|
|
|
+ const [alerts, setAlerts] = useState<number>(1)
|
|
|
|
|
+ const [todayCount, setTodayCount] = useState<number>(3200)
|
|
|
|
|
+
|
|
|
|
|
+ const [logs, setLogs] = useState<
|
|
|
|
|
+ { key: string; time: string; deviceId: string; product: string; msg: string; level: "INFO" | "WARN" | "ERROR" }[]
|
|
|
|
|
+ >([])
|
|
|
|
|
+
|
|
|
|
|
+ // UI states for feedback
|
|
|
|
|
+ const [projectModalOpen, setProjectModalOpen] = useState(false)
|
|
|
|
|
+ const [productModalOpen, setProductModalOpen] = useState(false)
|
|
|
|
|
+ const [deviceModalOpen, setDeviceModalOpen] = useState(false)
|
|
|
|
|
+ const [cmdModal, setCmdModal] = useState<{ open: boolean; device?: Device }>({ open: false })
|
|
|
|
|
+ const [dataDrawer, setDataDrawer] = useState<{ open: boolean; device?: Device }>({ open: false })
|
|
|
|
|
+ const [registerDrawer, setRegisterDrawer] = useState(false)
|
|
|
|
|
+ const [notifOpen, setNotifOpen] = useState(false)
|
|
|
|
|
+
|
|
|
|
|
+ // Extra feedback modals
|
|
|
|
|
+ const [svcModal, setSvcModal] = useState<{ open: boolean; svc?: any }>({ open: false })
|
|
|
|
|
+ const [weightModal, setWeightModal] = useState<{ open: boolean; weight: number }>({ open: false, weight: 50 })
|
|
|
|
|
+ const [scheduleModal, setScheduleModal] = useState<boolean>(false)
|
|
|
|
|
+ const [apiAccessModal, setApiAccessModal] = useState<boolean>(false)
|
|
|
|
|
+ const [rbacModal, setRbacModal] = useState<{ open: boolean; user?: any }>({ open: false })
|
|
|
|
|
+
|
|
|
|
|
+ const [form] = Form.useForm()
|
|
|
|
|
+ const [productForm] = Form.useForm()
|
|
|
|
|
+ const [deviceForm] = Form.useForm()
|
|
|
|
|
+ const [cmdForm] = Form.useForm()
|
|
|
|
|
+
|
|
|
|
|
+ // Simulate telemetry stream, queue and device health
|
|
|
|
|
+ useInterval(() => {
|
|
|
|
|
+ const onlineDevices = devices.filter((d) => d.status === "online")
|
|
|
|
|
+ if (onlineDevices.length === 0) return
|
|
|
|
|
+ const randDevice = onlineDevices[Math.floor(Math.random() * onlineDevices.length)]
|
|
|
|
|
+ const v = Math.round(80 + Math.random() * 40 - 20)
|
|
|
|
|
+ const now = Date.now()
|
|
|
|
|
+ setTelemetry((prev) => {
|
|
|
|
|
+ const next = [...prev, { ts: now, value: v, deviceId: randDevice.id }].slice(-120)
|
|
|
|
|
+ return next
|
|
|
|
|
+ })
|
|
|
|
|
+ setTodayCount((c) => c + 1)
|
|
|
|
|
+ setBacklog((b) => Math.max(0, b + 1 - Math.floor(throughput / 100)))
|
|
|
|
|
+ setLogs((prev) => {
|
|
|
|
|
+ const product = products.find((p) => p.id === randDevice.productId)?.name ?? "-"
|
|
|
|
|
+ const next = [
|
|
|
|
|
+ {
|
|
|
|
|
+ key: `${now}`,
|
|
|
|
|
+ time: new Date(now).toLocaleTimeString(),
|
|
|
|
|
+ deviceId: randDevice.id,
|
|
|
|
|
+ product,
|
|
|
|
|
+ msg: `上报: value=${v}`,
|
|
|
|
|
+ level: "INFO" as const,
|
|
|
|
|
+ },
|
|
|
|
|
+ ...prev,
|
|
|
|
|
+ ].slice(0, 50)
|
|
|
|
|
+ return next
|
|
|
|
|
+ })
|
|
|
|
|
+ if (Math.random() < 0.03) {
|
|
|
|
|
+ setAlerts((a) => a + 1)
|
|
|
|
|
+ setLogs((prev) => [
|
|
|
|
|
+ {
|
|
|
|
|
+ key: `${now}-warn`,
|
|
|
|
|
+ time: new Date(now).toLocaleTimeString(),
|
|
|
|
|
+ deviceId: randDevice.id,
|
|
|
|
|
+ product: products.find((p) => p.id === randDevice.productId)?.name ?? "-",
|
|
|
|
|
+ msg: "告警: 压力异常",
|
|
|
|
|
+ level: "WARN",
|
|
|
|
|
+ },
|
|
|
|
|
+ ...prev,
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+ setDevices((prev) => prev.map((d) => (d.id === randDevice.id ? { ...d, lastSeen: new Date().toISOString() } : d)))
|
|
|
|
|
+ }, 1200)
|
|
|
|
|
+
|
|
|
|
|
+ const onlineCount = devices.filter((d) => d.status === "online").length
|
|
|
|
|
+
|
|
|
|
|
+ const menuItems: MenuProps["items"] = [
|
|
|
|
|
+ { key: "overview", icon: <Activity size={18} />, label: "总览" },
|
|
|
|
|
+ { key: "south", icon: <Server size={18} />, label: "南向接入" },
|
|
|
|
|
+ { key: "terminal", icon: <LinkIcon size={18} />, label: "终端接入" },
|
|
|
|
|
+ { key: "distribution", icon: <Send size={18} />, label: "数据分发" },
|
|
|
|
|
+ { key: "mq", icon: <Network size={18} />, label: "北向消息队列" },
|
|
|
|
|
+ { key: "warehouse", icon: <Database size={18} />, label: "数据仓库" },
|
|
|
|
|
+ { key: "openapi", icon: <Globe size={18} />, label: "数据共享开放" },
|
|
|
|
|
+ { key: "micro", icon: <Boxes size={18} />, label: "微服务" },
|
|
|
|
|
+ { key: "auth", icon: <ShieldCheck size={18} />, label: "认证鉴权" },
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ const projectOptions = projects.map((p) => ({ label: p.name, value: p.id }))
|
|
|
|
|
+ const productOptions = products.map((p) => ({ label: p.name, value: p.id }))
|
|
|
|
|
+
|
|
|
|
|
+ function addProject(values: any) {
|
|
|
|
|
+ const p: Project = {
|
|
|
|
|
+ id: `p-${Math.random().toString(36).slice(2, 8)}`,
|
|
|
|
|
+ name: values.name,
|
|
|
|
|
+ description: values.description,
|
|
|
|
|
+ createdAt: new Date().toISOString(),
|
|
|
|
|
+ }
|
|
|
|
|
+ setProjects((prev) => [p, ...prev])
|
|
|
|
|
+ setProjectModalOpen(false)
|
|
|
|
|
+ form.resetFields()
|
|
|
|
|
+ globalMessage.success("项目已创建")
|
|
|
|
|
+ }
|
|
|
|
|
+ function addProduct(values: any) {
|
|
|
|
|
+ const prod: Product = {
|
|
|
|
|
+ id: `prod-${Math.random().toString(36).slice(2, 8)}`,
|
|
|
|
|
+ name: values.name,
|
|
|
|
|
+ protocol: values.protocol,
|
|
|
|
|
+ projectId: values.projectId,
|
|
|
|
|
+ thirdParty: values.thirdParty || false,
|
|
|
|
|
+ authToken: values.thirdParty ? `TP-${Math.random().toString(36).slice(2, 8)}` : undefined,
|
|
|
|
|
+ }
|
|
|
|
|
+ setProducts((prev) => [prod, ...prev])
|
|
|
|
|
+ setProductModalOpen(false)
|
|
|
|
|
+ productForm.resetFields()
|
|
|
|
|
+ globalMessage.success("产品已创建")
|
|
|
|
|
+ }
|
|
|
|
|
+ function addDevice(values: any) {
|
|
|
|
|
+ const dev: Device = {
|
|
|
|
|
+ id: values.id,
|
|
|
|
|
+ name: values.name,
|
|
|
|
|
+ productId: values.productId,
|
|
|
|
|
+ projectId: values.projectId,
|
|
|
|
|
+ status: "offline",
|
|
|
|
|
+ lat: values.lat ?? 31.23 + Math.random() * 0.05,
|
|
|
|
|
+ lng: values.lng ?? 121.47 + Math.random() * 0.05,
|
|
|
|
|
+ }
|
|
|
|
|
+ setDevices((prev) => [dev, ...prev])
|
|
|
|
|
+ setDeviceModalOpen(false)
|
|
|
|
|
+ deviceForm.resetFields()
|
|
|
|
|
+ globalMessage.success("设备已注册")
|
|
|
|
|
+ }
|
|
|
|
|
+ function toggleDevice(id: string, online: boolean) {
|
|
|
|
|
+ setDevices((prev) =>
|
|
|
|
|
+ prev.map((d) =>
|
|
|
|
|
+ d.id === id
|
|
|
|
|
+ ? { ...d, status: online ? "online" : "offline", lastSeen: online ? new Date().toISOString() : d.lastSeen }
|
|
|
|
|
+ : d,
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+ globalMessage.success(online ? "设备上线成功" : "设备已下线")
|
|
|
|
|
+ }
|
|
|
|
|
+ function sendCommand(values: any) {
|
|
|
|
|
+ const device = cmdModal.device
|
|
|
|
|
+ if (!device) return
|
|
|
|
|
+ const now = Date.now()
|
|
|
|
|
+ setLogs((prev) => [
|
|
|
|
|
+ {
|
|
|
|
|
+ key: `${now}-cmd`,
|
|
|
|
|
+ time: new Date(now).toLocaleTimeString(),
|
|
|
|
|
+ deviceId: device.id,
|
|
|
|
|
+ product: products.find((p) => p.id === device.productId)?.name ?? "-",
|
|
|
|
|
+ msg: `下发指令: ${values.serviceId} ${values.payload}`,
|
|
|
|
|
+ level: "INFO",
|
|
|
|
|
+ },
|
|
|
|
|
+ ...prev,
|
|
|
|
|
+ ])
|
|
|
|
|
+ setCmdModal({ open: false })
|
|
|
|
|
+ cmdForm.resetFields()
|
|
|
|
|
+ globalMessage.success("指令已发送")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const telemetrySeries = useMemo(() => {
|
|
|
|
|
+ const xs = telemetry.map((t) => new Date(t.ts).toLocaleTimeString())
|
|
|
|
|
+ const ys = telemetry.map((t) => t.value)
|
|
|
|
|
+ return { xs, ys }
|
|
|
|
|
+ }, [telemetry])
|
|
|
|
|
+
|
|
|
|
|
+ const alertLogs = logs.filter((l) => l.level !== "INFO").slice(0, 10)
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Layout style={{ minHeight: "100vh" }}>
|
|
|
|
|
+ <Sider collapsible collapsed={collapsed} onCollapse={setCollapsed} width={240}>
|
|
|
|
|
+ <div style={{ height: 60, display: "flex", alignItems: "center", padding: "0 12px", gap: 8 }}>
|
|
|
|
|
+ <Sprout size={22} color="white" />
|
|
|
|
|
+ {!collapsed && <Text style={{ color: "white", fontWeight: 600 }}>生命线物联网平台</Text>}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <Menu
|
|
|
|
|
+ theme="dark"
|
|
|
|
|
+ mode="inline"
|
|
|
|
|
+ selectedKeys={[activeKey]}
|
|
|
|
|
+ items={menuItems}
|
|
|
|
|
+ onClick={(e) => setActiveKey(e.key)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Sider>
|
|
|
|
|
+ <Layout>
|
|
|
|
|
+ <Header
|
|
|
|
|
+ style={{
|
|
|
|
|
+ background: "white",
|
|
|
|
|
+ borderBottom: "1px solid #f0f0f0",
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ alignItems: "center",
|
|
|
|
|
+ padding: "0 16px",
|
|
|
|
|
+ gap: 12,
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Radar size={22} />
|
|
|
|
|
+ <Title level={4} style={{ margin: 0 }}>
|
|
|
|
|
+ 城市生命线物联网平台
|
|
|
|
|
+ </Title>
|
|
|
|
|
+ <div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 12 }}>
|
|
|
|
|
+ <Badge count={alerts} overflowCount={99}>
|
|
|
|
|
+ <Button icon={<Bell size={16} />} onClick={() => setNotifOpen(true)} aria-label="查看通知" />
|
|
|
|
|
+ </Badge>
|
|
|
|
|
+ <Button icon={<RefreshCw size={16} />} onClick={() => globalMessage.success("已刷新")} aria-label="刷新" />
|
|
|
|
|
+ <Button type="primary" icon={<CloudUpload size={16} />} onClick={() => setRegisterDrawer(true)}>
|
|
|
|
|
+ 设备注册
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Header>
|
|
|
|
|
+ <Content style={{ padding: 16 }}>
|
|
|
|
|
+ {activeKey === "overview" && (
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ <Row gutter={[16, 16]}>
|
|
|
|
|
+ <Col xs={12} md={6}>
|
|
|
|
|
+ <Card>
|
|
|
|
|
+ <Statistic title="设备在线" value={onlineCount} prefix={<SignalHigh size={16} />} />
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col xs={12} md={6}>
|
|
|
|
|
+ <Card>
|
|
|
|
|
+ <Statistic title="今日上报" value={todayCount} prefix={<UploadIcon size={16} />} />
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col xs={12} md={6}>
|
|
|
|
|
+ <Card>
|
|
|
|
|
+ <Statistic title="队列积压" value={backlog} suffix="条" prefix={<Layers size={16} />} />
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col xs={12} md={6}>
|
|
|
|
|
+ <Card>
|
|
|
|
|
+ <Statistic title="告警数" value={alerts} prefix={<AlertIcon />} />
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ <Row gutter={[16, 16]}>
|
|
|
|
|
+ <Col xs={24} lg={16}>
|
|
|
|
|
+ <Card title="实时监测趋势">
|
|
|
|
|
+ <RealtimeLine title="压力/流量趋势" xs={telemetrySeries.xs} ys={telemetrySeries.ys} />
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col xs={24} lg={8}>
|
|
|
|
|
+ <Card title="消息队列压力">
|
|
|
|
|
+ <QueueGauge backlog={backlog} throughput={throughput} />
|
|
|
|
|
+ <Divider />
|
|
|
|
|
+ <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
|
|
|
+ <Text>处理吞吐</Text>
|
|
|
|
|
+ <Segmented
|
|
|
|
|
+ options={[
|
|
|
|
|
+ { label: "低", value: 80 },
|
|
|
|
|
+ { label: "中", value: 120 },
|
|
|
|
|
+ { label: "高", value: 200 },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ value={throughput}
|
|
|
|
|
+ onChange={(v) => {
|
|
|
|
|
+ setThroughput(Number(v))
|
|
|
|
|
+ globalMessage.success(`已切换吞吐:${v}/s`)
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ <Row gutter={[16, 16]}>
|
|
|
|
|
+ <Col xs={24} lg={12}>
|
|
|
|
|
+ <Card title="设备分布地图">
|
|
|
|
|
+ <div style={{ height: 360 }}>
|
|
|
|
|
+ <DeviceMap devices={devices} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col xs={24} lg={12}>
|
|
|
|
|
+ <Card title="实时日志">
|
|
|
|
|
+ <Table
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ dataSource={logs}
|
|
|
|
|
+ pagination={{ pageSize: 6 }}
|
|
|
|
|
+ columns={[
|
|
|
|
|
+ { title: "时间", dataIndex: "time", width: 100 },
|
|
|
|
|
+ { title: "设备ID", dataIndex: "deviceId", width: 120 },
|
|
|
|
|
+ { title: "产品", dataIndex: "product", width: 120 },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "级别",
|
|
|
|
|
+ dataIndex: "level",
|
|
|
|
|
+ width: 80,
|
|
|
|
|
+ render: (lv) =>
|
|
|
|
|
+ lv === "INFO" ? (
|
|
|
|
|
+ <Tag color="blue">INFO</Tag>
|
|
|
|
|
+ ) : lv === "WARN" ? (
|
|
|
|
|
+ <Tag color="orange">WARN</Tag>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <Tag color="red">ERROR</Tag>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ { title: "消息", dataIndex: "msg" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {activeKey === "south" && (
|
|
|
|
|
+ <Card>
|
|
|
|
|
+ <Tabs
|
|
|
|
|
+ defaultActiveKey="projects"
|
|
|
|
|
+ items={[
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "projects",
|
|
|
|
|
+ label: (
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <Package size={16} /> 项目管理
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ),
|
|
|
|
|
+ children: (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="flex justify-between mb-3">
|
|
|
|
|
+ <Text type="secondary">按项目分类设备,方便资产统一管理</Text>
|
|
|
|
|
+ <Button type="primary" onClick={() => setProjectModalOpen(true)} icon={<PlusIcon />}>
|
|
|
|
|
+ 新建项目
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <Table<Project>
|
|
|
|
|
+ rowKey="id"
|
|
|
|
|
+ dataSource={projects}
|
|
|
|
|
+ columns={[
|
|
|
|
|
+ { title: "项目名称", dataIndex: "name" },
|
|
|
|
|
+ { title: "描述", dataIndex: "description" },
|
|
|
|
|
+ { title: "创建时间", dataIndex: "createdAt", render: (t) => new Date(t).toLocaleString() },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "设备数",
|
|
|
|
|
+ render: (_, r) => devices.filter((d) => d.projectId === r.id).length,
|
|
|
|
|
+ },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ title="新建项目"
|
|
|
|
|
+ open={projectModalOpen}
|
|
|
|
|
+ onCancel={() => setProjectModalOpen(false)}
|
|
|
|
|
+ onOk={() => form.submit()}
|
|
|
|
|
+ okText="保存"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form form={form} layout="vertical" onFinish={addProject}>
|
|
|
|
|
+ <Form.Item label="项目名称" name="name" rules={[{ required: true, message: "请输入名称" }]}>
|
|
|
|
|
+ <Input placeholder="例如:城市燃气" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="描述" name="description">
|
|
|
|
|
+ <Input placeholder="可选" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+ </>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "products",
|
|
|
|
|
+ label: (
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <Box size={16} /> 产品管理
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ),
|
|
|
|
|
+ children: (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="flex justify-between mb-3">
|
|
|
|
|
+ <Text type="secondary">定义产品、添加设备,管理自建与第三方授权产品</Text>
|
|
|
|
|
+ <Button type="primary" icon={<PlusIcon />} onClick={() => setProductModalOpen(true)}>
|
|
|
|
|
+ 新建产品
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <Table<Product>
|
|
|
|
|
+ rowKey="id"
|
|
|
|
|
+ dataSource={products}
|
|
|
|
|
+ columns={[
|
|
|
|
|
+ { title: "名称", dataIndex: "name" },
|
|
|
|
|
+ { title: "协议", dataIndex: "protocol" },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "归属项目",
|
|
|
|
|
+ dataIndex: "projectId",
|
|
|
|
|
+ render: (id) => projects.find((p) => p.id === id)?.name,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "类型",
|
|
|
|
|
+ dataIndex: "thirdParty",
|
|
|
|
|
+ render: (v: boolean) =>
|
|
|
|
|
+ v ? <Tag color="purple">第三方</Tag> : <Tag color="green">自建</Tag>,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "授权Token",
|
|
|
|
|
+ dataIndex: "authToken",
|
|
|
|
|
+ render: (t) => (t ? <Text code>{t}</Text> : <Text type="secondary">-</Text>),
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "设备数",
|
|
|
|
|
+ render: (_, r) => devices.filter((d) => d.productId === r.id).length,
|
|
|
|
|
+ },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ title="新建产品"
|
|
|
|
|
+ open={productModalOpen}
|
|
|
|
|
+ onCancel={() => setProductModalOpen(false)}
|
|
|
|
|
+ onOk={() => productForm.submit()}
|
|
|
|
|
+ okText="保存"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form form={productForm} layout="vertical" onFinish={addProduct}>
|
|
|
|
|
+ <Form.Item label="产品名称" name="name" rules={[{ required: true }]}>
|
|
|
|
|
+ <Input placeholder="例如:压力传感器A" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="接入协议" name="protocol" rules={[{ required: true }]}>
|
|
|
|
|
+ <Select
|
|
|
|
|
+ options={[
|
|
|
|
|
+ { label: "TCP", value: "TCP" },
|
|
|
|
|
+ { label: "UDP", value: "UDP" },
|
|
|
|
|
+ { label: "LWM2M", value: "LWM2M" },
|
|
|
|
|
+ { label: "MQTTS", value: "MQTTS" },
|
|
|
|
|
+ { label: "HTTP/HTTPS", value: "HTTP/HTTPS" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="归属项目" name="projectId" rules={[{ required: true }]}>
|
|
|
|
|
+ <Select options={projectOptions} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="第三方产品" name="thirdParty" valuePropName="checked">
|
|
|
|
|
+ <Switch />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+ </>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "thing",
|
|
|
|
|
+ label: (
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <FileJson2 size={16} /> 物模型管理
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ),
|
|
|
|
|
+ children: (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <Row gutter={[16, 16]}>
|
|
|
|
|
+ <Col xs={24} md={12}>
|
|
|
|
|
+ <Card
|
|
|
|
|
+ title="定义物模型(JSON)"
|
|
|
|
|
+ extra={
|
|
|
|
|
+ <Button
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ const t: ThingModel = {
|
|
|
|
|
+ id: `tm-${Math.random().toString(36).slice(2, 8)}`,
|
|
|
|
|
+ productId: products[0]?.id ?? "",
|
|
|
|
|
+ json: JSON.stringify(
|
|
|
|
|
+ {
|
|
|
|
|
+ properties: [{ id: "temp", name: "温度", type: "number", unit: "℃" }],
|
|
|
|
|
+ events: [],
|
|
|
|
|
+ services: [],
|
|
|
|
|
+ },
|
|
|
|
|
+ null,
|
|
|
|
|
+ 2,
|
|
|
|
|
+ ),
|
|
|
|
|
+ }
|
|
|
|
|
+ setThingModels((prev) => [t, ...prev])
|
|
|
|
|
+ globalMessage.success("已创建示例物模型")
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ 创建示例
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ }
|
|
|
|
|
+ >
|
|
|
|
|
+ <Table
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ rowKey="id"
|
|
|
|
|
+ dataSource={thingModels}
|
|
|
|
|
+ pagination={{ pageSize: 5 }}
|
|
|
|
|
+ columns={[
|
|
|
|
|
+ { title: "ID", dataIndex: "id", width: 120 },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "产品",
|
|
|
|
|
+ dataIndex: "productId",
|
|
|
|
|
+ render: (id: string) => products.find((p) => p.id === id)?.name ?? "-",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "属性数",
|
|
|
|
|
+ render: (_, r: ThingModel) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const o = JSON.parse(r.json)
|
|
|
|
|
+ return o.properties?.length ?? 0
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return "-"
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "操作",
|
|
|
|
|
+ render: (_, r: ThingModel) => (
|
|
|
|
|
+ <Space>
|
|
|
|
|
+ <Popover
|
|
|
|
|
+ title="物模型预览"
|
|
|
|
|
+ content={
|
|
|
|
|
+ <pre style={{ maxWidth: 360, maxHeight: 200, overflow: "auto", margin: 0 }}>
|
|
|
|
|
+ {r.json}
|
|
|
|
|
+ </pre>
|
|
|
|
|
+ }
|
|
|
|
|
+ >
|
|
|
|
|
+ <Button size="small">预览</Button>
|
|
|
|
|
+ </Popover>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ const prod = products.find((p) => p.id === r.productId)
|
|
|
|
|
+ globalMessage.success(`已应用到产品 ${prod?.name ?? "-"}`)
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ 应用到产品
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col xs={24} md={12}>
|
|
|
|
|
+ <Card title="快速校验">
|
|
|
|
|
+ <Form
|
|
|
|
|
+ layout="vertical"
|
|
|
|
|
+ onFinish={(v) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const obj = JSON.parse(v.json)
|
|
|
|
|
+ if (!Array.isArray(obj.properties)) throw new Error("缺少 properties")
|
|
|
|
|
+ globalMessage.success("物模型JSON校验通过")
|
|
|
|
|
+ } catch (e: any) {
|
|
|
|
|
+ globalMessage.error(`JSON错误: ${e.message}`)
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ label="JSON定义"
|
|
|
|
|
+ name="json"
|
|
|
|
|
+ rules={[{ required: true, message: "请输入物模型JSON" }]}
|
|
|
|
|
+ initialValue={thingModels[0]?.json}
|
|
|
|
|
+ >
|
|
|
|
|
+ <TextArea rows={12} placeholder="输入物模型JSON" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Button type="primary" htmlType="submit" icon={<Check size={16} />}>
|
|
|
|
|
+ 校验
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ </>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "devices",
|
|
|
|
|
+ label: (
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <Factory size={16} /> 设备管理
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ),
|
|
|
|
|
+ children: (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="flex justify-between mb-3">
|
|
|
|
|
+ <Text type="secondary">注册、上线、上报数据与指令下发</Text>
|
|
|
|
|
+ <Space>
|
|
|
|
|
+ <Button icon={<PlusIcon />} onClick={() => setDeviceModalOpen(true)}>
|
|
|
|
|
+ 注册设备
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button type="primary" icon={<Send size={16} />} onClick={() => setRegisterDrawer(true)}>
|
|
|
|
|
+ 批量/页面注册
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <Table<Device>
|
|
|
|
|
+ rowKey="id"
|
|
|
|
|
+ dataSource={devices}
|
|
|
|
|
+ pagination={{ pageSize: 8 }}
|
|
|
|
|
+ columns={[
|
|
|
|
|
+ { title: "设备ID", dataIndex: "id", width: 140 },
|
|
|
|
|
+ { title: "名称", dataIndex: "name" },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "项目",
|
|
|
|
|
+ dataIndex: "projectId",
|
|
|
|
|
+ render: (id) => projects.find((p) => p.id === id)?.name,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "产品",
|
|
|
|
|
+ dataIndex: "productId",
|
|
|
|
|
+ render: (id) => products.find((p) => p.id === id)?.name,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "状态",
|
|
|
|
|
+ dataIndex: "status",
|
|
|
|
|
+ render: (s: Device["status"]) =>
|
|
|
|
|
+ s === "online" ? <Tag color="green">在线</Tag> : <Tag>离线</Tag>,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "最后上报",
|
|
|
|
|
+ dataIndex: "lastSeen",
|
|
|
|
|
+ render: (t) => (t ? new Date(t).toLocaleString() : "-"),
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "操作",
|
|
|
|
|
+ render: (_, r) => (
|
|
|
|
|
+ <Space>
|
|
|
|
|
+ {r.status === "online" ? (
|
|
|
|
|
+ <Button size="small" onClick={() => toggleDevice(r.id, false)}>
|
|
|
|
|
+ 下线
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <Button size="small" type="primary" onClick={() => toggleDevice(r.id, true)}>
|
|
|
|
|
+ 上线
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <Button
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ icon={<Send size={14} />}
|
|
|
|
|
+ onClick={() => setCmdModal({ open: true, device: r })}
|
|
|
|
|
+ >
|
|
|
|
|
+ 下发指令
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button size="small" onClick={() => setDataDrawer({ open: true, device: r })}>
|
|
|
|
|
+ 上报数据
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ title="注册设备"
|
|
|
|
|
+ open={deviceModalOpen}
|
|
|
|
|
+ onCancel={() => setDeviceModalOpen(false)}
|
|
|
|
|
+ onOk={() => deviceForm.submit()}
|
|
|
|
|
+ okText="保存"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form form={deviceForm} layout="vertical" onFinish={addDevice}>
|
|
|
|
|
+ <Form.Item label="设备ID" name="id" rules={[{ required: true }]}>
|
|
|
|
|
+ <Input placeholder="例如:dev-004" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="设备名称" name="name" rules={[{ required: true }]}>
|
|
|
|
|
+ <Input placeholder="例如:压力A-004" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="项目" name="projectId" rules={[{ required: true }]}>
|
|
|
|
|
+ <Select options={projectOptions} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="产品" name="productId" rules={[{ required: true }]}>
|
|
|
|
|
+ <Select options={productOptions} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Row gutter={8}>
|
|
|
|
|
+ <Col span={12}>
|
|
|
|
|
+ <Form.Item label="纬度" name="lat">
|
|
|
|
|
+ <Input type="number" placeholder="31.23" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col span={12}>
|
|
|
|
|
+ <Form.Item label="经度" name="lng">
|
|
|
|
|
+ <Input type="number" placeholder="121.47" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ title={`下发指令 - ${cmdModal.device?.name ?? ""}`}
|
|
|
|
|
+ open={cmdModal.open}
|
|
|
|
|
+ onCancel={() => setCmdModal({ open: false })}
|
|
|
|
|
+ onOk={() => cmdForm.submit()}
|
|
|
|
|
+ okText="发送"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form form={cmdForm} layout="vertical" onFinish={sendCommand}>
|
|
|
|
|
+ <Form.Item label="服务标识" name="serviceId" rules={[{ required: true }]}>
|
|
|
|
|
+ <Input placeholder="例如:reset" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="负载(JSON)" name="payload" rules={[{ required: true }]}>
|
|
|
|
|
+ <TextArea rows={6} placeholder='例如:{"delay":5}' />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+
|
|
|
|
|
+ <Drawer
|
|
|
|
|
+ title={`上报数据 - ${dataDrawer.device?.name ?? ""}`}
|
|
|
|
|
+ open={dataDrawer.open}
|
|
|
|
|
+ onClose={() => setDataDrawer({ open: false })}
|
|
|
|
|
+ width={520}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Table
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ pagination={{ pageSize: 10 }}
|
|
|
|
|
+ dataSource={logs.filter((l) => l.deviceId === dataDrawer.device?.id)}
|
|
|
|
|
+ columns={[
|
|
|
|
|
+ { title: "时间", dataIndex: "time", width: 120 },
|
|
|
|
|
+ { title: "级别", dataIndex: "level", width: 80 },
|
|
|
|
|
+ { title: "消息", dataIndex: "msg" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Drawer>
|
|
|
|
|
+ </>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {activeKey === "terminal" && (
|
|
|
|
|
+ <Row gutter={[16, 16]}>
|
|
|
|
|
+ <Col xs={24} lg={14}>
|
|
|
|
|
+ <Card title="设备连接与认证流程">
|
|
|
|
|
+ <Steps
|
|
|
|
|
+ direction="vertical"
|
|
|
|
|
+ items={[
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "选择协议连接",
|
|
|
|
|
+ description: "支持 TCP / UDP / LWM2M / MQTTS / HTTP(S)",
|
|
|
|
|
+ icon: <LinkIcon size={16} />,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "设备注册(批量/单一)",
|
|
|
|
|
+ description: "提供服务交互或页面交互两种方式",
|
|
|
|
|
+ icon: <Users size={16} />,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "授权与安全认证",
|
|
|
|
|
+ description: "加解密与签名认证模块,确保高安全场景",
|
|
|
|
|
+ icon: <Lock size={16} />,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "上线与心跳",
|
|
|
|
|
+ description: "接入后定期保活,保持在线状态",
|
|
|
|
|
+ icon: <CheckCircle2 size={16} />,
|
|
|
|
|
+ },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ <Divider />
|
|
|
|
|
+ <Form
|
|
|
|
|
+ layout="vertical"
|
|
|
|
|
+ onFinish={() => {
|
|
|
|
|
+ globalMessage.success("认证通过")
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Row gutter={12}>
|
|
|
|
|
+ <Col span={12}>
|
|
|
|
|
+ <Form.Item label="设备ID" name="id" rules={[{ required: true }]}>
|
|
|
|
|
+ <Input placeholder="dev-xxx" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col span={12}>
|
|
|
|
|
+ <Form.Item label="协议" name="protocol" rules={[{ required: true }]}>
|
|
|
|
|
+ <Select
|
|
|
|
|
+ options={[
|
|
|
|
|
+ { label: "TCP", value: "TCP" },
|
|
|
|
|
+ { label: "UDP", value: "UDP" },
|
|
|
|
|
+ { label: "LWM2M", value: "LWM2M" },
|
|
|
|
|
+ { label: "MQTTS", value: "MQTTS" },
|
|
|
|
|
+ { label: "HTTP/HTTPS", value: "HTTP/HTTPS" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ <Form.Item label="设备密钥" name="secret" rules={[{ required: true }]}>
|
|
|
|
|
+ <Input.Password placeholder="******" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Button type="primary" htmlType="submit" icon={<ShieldCheck size={16} />}>
|
|
|
|
|
+ 认证
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col xs={24} lg={10}>
|
|
|
|
|
+ <Card title="批量注册(CSV)">
|
|
|
|
|
+ <Upload.Dragger
|
|
|
|
|
+ multiple
|
|
|
|
|
+ beforeUpload={() => {
|
|
|
|
|
+ globalMessage.success("CSV已接收,已加入处理队列")
|
|
|
|
|
+ return false
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <p className="ant-upload-drag-icon">
|
|
|
|
|
+ <UploadIcon />
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <p className="ant-upload-text">点击或拖拽CSV文件到此处上传</p>
|
|
|
|
|
+ <p className="ant-upload-hint">示例列:deviceId,productId,projectId,secret</p>
|
|
|
|
|
+ </Upload.Dragger>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ <Card title="连接参数示例" className="mt-4">
|
|
|
|
|
+ <Descriptions size="small" column={1} bordered>
|
|
|
|
|
+ <Descriptions.Item label="MQTTS 服务器">mqtts://iot.example.com:8883</Descriptions.Item>
|
|
|
|
|
+ <Descriptions.Item label="HTTP 上报">POST https://api.example.com/iot/ingest</Descriptions.Item>
|
|
|
|
|
+ <Descriptions.Item label="LWM2M 服务器">coaps://lwm2m.example.com:5684</Descriptions.Item>
|
|
|
|
|
+ </Descriptions>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {activeKey === "distribution" && (
|
|
|
|
|
+ <Row gutter={[16, 16]}>
|
|
|
|
|
+ <Col xs={24} md={12}>
|
|
|
|
|
+ <Card title="数据接入与加工流程">
|
|
|
|
|
+ <Timeline
|
|
|
|
|
+ items={[
|
|
|
|
|
+ {
|
|
|
|
|
+ color: "blue",
|
|
|
|
|
+ children: "协议接入(TCP/UDP/LWM2M/MQTTS/HTTP)",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ color: "green",
|
|
|
|
|
+ children: "报文解析(TLV/JSON/自定义)",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ color: "green",
|
|
|
|
|
+ children: "清洗与标准化(字段映射/单位换算/去噪)",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ color: "green",
|
|
|
|
|
+ children: "入库(时序/对象/冷存)",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ color: "purple",
|
|
|
|
|
+ children: "开放共享(发布/订阅)",
|
|
|
|
|
+ },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ <Card title="实时发布/订阅" className="mt-4">
|
|
|
|
|
+ <Space direction="vertical" style={{ width: "100%" }}>
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <Text>发布主题</Text>
|
|
|
|
|
+ <Text code>{`/city/${products[0]?.id ?? "prod"}/pressure`}</Text>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-8">
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <Text type="secondary">发布</Text>
|
|
|
|
|
+ <Switch defaultChecked onChange={(c) => globalMessage.success(c ? "已开启发布" : "已关闭发布")} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <Text type="secondary">订阅</Text>
|
|
|
|
|
+ <Switch defaultChecked onChange={(c) => globalMessage.success(c ? "已开启订阅" : "已关闭订阅")} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <Button icon={<Send size={16} />} onClick={() => globalMessage.success("已发布一条消息")}>
|
|
|
|
|
+ 推送一条
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col xs={24} md={12}>
|
|
|
|
|
+ <Card title="加工结果预览">
|
|
|
|
|
+ <pre
|
|
|
|
|
+ style={{
|
|
|
|
|
+ maxHeight: 320,
|
|
|
|
|
+ overflow: "auto",
|
|
|
|
|
+ background: "#0b1020",
|
|
|
|
|
+ color: "#d6e5ff",
|
|
|
|
|
+ padding: 12,
|
|
|
|
|
+ borderRadius: 8,
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {`{
|
|
|
|
|
+ "deviceId": "${devices[0]?.id ?? "dev"}",
|
|
|
|
|
+ "ts": "${new Date().toISOString()}",
|
|
|
|
|
+ "model": "pressure",
|
|
|
|
|
+ "value": ${Number.isFinite(telemetrySeries.ys.at(-1) ?? Number.NaN) ? telemetrySeries.ys.at(-1) : 100},
|
|
|
|
|
+ "unit": "kPa",
|
|
|
|
|
+ "quality": "good"
|
|
|
|
|
+}`}
|
|
|
|
|
+ </pre>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {activeKey === "mq" && (
|
|
|
|
|
+ <Row gutter={[16, 16]}>
|
|
|
|
|
+ <Col xs={24} lg={10}>
|
|
|
|
|
+ <Card title="流量削峰与高并发处理">
|
|
|
|
|
+ <QueueGauge backlog={backlog} throughput={throughput} />
|
|
|
|
|
+ <Divider />
|
|
|
|
|
+ <Text>解耦采集与处理服务,实现高并发接入</Text>
|
|
|
|
|
+ <Progress className="mt-3" percent={Math.min(100, Math.round((throughput / 250) * 100))} steps={12} />
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ <Card title="指标类型转换" className="mt-4">
|
|
|
|
|
+ <Form
|
|
|
|
|
+ layout="vertical"
|
|
|
|
|
+ onFinish={() => globalMessage.success("转换规则已保存")}
|
|
|
|
|
+ initialValues={{ topic: "按产品", format: "物模型", compress: false }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form.Item label="主题维度" name="topic">
|
|
|
|
|
+ <Select
|
|
|
|
|
+ options={[
|
|
|
|
|
+ { label: "按产品", value: "按产品" },
|
|
|
|
|
+ { label: "按物模型指标", value: "按物模型指标" },
|
|
|
|
|
+ { label: "按项目", value: "按项目" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="格式转换" name="format">
|
|
|
|
|
+ <Select
|
|
|
|
|
+ options={[
|
|
|
|
|
+ { label: "物模型(标准)", value: "物模型" },
|
|
|
|
|
+ { label: "CSV", value: "CSV" },
|
|
|
|
|
+ { label: "自定义", value: "自定义" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="开启压缩" name="compress" valuePropName="checked">
|
|
|
|
|
+ <Switch />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Button type="primary" htmlType="submit" icon={<Check size={16} />}>
|
|
|
|
|
+ 保存
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col xs={24} lg={14}>
|
|
|
|
|
+ <Card title="转换前/后对比">
|
|
|
|
|
+ <Row gutter={12}>
|
|
|
|
|
+ <Col span={12}>
|
|
|
|
|
+ <Title level={5}>原始数据</Title>
|
|
|
|
|
+ <pre
|
|
|
|
|
+ style={{
|
|
|
|
|
+ background: "#0b1020",
|
|
|
|
|
+ color: "#d6e5ff",
|
|
|
|
|
+ padding: 12,
|
|
|
|
|
+ borderRadius: 8,
|
|
|
|
|
+ minHeight: 220,
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {`{"dev":"${devices[0]?.id ?? "dev"}","p":${telemetrySeries.ys.at(-1) ?? 100},"ts":${Date.now()}}`}
|
|
|
|
|
+ </pre>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col span={12}>
|
|
|
|
|
+ <Title level={5}>标准化后</Title>
|
|
|
|
|
+ <pre
|
|
|
|
|
+ style={{
|
|
|
|
|
+ background: "#0b1020",
|
|
|
|
|
+ color: "#d6e5ff",
|
|
|
|
|
+ padding: 12,
|
|
|
|
|
+ borderRadius: 8,
|
|
|
|
|
+ minHeight: 220,
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {`{
|
|
|
|
|
+ "deviceId": "${devices[0]?.id ?? "dev"}",
|
|
|
|
|
+ "metrics": [{"id":"pressure","value": ${telemetrySeries.ys.at(-1) ?? 100}, "unit":"kPa"}],
|
|
|
|
|
+ "timestamp": "${new Date().toISOString()}"
|
|
|
|
|
+}`}
|
|
|
|
|
+ </pre>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {activeKey === "warehouse" && (
|
|
|
|
|
+ <Row gutter={[16, 16]}>
|
|
|
|
|
+ <Col xs={24} lg={14}>
|
|
|
|
|
+ <Card title="原始数据(近50条)">
|
|
|
|
|
+ <Table
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ dataSource={logs.map((l, i) => ({ ...l, idx: i }))}
|
|
|
|
|
+ pagination={{ pageSize: 8 }}
|
|
|
|
|
+ columns={[
|
|
|
|
|
+ { title: "#", dataIndex: "idx", width: 60 },
|
|
|
|
|
+ { title: "时间", dataIndex: "time", width: 120 },
|
|
|
|
|
+ { title: "设备ID", dataIndex: "deviceId", width: 140 },
|
|
|
|
|
+ { title: "消息", dataIndex: "msg" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col xs={24} lg={10}>
|
|
|
|
|
+ <Card title="ETL 作业">
|
|
|
|
|
+ <Collapse
|
|
|
|
|
+ items={[
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "extract",
|
|
|
|
|
+ label: "抽取",
|
|
|
|
|
+ children: <Text>从接入缓冲区读取原始数据,写入ODS层。</Text>,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ key: "clean",
|
|
|
|
|
+ label: "清洗",
|
|
|
|
|
+ children: <Text>字段校验、缺失填充、单位换算、异常剔除。</Text>,
|
|
|
|
|
+ },
|
|
|
|
|
+ { key: "load", label: "加载", children: <Text>入库到时序与事实表,供分析与API服务。</Text> },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ defaultActiveKey={["extract", "clean", "load"]}
|
|
|
|
|
+ />
|
|
|
|
|
+ <Divider />
|
|
|
|
|
+ <Space>
|
|
|
|
|
+ <Button type="primary" icon={<PlayIcon />} onClick={() => globalMessage.success("已触发 ETL 作业")}>
|
|
|
|
|
+ 运行一次
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button icon={<Settings size={16} />} onClick={() => setScheduleModal(true)}>
|
|
|
|
|
+ 计划配置
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {activeKey === "openapi" && (
|
|
|
|
|
+ <Row gutter={[16, 16]}>
|
|
|
|
|
+ <Col xs={24} lg={12}>
|
|
|
|
|
+ <Card title="接口服务管理">
|
|
|
|
|
+ <Table
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ rowKey="name"
|
|
|
|
|
+ dataSource={[
|
|
|
|
|
+ { name: "实时监测查询", path: "/api/metrics/realtime", status: "running" },
|
|
|
|
|
+ { name: "设备状态查询", path: "/api/devices/status", status: "running" },
|
|
|
|
|
+ { name: "历史数据查询", path: "/api/metrics/history", status: "stopped" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ columns={[
|
|
|
|
|
+ { title: "名称", dataIndex: "name" },
|
|
|
|
|
+ { title: "路径", dataIndex: "path", render: (p) => <Text code>{p}</Text> },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "状态",
|
|
|
|
|
+ dataIndex: "status",
|
|
|
|
|
+ render: (s) =>
|
|
|
|
|
+ s === "running" ? (
|
|
|
|
|
+ <Badge status="success" text="运行中" />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <Badge status="default" text="已停止" />
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "操作",
|
|
|
|
|
+ render: (_, r) => (
|
|
|
|
|
+ <Space>
|
|
|
|
|
+ <Button size="small" onClick={() => setSvcModal({ open: true, svc: r })}>
|
|
|
|
|
+ 配置
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ type="primary"
|
|
|
|
|
+ onClick={() => globalMessage.success(r.status === "running" ? "已停止" : "已启动")}
|
|
|
|
|
+ >
|
|
|
|
|
+ {r.status === "running" ? "停止" : "启动"}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col xs={24} lg={12}>
|
|
|
|
|
+ <Card title="接口定义示例">
|
|
|
|
|
+ <pre
|
|
|
|
|
+ style={{ background: "#0b1020", color: "#d6e5ff", padding: 12, borderRadius: 8, minHeight: 240 }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {`GET /api/metrics/realtime?productId=prod1
|
|
|
|
|
+
|
|
|
|
|
+Response
|
|
|
|
|
+{
|
|
|
|
|
+ "code": 0,
|
|
|
|
|
+ "data": [
|
|
|
|
|
+ {"deviceId":"dev-001","metric":"pressure","value": 112.3,"unit":"kPa","ts":"${new Date().toISOString()}"}
|
|
|
|
|
+ ]
|
|
|
|
|
+}`}
|
|
|
|
|
+ </pre>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {activeKey === "micro" && (
|
|
|
|
|
+ <Row gutter={[16, 16]}>
|
|
|
|
|
+ <Col xs={24} lg={12}>
|
|
|
|
|
+ <Card title="注册中心(服务发现/健康检查/负载均衡)">
|
|
|
|
|
+ <Table
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ rowKey="name"
|
|
|
|
|
+ dataSource={[
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "ingest-service",
|
|
|
|
|
+ addr: "10.0.0.2:7001",
|
|
|
|
|
+ status: Math.random() > 0.05 ? "UP" : "DOWN",
|
|
|
|
|
+ load: "32%",
|
|
|
|
|
+ },
|
|
|
|
|
+ { name: "parser-service", addr: "10.0.0.3:7002", status: "UP", load: "41%" },
|
|
|
|
|
+ { name: "mq-gateway", addr: "10.0.0.4:7003", status: "UP", load: "27%" },
|
|
|
|
|
+ { name: "api-service", addr: "10.0.0.5:7004", status: "UP", load: "36%" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ columns={[
|
|
|
|
|
+ { title: "服务", dataIndex: "name" },
|
|
|
|
|
+ { title: "地址", dataIndex: "addr" },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "状态",
|
|
|
|
|
+ dataIndex: "status",
|
|
|
|
|
+ render: (s) =>
|
|
|
|
|
+ s === "UP" ? <Badge status="success" text="UP" /> : <Badge status="error" text="DOWN" />,
|
|
|
|
|
+ },
|
|
|
|
|
+ { title: "负载", dataIndex: "load" },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "操作",
|
|
|
|
|
+ render: (_, r) => (
|
|
|
|
|
+ <Space>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ icon={<RefreshCw size={14} />}
|
|
|
|
|
+ onClick={() => globalMessage.success(`已触发健康检查:${r.name}`)}
|
|
|
|
|
+ >
|
|
|
|
|
+ 健康检查
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button size="small" onClick={() => setWeightModal({ open: true, weight: 50 })}>
|
|
|
|
|
+ 权重
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col xs={24} lg={12}>
|
|
|
|
|
+ <Card title="配置中心(动态配置/多环境)">
|
|
|
|
|
+ <Form
|
|
|
|
|
+ layout="vertical"
|
|
|
|
|
+ onFinish={() => globalMessage.success("配置已更新")}
|
|
|
|
|
+ initialValues={{ env: "dev", log: "info", db: "postgres://neon-db/..." }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form.Item label="环境" name="env">
|
|
|
|
|
+ <Select
|
|
|
|
|
+ options={[
|
|
|
|
|
+ { label: "开发", value: "dev" },
|
|
|
|
|
+ { label: "测试", value: "test" },
|
|
|
|
|
+ { label: "生产", value: "prod" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="日志级别" name="log">
|
|
|
|
|
+ <Select
|
|
|
|
|
+ options={[
|
|
|
|
|
+ { label: "debug", value: "debug" },
|
|
|
|
|
+ { label: "info", value: "info" },
|
|
|
|
|
+ { label: "warn", value: "warn" },
|
|
|
|
|
+ { label: "error", value: "error" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="数据库连接串" name="db">
|
|
|
|
|
+ <Input placeholder="postgres://..." />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Space>
|
|
|
|
|
+ <Button type="primary" htmlType="submit" icon={<Check size={16} />}>
|
|
|
|
|
+ 保存并推送
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button icon={<Cog size={16} />} onClick={() => setApiAccessModal(true)}>
|
|
|
|
|
+ 访问接口
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {activeKey === "auth" && (
|
|
|
|
|
+ <Row gutter={[16, 16]}>
|
|
|
|
|
+ <Col xs={24} lg={12}>
|
|
|
|
|
+ <Card title="OAuth 2.0">
|
|
|
|
|
+ <Form
|
|
|
|
|
+ layout="vertical"
|
|
|
|
|
+ onFinish={() => globalMessage.success("已签发示例Token")}
|
|
|
|
|
+ initialValues={{ clientId: "demo-client", scope: "read:devices", grant: "client_credentials" }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form.Item label="Client ID" name="clientId" rules={[{ required: true }]}>
|
|
|
|
|
+ <Input />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="授权范围" name="scope">
|
|
|
|
|
+ <Input placeholder="read:devices write:commands" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="授权模式" name="grant">
|
|
|
|
|
+ <Select
|
|
|
|
|
+ options={[
|
|
|
|
|
+ { label: "Client Credentials", value: "client_credentials" },
|
|
|
|
|
+ { label: "Authorization Code", value: "authorization_code" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Button type="primary" htmlType="submit" icon={<ShieldCheck size={16} />}>
|
|
|
|
|
+ 获取Token
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ <Divider />
|
|
|
|
|
+ <Text>示例Token</Text>
|
|
|
|
|
+ <pre style={{ background: "#0b1020", color: "#d6e5ff", padding: 12, borderRadius: 8 }}>
|
|
|
|
|
+ {"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.demo.payload.signature"}
|
|
|
|
|
+ </pre>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col xs={24} lg={12}>
|
|
|
|
|
+ <Card title="RBAC 权限管理">
|
|
|
|
|
+ <Row gutter={12}>
|
|
|
|
|
+ <Col span={12}>
|
|
|
|
|
+ <Title level={5}>角色</Title>
|
|
|
|
|
+ <Table
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ pagination={false}
|
|
|
|
|
+ dataSource={[
|
|
|
|
|
+ { key: "r1", name: "管理员", perms: "ALL" },
|
|
|
|
|
+ { key: "r2", name: "运维", perms: "devices:read, commands:write" },
|
|
|
|
|
+ { key: "r3", name: "访客", perms: "devices:read" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ columns={[
|
|
|
|
|
+ { title: "角色", dataIndex: "name" },
|
|
|
|
|
+ { title: "权限", dataIndex: "perms" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col span={12}>
|
|
|
|
|
+ <Title level={5}>用户</Title>
|
|
|
|
|
+ <Table
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ pagination={false}
|
|
|
|
|
+ dataSource={[
|
|
|
|
|
+ { key: "u1", name: "alice", role: "管理员" },
|
|
|
|
|
+ { key: "u2", name: "bob", role: "运维" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ columns={[
|
|
|
|
|
+ { title: "用户", dataIndex: "name" },
|
|
|
|
|
+ { title: "角色", dataIndex: "role" },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: "操作",
|
|
|
|
|
+ render: (_, r) => (
|
|
|
|
|
+ <Button size="small" onClick={() => setRbacModal({ open: true, user: r })}>
|
|
|
|
|
+ 分配权限
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Content>
|
|
|
|
|
+ </Layout>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Drawer: 设备注册引导 */}
|
|
|
|
|
+ <Drawer title="设备注册与授权" open={registerDrawer} onClose={() => setRegisterDrawer(false)} width={640}>
|
|
|
|
|
+ <Steps
|
|
|
|
|
+ current={1}
|
|
|
|
|
+ items={[{ title: "选择产品" }, { title: "注册设备" }, { title: "获取密钥" }, { title: "接入测试" }]}
|
|
|
|
|
+ />
|
|
|
|
|
+ <Divider />
|
|
|
|
|
+ <Form
|
|
|
|
|
+ layout="vertical"
|
|
|
|
|
+ onFinish={(v) => {
|
|
|
|
|
+ addDevice({
|
|
|
|
|
+ id: v.id,
|
|
|
|
|
+ name: v.name,
|
|
|
|
|
+ projectId: products.find((p) => p.id === v.productId)?.projectId ?? projects[0]?.id,
|
|
|
|
|
+ productId: v.productId,
|
|
|
|
|
+ lat: 31.23 + Math.random() * 0.05,
|
|
|
|
|
+ lng: 121.47 + Math.random() * 0.05,
|
|
|
|
|
+ })
|
|
|
|
|
+ globalMessage.success("注册成功")
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form.Item label="产品" name="productId" rules={[{ required: true }]}>
|
|
|
|
|
+ <Select options={productOptions} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Row gutter={12}>
|
|
|
|
|
+ <Col span={12}>
|
|
|
|
|
+ <Form.Item label="设备ID" name="id" rules={[{ required: true }]}>
|
|
|
|
|
+ <Input placeholder="dev-xxx" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ <Col span={12}>
|
|
|
|
|
+ <Form.Item label="设备名称" name="name" rules={[{ required: true }]}>
|
|
|
|
|
+ <Input />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Col>
|
|
|
|
|
+ </Row>
|
|
|
|
|
+ <Form.Item>
|
|
|
|
|
+ <Space>
|
|
|
|
|
+ <Button htmlType="submit" type="primary" icon={<Check size={16} />}>
|
|
|
|
|
+ 注册
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button icon={<UploadIcon size={16} />} onClick={() => globalMessage.success("已接收CSV")}>
|
|
|
|
|
+ 导入CSV
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ <Divider />
|
|
|
|
|
+ <Title level={5}>连接示例(MQTTS)</Title>
|
|
|
|
|
+ <pre style={{ background: "#0b1020", color: "#d6e5ff", padding: 12, borderRadius: 8 }}>
|
|
|
|
|
+ {`mqtts://iot.example.com:8883
|
|
|
|
|
+username: <deviceId>
|
|
|
|
|
+password: <deviceSecret>
|
|
|
|
|
+topic: /up/<product>/<deviceId>`}
|
|
|
|
|
+ </pre>
|
|
|
|
|
+ </Drawer>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Notifications modal */}
|
|
|
|
|
+ <Modal title="通知中心" open={notifOpen} onCancel={() => setNotifOpen(false)} onOk={() => setNotifOpen(false)}>
|
|
|
|
|
+ {alertLogs.length === 0 ? (
|
|
|
|
|
+ <Text type="secondary">暂无告警</Text>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <Table
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ pagination={false}
|
|
|
|
|
+ dataSource={alertLogs}
|
|
|
|
|
+ columns={[
|
|
|
|
|
+ { title: "时间", dataIndex: "time", width: 120 },
|
|
|
|
|
+ { title: "设备ID", dataIndex: "deviceId", width: 140 },
|
|
|
|
|
+ { title: "消息", dataIndex: "msg" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+
|
|
|
|
|
+ {/* OpenAPI config modal */}
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ title={`服务配置 - ${svcModal.svc?.name ?? ""}`}
|
|
|
|
|
+ open={svcModal.open}
|
|
|
|
|
+ onCancel={() => setSvcModal({ open: false })}
|
|
|
|
|
+ onOk={() => {
|
|
|
|
|
+ setSvcModal({ open: false })
|
|
|
|
|
+ globalMessage.success("配置已保存")
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form layout="vertical" initialValues={svcModal.svc}>
|
|
|
|
|
+ <Form.Item label="名称" name="name">
|
|
|
|
|
+ <Input disabled />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="路径" name="path">
|
|
|
|
|
+ <Input />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="限流(req/s)" name="qps">
|
|
|
|
|
+ <Input placeholder="例如 200" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Service weight modal */}
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ title="实例权重"
|
|
|
|
|
+ open={weightModal.open}
|
|
|
|
|
+ onCancel={() => setWeightModal((s) => ({ ...s, open: false }))}
|
|
|
|
|
+ onOk={() => {
|
|
|
|
|
+ setWeightModal((s) => ({ ...s, open: false }))
|
|
|
|
|
+ globalMessage.success(`已设置权重:${weightModal.weight}`)
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Slider
|
|
|
|
|
+ min={0}
|
|
|
|
|
+ max={100}
|
|
|
|
|
+ value={weightModal.weight}
|
|
|
|
|
+ onChange={(v) => setWeightModal({ open: true, weight: Number(v) })}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Warehouse schedule modal */}
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ title="ETL 计划配置"
|
|
|
|
|
+ open={scheduleModal}
|
|
|
|
|
+ onCancel={() => setScheduleModal(false)}
|
|
|
|
|
+ onOk={() => {
|
|
|
|
|
+ setScheduleModal(false)
|
|
|
|
|
+ globalMessage.success("计划已保存")
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form layout="vertical" initialValues={{ cron: "0 */1 * * *", retry: 3 }}>
|
|
|
|
|
+ <Form.Item label="Cron 表达式" name="cron">
|
|
|
|
|
+ <Input />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item label="失败重试次数" name="retry">
|
|
|
|
|
+ <Input type="number" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Config center API access modal */}
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ title="配置访问接口"
|
|
|
|
|
+ open={apiAccessModal}
|
|
|
|
|
+ onCancel={() => setApiAccessModal(false)}
|
|
|
|
|
+ onOk={() => setApiAccessModal(false)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Descriptions size="small" column={1} bordered>
|
|
|
|
|
+ <Descriptions.Item label="GET">
|
|
|
|
|
+ <Text code>/config/{`{env}`}</Text>
|
|
|
|
|
+ </Descriptions.Item>
|
|
|
|
|
+ <Descriptions.Item label="POST 推送更新">
|
|
|
|
|
+ <Text code>/config/push</Text>
|
|
|
|
|
+ </Descriptions.Item>
|
|
|
|
|
+ </Descriptions>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+
|
|
|
|
|
+ {/* RBAC assign modal */}
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ title={`分配权限 - ${rbacModal.user?.name ?? ""}`}
|
|
|
|
|
+ open={rbacModal.open}
|
|
|
|
|
+ onCancel={() => setRbacModal({ open: false })}
|
|
|
|
|
+ onOk={() => {
|
|
|
|
|
+ setRbacModal({ open: false })
|
|
|
|
|
+ globalMessage.success("权限已更新")
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form layout="vertical" initialValues={{ role: rbacModal.user?.role ?? "访客" }}>
|
|
|
|
|
+ <Form.Item label="角色" name="role">
|
|
|
|
|
+ <Select
|
|
|
|
|
+ options={[
|
|
|
|
|
+ { label: "管理员", value: "管理员" },
|
|
|
|
|
+ { label: "运维", value: "运维" },
|
|
|
|
|
+ { label: "访客", value: "访客" },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+ </Layout>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function PlusIcon() {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <span aria-hidden className="inline-flex">
|
|
|
|
|
+ <svg width="0" height="0" />
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+function AlertIcon() {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <span aria-hidden className="inline-flex">
|
|
|
|
|
+ <svg width="0" height="0" />
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+function PlayIcon() {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <span aria-hidden className="inline-flex">
|
|
|
|
|
+ <svg width="0" height="0" />
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|