page.tsx 51 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574
  1. "use client"
  2. import React, {useEffect, useMemo, useState} from "react"
  3. import "antd/dist/reset.css"
  4. import {
  5. BadgeAlertIcon as Alert,
  6. BarChartIcon as ChartBar,
  7. BellRing,
  8. CheckCircle2,
  9. ChevronsUp,
  10. FileText,
  11. LineChart,
  12. MapPin,
  13. Settings,
  14. ShieldAlert,
  15. Siren,
  16. SquareGanttChart,
  17. TimerIcon as Timeline,
  18. UserRoundCheck,
  19. } from "lucide-react" // Declare ShieldAlert variable
  20. import {
  21. Badge as AntdBadge,
  22. Button,
  23. Card,
  24. Col,
  25. DatePicker,
  26. Descriptions,
  27. Divider,
  28. Drawer,
  29. Form,
  30. Input,
  31. Modal,
  32. notification,
  33. Popconfirm,
  34. Radio,
  35. Row,
  36. Segmented,
  37. Select,
  38. Space,
  39. Statistic,
  40. Steps,
  41. Table,
  42. Tabs,
  43. Tag,
  44. Tooltip,
  45. Upload,
  46. } from "antd"
  47. import type {ColumnsType} from "antd/es/table"
  48. import type {UploadFile} from "antd/es/upload/interface"
  49. import dayjs, {type Dayjs} from "dayjs"
  50. import relativeTime from "dayjs/plugin/relativeTime"
  51. import isBetween from "dayjs/plugin/isBetween"
  52. import EChart from "@/components/echarts"
  53. import MatterManagement from "./components/matter-management"
  54. import globalMessage from "@/app/_modules/globalMessage";
  55. dayjs.extend(relativeTime)
  56. dayjs.extend(isBetween)
  57. type WarningStatus =
  58. | "draft"
  59. | "published"
  60. | "in_process"
  61. | "supervising"
  62. | "returned"
  63. | "upgraded"
  64. | "resolved"
  65. | "released"
  66. type Level = "红色" | "橙色" | "黄色" | "蓝色"
  67. type WarnType = "燃气" | "供水" | "电力" | "交通" | "综合"
  68. type Industry = WarnType
  69. type ProcessLog = {
  70. time: string
  71. user: string
  72. action: string
  73. remark?: string
  74. }
  75. type Alarm = {
  76. id: string
  77. device: string
  78. metric: string
  79. value: number
  80. threshold: number
  81. time: string
  82. }
  83. type WarningItem = {
  84. id: string
  85. code: string
  86. name: string
  87. type: WarnType
  88. level: Level
  89. industry: Industry
  90. status: WarningStatus
  91. createdAt: string
  92. releasedAt?: string
  93. deadline?: string
  94. assignee?: string
  95. handler?: string
  96. location?: string
  97. coords?: [number, number]
  98. description?: string
  99. attachments?: UploadFile[]
  100. relatedAlarms?: Alarm[]
  101. process: ProcessLog[]
  102. }
  103. type MatterItemStatus = "启用" | "禁用" | "作废"
  104. type MatterItem = {
  105. id: string
  106. code: string
  107. name: string
  108. type: WarnType
  109. level: Level
  110. status: MatterItemStatus
  111. createdAt: string
  112. updatedAt: string
  113. }
  114. type ReasonEntry = {
  115. id: string
  116. type: WarnType
  117. level: Level
  118. reason: string
  119. createdAt: string
  120. }
  121. const LEVELS: Level[] = ["红色", "橙色", "黄色", "蓝色"]
  122. const TYPES: WarnType[] = ["燃气", "供水", "电力", "交通", "综合"]
  123. const INDUSTRIES: Industry[] = ["燃气", "供水", "电力", "交通", "综合"]
  124. const USERS = ["张三", "李四", "王五", "赵六", "钱七", "管理员"]
  125. const LEADERS = ["总指挥-陈总", "副总指挥-李总", "应急办-王主任"]
  126. const levelColor = (lvl: Level) => {
  127. switch (lvl) {
  128. case "红色":
  129. return "red"
  130. case "橙色":
  131. return "orange"
  132. case "黄色":
  133. return "gold"
  134. case "蓝色":
  135. return "blue"
  136. default:
  137. return "default"
  138. }
  139. }
  140. const statusTag = (status: WarningStatus) => {
  141. const map: Record<WarningStatus, { color: string; text: string; icon?: React.ReactNode }> = {
  142. draft: { color: "default", text: "草稿", icon: <FileText size={14} /> },
  143. published: { color: "processing", text: "已发布", icon: <Siren size={14} /> },
  144. in_process: {
  145. color: "blue",
  146. text: "处置中",
  147. icon: <Timeline size={14} />,
  148. },
  149. supervising: {
  150. color: "purple",
  151. text: "督办中",
  152. icon: <UserRoundCheck size={14} />,
  153. },
  154. returned: { color: "warning", text: "已退回", icon: <Alert size={14} /> },
  155. upgraded: {
  156. color: "magenta",
  157. text: "已升级",
  158. icon: <ChevronsUp size={14} />,
  159. },
  160. resolved: {
  161. color: "success",
  162. text: "已处置",
  163. icon: <CheckCircle2 size={14} />,
  164. },
  165. released: {
  166. color: "green",
  167. text: "已解除",
  168. icon: <ShieldAlert size={14} />,
  169. },
  170. }
  171. const cfg = map[status]
  172. return (
  173. <Tag color={cfg.color}>
  174. <Space size={4}>
  175. {cfg.icon}
  176. <span>{cfg.text}</span>
  177. </Space>
  178. </Tag>
  179. )
  180. }
  181. function randPick<T>(arr: T[]): T {
  182. return arr[Math.floor(Math.random() * arr.length)]
  183. }
  184. function genId(prefix = "id") {
  185. return `${prefix}_${Math.random().toString(36).slice(2, 10)}`
  186. }
  187. function autoCode(type: WarnType, level: Level, seq: number) {
  188. const date = dayjs().format("YYYYMMDD")
  189. const t = { 燃气: "GAS", 供水: "WTR", 电力: "ELE", 交通: "TRF", 综合: "COM" }[type]
  190. const l = { 红色: "R", 橙色: "O", 黄色: "Y", 蓝色: "B" }[level]
  191. return `YW-${t}-${l}-${date}-${seq.toString().padStart(3, "0")}`
  192. }
  193. function isToday(iso?: string) {
  194. if (!iso) return false
  195. return dayjs(iso).isSame(dayjs(), "day")
  196. }
  197. function daysAgo(n: number) {
  198. return dayjs().subtract(n, "day").toISOString()
  199. }
  200. // demo seeds (client-only)
  201. const seedAlarms: Alarm[] = new Array(24).fill(0).map((_, i) => ({
  202. id: genId("alarm"),
  203. device: `设备-${(i % 8) + 1}`,
  204. metric: ["压力", "温度", "流量"][i % 3],
  205. value: +(80 + Math.random() * 50).toFixed(1),
  206. threshold: 100,
  207. time: dayjs()
  208. .subtract(Math.floor(Math.random() * 72), "hour")
  209. .toISOString(),
  210. }))
  211. const seedWarnings: WarningItem[] = (() => {
  212. const arr: WarningItem[] = []
  213. for (let i = 0; i < 26; i++) {
  214. const type = randPick(TYPES)
  215. const level = randPick(LEVELS)
  216. const status = randPick<WarningStatus>([
  217. "draft",
  218. "published",
  219. "in_process",
  220. "supervising",
  221. "returned",
  222. "upgraded",
  223. "resolved",
  224. "released",
  225. ])
  226. const createdAt = dayjs()
  227. .subtract(Math.floor(Math.random() * 15), "day")
  228. .subtract(Math.floor(Math.random() * 24), "hour")
  229. .toISOString()
  230. const releasedAt =
  231. status === "released" || status === "resolved"
  232. ? dayjs(createdAt)
  233. .add(Math.floor(Math.random() * 48), "hour")
  234. .toISOString()
  235. : undefined
  236. arr.push({
  237. id: genId("warn"),
  238. code: autoCode(type, level, i + 1),
  239. name: `${type}专项-${["一号", "二号", "三号", "四号"][i % 4]}预警`,
  240. type,
  241. level,
  242. industry: type,
  243. status,
  244. createdAt,
  245. releasedAt,
  246. deadline: dayjs(createdAt).add(2, "day").toISOString(),
  247. assignee: randPick(USERS),
  248. handler: randPick(USERS),
  249. location: ["城区A", "城区B", "园区C", "沿线D"][i % 4],
  250. coords: [120.1 + Math.random(), 30.2 + Math.random()],
  251. description: "设备异常波动,指标超限,建议立即排查。包含监测曲线、现场图片等信息。",
  252. attachments: [],
  253. relatedAlarms: seedAlarms.slice(i, i + 3),
  254. process: [
  255. { time: createdAt, user: randPick(USERS), action: "创建预警" },
  256. ...(status !== "draft"
  257. ? [{ time: dayjs(createdAt).add(1, "hour").toISOString(), user: randPick(USERS), action: "发布预警" }]
  258. : []),
  259. ...(status === "resolved" || status === "released"
  260. ? [
  261. {
  262. time: dayjs(createdAt).add(2, "day").toISOString(),
  263. user: randPick(USERS),
  264. action: status === "released" ? "解除预警" : "完成处置",
  265. },
  266. ]
  267. : []),
  268. ],
  269. })
  270. }
  271. return arr
  272. })()
  273. const seedMatters: MatterItem[] = new Array(12).fill(0).map((_, i) => {
  274. const type = randPick(TYPES)
  275. const level = randPick(LEVELS)
  276. return {
  277. id: genId("matter"),
  278. code: autoCode(type, level, i + 1),
  279. name: `${type}专项事项-${i + 1}`,
  280. type,
  281. level,
  282. status: randPick(["启用", "禁用", "作废"]),
  283. createdAt: daysAgo(30 - i),
  284. updatedAt: daysAgo(20 - i),
  285. }
  286. })
  287. const seedReasons: ReasonEntry[] = [
  288. { id: genId("reason"), type: "燃气", level: "红色", reason: "主干管压力骤降", createdAt: daysAgo(40) },
  289. { id: genId("reason"), type: "供水", level: "黄色", reason: "水质浊度升高", createdAt: daysAgo(25) },
  290. { id: genId("reason"), type: "电力", level: "橙色", reason: "变压器过载", createdAt: daysAgo(12) },
  291. ]
  292. export default function WarningDashboard() {
  293. const [currentUser] = useState("刘昊林")
  294. const [warnings, setWarnings] = useState<WarningItem[]>([])
  295. const [matters, setMatters] = useState<MatterItem[]>([])
  296. const [reasons, setReasons] = useState<ReasonEntry[]>([])
  297. useEffect(() => {
  298. // 仅在客户端生成随机数据
  299. setWarnings(seedWarnings)
  300. setMatters(seedMatters)
  301. setReasons(seedReasons)
  302. }, [])
  303. const [activeTab, setActiveTab] = useState("overview")
  304. const [detail, setDetail] = useState<WarningItem | null>(null)
  305. const [publishForm] = Form.useForm()
  306. const [publishOpen, setPublishOpen] = useState(false)
  307. const [superviseOpen, setSuperviseOpen] = useState<WarningItem | null>(null)
  308. const [upgradeOpen, setUpgradeOpen] = useState<WarningItem | null>(null)
  309. const [leaderOpen, setLeaderOpen] = useState<WarningItem | null>(null)
  310. const [returnOpen, setReturnOpen] = useState<WarningItem | null>(null)
  311. const [todoFilter, setTodoFilter] = useState<{ q?: string; status?: "todo" | "done" }>({ status: "todo" })
  312. const [mgmtFilters, setMgmtFilters] = useState<{
  313. type?: WarnType
  314. level?: Level
  315. industry?: Industry
  316. status?: WarningStatus
  317. date?: [Dayjs, Dayjs]
  318. q?: string
  319. }>({})
  320. const [todayOnly, setTodayOnly] = useState(true)
  321. // Derived stats
  322. const todayStats = useMemo(() => {
  323. const todayList = warnings.filter((w) => isToday(w.createdAt))
  324. const released = todayList.filter((w) => w.status === "released").length
  325. const unresolved = todayList.filter((w) => !["released"].includes(w.status)).length
  326. return { total: todayList.length, released, unresolved, list: todayList }
  327. }, [warnings])
  328. const todayUnhandled = useMemo(() => {
  329. return warnings.filter(
  330. (w) =>
  331. isToday(w.createdAt) && ["published", "in_process", "supervising", "upgraded", "returned"].includes(w.status),
  332. )
  333. }, [warnings])
  334. const historyByType = useMemo(() => {
  335. const map = new Map<WarnType, number>()
  336. TYPES.forEach((t) => map.set(t, 0))
  337. warnings.forEach((w) => map.set(w.type, (map.get(w.type) || 0) + 1))
  338. return Array.from(map.entries()).map(([name, value]) => ({ name, value }))
  339. }, [warnings])
  340. const historyByLevel = useMemo(() => {
  341. const map = new Map<Level, number>()
  342. LEVELS.forEach((l) => map.set(l, 0))
  343. warnings.forEach((w) => map.set(w.level, (map.get(w.level) || 0) + 1))
  344. return Array.from(map.entries()).map(([name, value]) => ({ name, value }))
  345. }, [warnings])
  346. const mineTodo = useMemo(() => {
  347. return warnings.filter(
  348. (w) =>
  349. w.assignee === currentUser &&
  350. ["published", "in_process", "supervising", "upgraded", "returned"].includes(w.status),
  351. )
  352. }, [warnings, currentUser])
  353. const mineDone = useMemo(() => {
  354. return warnings.filter((w) => w.assignee === currentUser && ["resolved", "released"].includes(w.status))
  355. }, [warnings, currentUser])
  356. // Actions
  357. function pushProcess(w: WarningItem, action: string, remark?: string) {
  358. w.process = [...w.process, { time: new Date().toISOString(), user: currentUser, action, remark }]
  359. }
  360. function updateWarning(wid: string, patch: Partial<WarningItem>, processAction?: string, remark?: string) {
  361. setWarnings((prev) =>
  362. prev.map((w) => {
  363. if (w.id !== wid) return w
  364. const next = { ...w, ...patch }
  365. if (processAction) {
  366. const cp = { ...next }
  367. pushProcess(cp, processAction, remark)
  368. return cp
  369. }
  370. return next
  371. }),
  372. )
  373. }
  374. function handleRelease(w: WarningItem) {
  375. updateWarning(w.id, { status: "released", releasedAt: new Date().toISOString() }, "解除预警")
  376. globalMessage.success("预警已解除")
  377. }
  378. function handleResolve(w: WarningItem) {
  379. updateWarning(w.id, { status: "resolved" }, "完成处置")
  380. globalMessage.success("预警已标记为已处置")
  381. }
  382. function handleReturn(w: WarningItem, reason: string) {
  383. updateWarning(w.id, { status: "returned" }, "退回预警", reason)
  384. globalMessage.warning("预警已退回处置单位")
  385. }
  386. function handleUpgrade(w: WarningItem, newLevel: Level, reason?: string) {
  387. updateWarning(w.id, { status: "upgraded", level: newLevel }, "升级预警", reason)
  388. notification.info({ message: "预警已升级", description: `${w.code} -> ${newLevel}` })
  389. }
  390. function handleSupervise(w: WarningItem, payload: { people: string[]; opinion: string; channels: string[] }) {
  391. updateWarning(
  392. w.id,
  393. { status: "supervising" },
  394. "督办",
  395. `人员: ${payload.people.join(",")} | 方式: ${payload.channels.join(",")} | 意见: ${payload.opinion}`,
  396. )
  397. notification.warning({
  398. message: "已发起督办",
  399. description: `${w.name} - ${payload.people.join(", ")}`,
  400. })
  401. }
  402. function handleLeaderInstruction(w: WarningItem, payload: { leaders: string[]; sms: string }) {
  403. updateWarning(w.id, {}, "领导批示", `已短信推送给: ${payload.leaders.join(", ")};内容:${payload.sms}`)
  404. notification.success({
  405. message: "已推送领导批示",
  406. description: payload.sms,
  407. })
  408. }
  409. function handlePublish(values: any) {
  410. const seq = warnings.length + 1
  411. const code = autoCode(values.type, values.level, seq)
  412. const newItem: WarningItem = {
  413. id: genId("warn"),
  414. code,
  415. name: values.name,
  416. type: values.type,
  417. level: values.level,
  418. industry: values.type,
  419. status: "published",
  420. createdAt: values.releasedAt?.toISOString?.() ?? new Date().toISOString(),
  421. releasedAt: undefined,
  422. deadline: dayjs().add(2, "day").toISOString(),
  423. assignee: values.assignee,
  424. handler: currentUser,
  425. location: values.location,
  426. coords: [120.18 + Math.random() * 0.1, 30.25 + Math.random() * 0.1],
  427. description: values.description,
  428. attachments: values.attachments?.fileList ?? [],
  429. relatedAlarms: seedAlarms.slice(0, Math.min(3, seedAlarms.length)),
  430. process: [
  431. {
  432. time: new Date().toISOString(),
  433. user: currentUser,
  434. action: "发布预警",
  435. remark: values.description,
  436. },
  437. ],
  438. }
  439. setWarnings((prev) => [newItem, ...prev])
  440. setPublishOpen(false)
  441. publishForm.resetFields()
  442. globalMessage.success("预警已发布")
  443. setActiveTab("publish")
  444. }
  445. // Filters
  446. const filteredMgmtList = useMemo(() => {
  447. return warnings.filter((w) => {
  448. if (todayOnly && !isToday(w.createdAt)) return false
  449. if (mgmtFilters.type && w.type !== mgmtFilters.type) return false
  450. if (mgmtFilters.level && w.level !== mgmtFilters.level) return false
  451. if (mgmtFilters.industry && w.industry !== mgmtFilters.industry) return false
  452. if (mgmtFilters.status && w.status !== mgmtFilters.status) return false
  453. if (mgmtFilters.date && mgmtFilters.date.length === 2) {
  454. const [s, e] = mgmtFilters.date
  455. if (!dayjs(w.createdAt).isBetween(s, e, "day", "[]")) return false
  456. }
  457. if (mgmtFilters.q) {
  458. const q = mgmtFilters.q.trim()
  459. if (!q) return true
  460. return w.name.includes(q) || w.code.includes(q) || w.location?.includes(q) || w.description?.includes(q)
  461. }
  462. return true
  463. })
  464. }, [warnings, mgmtFilters, todayOnly])
  465. // Tables
  466. const warnColumns: ColumnsType<WarningItem> = [
  467. {
  468. title: "预警名称",
  469. dataIndex: "name",
  470. key: "name",
  471. render: (text, record) => (
  472. <Space>
  473. <Tooltip title={record.type}>
  474. <AntdBadge color="geekblue" />
  475. </Tooltip>
  476. <a onClick={() => setDetail(record)}>{text}</a>
  477. {statusTag(record.status)}
  478. </Space>
  479. ),
  480. },
  481. { title: "编码", dataIndex: "code", key: "code" },
  482. {
  483. title: "类型/行业",
  484. key: "type",
  485. render: (_, r) => (
  486. <Space size={6}>
  487. <Tag>{r.type}</Tag>
  488. <Tag color={levelColor(r.level)}>{r.level}</Tag>
  489. </Space>
  490. ),
  491. },
  492. { title: "位置", dataIndex: "location", key: "location" },
  493. {
  494. title: "创建时间",
  495. dataIndex: "createdAt",
  496. key: "createdAt",
  497. render: (t: string) => dayjs(t).format("YYYY-MM-DD HH:mm"),
  498. },
  499. { title: "责任人", dataIndex: "assignee", key: "assignee" },
  500. {
  501. title: "操作",
  502. key: "actions",
  503. fixed: "right",
  504. render: (_, r) => (
  505. <Space wrap>
  506. <Button size="small" onClick={() => setDetail(r)}>
  507. 详情
  508. </Button>
  509. <Button size="small" type="default" onClick={() => setUpgradeOpen(r)} icon={<ChevronsUp size={14} />}>
  510. 升级
  511. </Button>
  512. <Button size="small" type="dashed" onClick={() => setSuperviseOpen(r)} icon={<UserRoundCheck size={14} />}>
  513. 督办
  514. </Button>
  515. <Button size="small" type="dashed" onClick={() => setLeaderOpen(r)} icon={<BellRing size={14} />}>
  516. 批示
  517. </Button>
  518. <Button size="small" danger onClick={() => setReturnOpen(r)} icon={<Alert size={14} />}>
  519. 退回
  520. </Button>
  521. <Popconfirm title="确认解除该预警?" onConfirm={() => handleRelease(r)} okText="解除" cancelText="取消">
  522. <Button size="small" type="primary" ghost icon={<ShieldAlert size={14} />}>
  523. 解除
  524. </Button>
  525. </Popconfirm>
  526. <Popconfirm title="标记为已处置?" onConfirm={() => handleResolve(r)} okText="确定" cancelText="取消">
  527. <Button size="small" type="primary" icon={<CheckCircle2 size={14} />}>
  528. 已处置
  529. </Button>
  530. </Popconfirm>
  531. </Space>
  532. ),
  533. },
  534. ]
  535. // Charts options
  536. const todayPieOption = useMemo(() => {
  537. return {
  538. tooltip: { trigger: "item" },
  539. legend: { top: "bottom" },
  540. series: [
  541. {
  542. name: "今日预警",
  543. type: "pie",
  544. radius: ["40%", "60%"],
  545. label: { formatter: "{b}: {c} ({d}%)" },
  546. data: [
  547. { name: "未解除", value: todayStats.unresolved },
  548. { name: "已解除", value: todayStats.released },
  549. ],
  550. },
  551. ],
  552. color: ["#f97316", "#10b981"],
  553. }
  554. }, [todayStats])
  555. const historyBarOption = useMemo(() => {
  556. return {
  557. tooltip: { trigger: "axis" },
  558. legend: { data: ["各专项累计"] },
  559. xAxis: { type: "category", data: historyByType.map((d) => d.name) },
  560. yAxis: { type: "value" },
  561. series: [
  562. {
  563. name: "各专项累计",
  564. type: "bar",
  565. data: historyByType.map((d) => d.value),
  566. itemStyle: { color: "#6b7280" },
  567. },
  568. ],
  569. }
  570. }, [historyByType])
  571. const levelRingOption = useMemo(() => {
  572. return {
  573. tooltip: { trigger: "item" },
  574. series: [
  575. {
  576. name: "等级分布",
  577. type: "pie",
  578. radius: ["30%", "55%"],
  579. label: { formatter: "{b}: {c}" },
  580. data: historyByLevel,
  581. color: ["#ef4444", "#fb923c", "#facc15", "#3b82f6"],
  582. },
  583. ],
  584. }
  585. }, [historyByLevel])
  586. const trendOption = useMemo(() => {
  587. const days = Array.from({ length: 14 }, (_, i) =>
  588. dayjs()
  589. .subtract(13 - i, "day")
  590. .format("MM-DD"),
  591. )
  592. const series = TYPES.map((t) => ({
  593. name: t,
  594. type: "line",
  595. smooth: true,
  596. data: days.map(() => Math.floor(Math.random() * 6)),
  597. areaStyle: { opacity: 0.08 },
  598. emphasis: { focus: "series" as const },
  599. }))
  600. return {
  601. tooltip: { trigger: "axis" },
  602. legend: { top: 0 },
  603. grid: { left: 24, right: 16, bottom: 24, top: 40 },
  604. xAxis: { type: "category", data: days },
  605. yAxis: { type: "value" },
  606. series,
  607. }
  608. }, [])
  609. const efficiencyTopOption = useMemo(() => {
  610. const data = TYPES.map((t) => ({
  611. name: t,
  612. value: +(80 + Math.random() * 20).toFixed(1),
  613. })).sort((a, b) => b.value - a.value)
  614. return {
  615. tooltip: { trigger: "axis" },
  616. xAxis: { type: "value", max: 100 },
  617. yAxis: { type: "category", data: data.map((d) => d.name), inverse: true },
  618. series: [
  619. {
  620. type: "bar",
  621. data: data.map((d) => d.value),
  622. itemStyle: {
  623. color: (params: any) => ["#10b981", "#22c55e", "#84cc16", "#eab308", "#f97316"][params.dataIndex % 5],
  624. },
  625. label: { show: true, position: "right", formatter: "{c}%" },
  626. },
  627. ],
  628. }
  629. }, [])
  630. // Modals
  631. function UpgradeModal() {
  632. const [form] = Form.useForm<{ level: Level; reason: string }>()
  633. const rec = upgradeOpen
  634. return (
  635. <Modal
  636. title="预警升级"
  637. open={!!rec}
  638. onCancel={() => setUpgradeOpen(null)}
  639. onOk={() => {
  640. form.validateFields().then((vals) => {
  641. if (rec) handleUpgrade(rec, vals.level, vals.reason)
  642. setUpgradeOpen(null)
  643. })
  644. }}
  645. okText="升级"
  646. >
  647. <Form form={form} layout="vertical" initialValues={{ level: rec?.level }}>
  648. <Form.Item label="新等级" name="level" rules={[{ required: true, message: "请选择新等级" }]}>
  649. <Select options={LEVELS.map((l) => ({ label: l, value: l }))} placeholder="选择新等级" />
  650. </Form.Item>
  651. <Form.Item label="升级原因" name="reason" rules={[{ required: true }]}>
  652. <Input.TextArea placeholder="说明升级原因..." rows={3} />
  653. </Form.Item>
  654. </Form>
  655. </Modal>
  656. )
  657. }
  658. function SuperviseModal() {
  659. const [form] = Form.useForm<{ people: string[]; channels: string[]; opinion: string }>()
  660. const rec = superviseOpen
  661. return (
  662. <Modal
  663. title="预警督办"
  664. open={!!rec}
  665. onCancel={() => setSuperviseOpen(null)}
  666. onOk={() => {
  667. form.validateFields().then((vals) => {
  668. if (rec) handleSupervise(rec, vals)
  669. setSuperviseOpen(null)
  670. })
  671. }}
  672. okText="督办"
  673. >
  674. <Form form={form} layout="vertical" initialValues={{ channels: ["平台通知"] }}>
  675. <Form.Item label="督办人员" name="people" rules={[{ required: true, message: "请选择督办人员" }]}>
  676. <Select mode="multiple" options={USERS.map((u) => ({ label: u, value: u }))} placeholder="选择人员" />
  677. </Form.Item>
  678. <Form.Item label="通知方式" name="channels">
  679. <Select mode="multiple" options={["短信", "邮件", "平台通知"].map((c) => ({ label: c, value: c }))} />
  680. </Form.Item>
  681. <Form.Item label="督办意见" name="opinion" rules={[{ required: true }]}>
  682. <Input.TextArea rows={3} placeholder="输入督办意见..." />
  683. </Form.Item>
  684. </Form>
  685. </Modal>
  686. )
  687. }
  688. function LeaderModal() {
  689. const [form] = Form.useForm<{ leaders: string[]; sms: string }>()
  690. const rec = leaderOpen
  691. return (
  692. <Modal
  693. title="添加领导批示(短信推送)"
  694. open={!!rec}
  695. onCancel={() => setLeaderOpen(null)}
  696. onOk={() => {
  697. form.validateFields().then((vals) => {
  698. if (rec) handleLeaderInstruction(rec, vals)
  699. setLeaderOpen(null)
  700. })
  701. }}
  702. okText="推送"
  703. >
  704. <Form form={form} layout="vertical" initialValues={{ leaders: [LEADERS[0]] }}>
  705. <Form.Item label="接收领导" name="leaders" rules={[{ required: true, message: "请选择接收人" }]}>
  706. <Select mode="multiple" options={LEADERS.map((l) => ({ label: l, value: l }))} />
  707. </Form.Item>
  708. <Form.Item label="短信内容" name="sms" rules={[{ required: true, message: "请输入短信内容" }]}>
  709. <Input.TextArea rows={3} maxLength={200} showCount />
  710. </Form.Item>
  711. </Form>
  712. </Modal>
  713. )
  714. }
  715. function ReturnModal() {
  716. const [form] = Form.useForm<{ reason: string }>()
  717. const rec = returnOpen
  718. return (
  719. <Modal
  720. title="退回预警"
  721. open={!!rec}
  722. onCancel={() => setReturnOpen(null)}
  723. onOk={() => {
  724. form.validateFields().then((vals) => {
  725. if (rec) handleReturn(rec, vals.reason)
  726. setReturnOpen(null)
  727. })
  728. }}
  729. okText="退回"
  730. >
  731. <Form form={form} layout="vertical">
  732. <Form.Item label="退回原因" name="reason" rules={[{ required: true, message: "请输入退回原因" }]}>
  733. <Input.TextArea rows={3} placeholder="说明退回原因..." />
  734. </Form.Item>
  735. </Form>
  736. </Modal>
  737. )
  738. }
  739. function DetailDrawer() {
  740. const rec = detail
  741. const stepIndex: number = useMemo(() => {
  742. if (!rec) return 0
  743. const stepMap: Record<WarningStatus, number> = {
  744. draft: 0,
  745. published: 1,
  746. in_process: 2,
  747. supervising: 2,
  748. returned: 2,
  749. upgraded: 2,
  750. resolved: 3,
  751. released: 4,
  752. }
  753. return stepMap[rec.status]
  754. }, [rec])
  755. return (
  756. <Drawer
  757. title={
  758. <Space>
  759. <Siren />
  760. <span>预警详情</span>
  761. {rec ? statusTag(rec.status) : null}
  762. </Space>
  763. }
  764. width={720}
  765. open={!!rec}
  766. onClose={() => setDetail(null)}
  767. >
  768. {!rec ? null : (
  769. <Space direction="vertical" size={16} className="w-full">
  770. <Descriptions bordered size="small" column={2}>
  771. <Descriptions.Item label="预警名称">{rec.name}</Descriptions.Item>
  772. <Descriptions.Item label="编码">{rec.code}</Descriptions.Item>
  773. <Descriptions.Item label="类型">{rec.type}</Descriptions.Item>
  774. <Descriptions.Item label="等级">
  775. <Tag color={levelColor(rec.level)}>{rec.level}</Tag>
  776. </Descriptions.Item>
  777. <Descriptions.Item label="行业">{rec.industry}</Descriptions.Item>
  778. <Descriptions.Item label="责任人">{rec.assignee}</Descriptions.Item>
  779. <Descriptions.Item label="创建时间">{dayjs(rec.createdAt).format("YYYY-MM-DD HH:mm")}</Descriptions.Item>
  780. <Descriptions.Item label="截止时间">
  781. {rec.deadline ? dayjs(rec.deadline).format("YYYY-MM-DD HH:mm") : "-"}
  782. </Descriptions.Item>
  783. <Descriptions.Item label="位置" span={2}>
  784. <Space>
  785. <MapPin size={16} />
  786. <span>{rec.location}</span>
  787. </Space>
  788. </Descriptions.Item>
  789. <Descriptions.Item label="描述" span={2}>
  790. {rec.description}
  791. </Descriptions.Item>
  792. </Descriptions>
  793. <Card size="small" title="GIS 定位(示意)">
  794. <img
  795. src="/placeholder.svg?height=220&width=660"
  796. alt="GIS 定位示意图"
  797. className="w-full rounded-md border"
  798. />
  799. </Card>
  800. <Card size="small" title="报警关联">
  801. <Table<Alarm>
  802. size="small"
  803. rowKey="id"
  804. pagination={false}
  805. dataSource={rec.relatedAlarms}
  806. columns={[
  807. { title: "设备", dataIndex: "device" },
  808. { title: "指标", dataIndex: "metric" },
  809. { title: "数值/阈值", render: (_, a) => `${a.value} / ${a.threshold}` },
  810. { title: "时间", dataIndex: "time", render: (t) => dayjs(t).format("YYYY-MM-DD HH:mm") },
  811. ]}
  812. />
  813. </Card>
  814. <Card size="small" title="处置流程(可视化)">
  815. <Steps
  816. current={stepIndex}
  817. items={[
  818. { title: "草稿" },
  819. { title: "已发布" },
  820. { title: "处置中/督办" },
  821. { title: "已处置" },
  822. { title: "已解除" },
  823. ]}
  824. />
  825. <Divider />
  826. <Table<ProcessLog>
  827. size="small"
  828. pagination={false}
  829. rowKey={(r) => `${r.time}-${r.action}`}
  830. dataSource={[...rec.process].reverse()}
  831. columns={[
  832. { title: "时间", dataIndex: "time", render: (t) => dayjs(t).format("YYYY-MM-DD HH:mm") },
  833. { title: "人员", dataIndex: "user" },
  834. { title: "动作", dataIndex: "action" },
  835. { title: "备注", dataIndex: "remark" },
  836. ]}
  837. />
  838. </Card>
  839. </Space>
  840. )}
  841. </Drawer>
  842. )
  843. }
  844. // Upload handler
  845. const normFile = (e: any) => {
  846. if (Array.isArray(e)) return e
  847. return e?.fileList
  848. }
  849. // Sections
  850. function Overview() {
  851. return (
  852. <Space direction="vertical" size={16} className="w-full">
  853. <Row gutter={16}>
  854. <Col xs={24} md={6}>
  855. <Card>
  856. <Statistic title="今日预警总数" value={todayStats.total} prefix={<Siren className="text-orange-500" />} />
  857. </Card>
  858. </Col>
  859. <Col xs={24} md={6}>
  860. <Card>
  861. <Statistic
  862. title="未解除"
  863. value={todayStats.unresolved}
  864. valueStyle={{ color: "#f97316" }}
  865. prefix={<Alert className="text-orange-500" />}
  866. />
  867. </Card>
  868. </Col>
  869. <Col xs={24} md={6}>
  870. <Card>
  871. <Statistic
  872. title="已解除"
  873. value={todayStats.released}
  874. valueStyle={{ color: "#10b981" }}
  875. prefix={<ShieldAlert className="text-emerald-500" />}
  876. />
  877. </Card>
  878. </Col>
  879. <Col xs={24} md={6}>
  880. <Card>
  881. <Statistic
  882. title="当前我的待办"
  883. value={mineTodo.length}
  884. prefix={<UserRoundCheck className="text-purple-500" />}
  885. />
  886. </Card>
  887. </Col>
  888. </Row>
  889. <Row gutter={16}>
  890. <Col xs={24} md={10}>
  891. <Card title="今日预警概况">
  892. <EChart option={todayPieOption} style={{ height: 260 }} opts={{ notMerge: true, lazyUpdate: true }} />
  893. <Divider />
  894. <Table<WarningItem>
  895. size="small"
  896. rowKey="id"
  897. pagination={{ pageSize: 5 }}
  898. dataSource={todayStats.list}
  899. columns={[
  900. { title: "名称", dataIndex: "name", render: (t, r) => <a onClick={() => setDetail(r)}>{t}</a> },
  901. { title: "等级", dataIndex: "level", render: (l: Level) => <Tag color={levelColor(l)}>{l}</Tag> },
  902. { title: "状态", dataIndex: "status", render: (s: WarningStatus) => statusTag(s) },
  903. ]}
  904. />
  905. </Card>
  906. </Col>
  907. <Col xs={24} md={14}>
  908. <Card title="历史预警概况">
  909. <Row gutter={12}>
  910. <Col span={14}>
  911. <EChart
  912. option={historyBarOption}
  913. style={{ height: 280 }}
  914. opts={{ notMerge: true, lazyUpdate: true }}
  915. />
  916. </Col>
  917. <Col span={10}>
  918. <EChart
  919. option={levelRingOption}
  920. style={{ height: 280 }}
  921. opts={{ notMerge: true, lazyUpdate: true }}
  922. />
  923. </Col>
  924. </Row>
  925. </Card>
  926. </Col>
  927. </Row>
  928. <Card title="今日未处置预警">
  929. <Table<WarningItem>
  930. size="small"
  931. rowKey="id"
  932. dataSource={todayUnhandled}
  933. pagination={{ pageSize: 8 }}
  934. columns={warnColumns}
  935. scroll={{ x: 1000 }}
  936. />
  937. </Card>
  938. </Space>
  939. )
  940. }
  941. function Publish() {
  942. return (
  943. <Space direction="vertical" size={16} className="w-full">
  944. <Card
  945. title={
  946. <Space>
  947. <Siren />
  948. <span>预警发布</span>
  949. </Space>
  950. }
  951. extra={
  952. <Button type="primary" onClick={() => setPublishOpen(true)} icon={<Siren />}>
  953. 新建预警
  954. </Button>
  955. }
  956. >
  957. <Form
  958. form={publishForm}
  959. layout="vertical"
  960. onFinish={handlePublish}
  961. initialValues={{
  962. type: "综合",
  963. level: "黄色",
  964. assignee: currentUser,
  965. }}
  966. >
  967. <Row gutter={12}>
  968. <Col xs={24} md={8}>
  969. <Form.Item label="预警名称" name="name" rules={[{ required: true, message: "请输入预警名称" }]}>
  970. <Input placeholder="如:某路段燃气压力异常预警" />
  971. </Form.Item>
  972. </Col>
  973. <Col xs={24} md={6}>
  974. <Form.Item label="预警类型" name="type" rules={[{ required: true }]}>
  975. <Select options={TYPES.map((t) => ({ label: t, value: t }))} />
  976. </Form.Item>
  977. </Col>
  978. <Col xs={24} md={6}>
  979. <Form.Item label="预警等级" name="level" rules={[{ required: true }]}>
  980. <Select options={LEVELS.map((l) => ({ label: l, value: l }))} />
  981. </Form.Item>
  982. </Col>
  983. <Col xs={24} md={4}>
  984. <Form.Item label="发布时间" name="releasedAt">
  985. <DatePicker showTime className="w-full" />
  986. </Form.Item>
  987. </Col>
  988. <Col xs={24} md={8}>
  989. <Form.Item label="位置" name="location">
  990. <Input prefix={<MapPin size={16} />} placeholder="输入预警位置" />
  991. </Form.Item>
  992. </Col>
  993. <Col xs={24} md={8}>
  994. <Form.Item label="责任人" name="assignee" rules={[{ required: true }]}>
  995. <Select options={USERS.map((u) => ({ label: u, value: u }))} />
  996. </Form.Item>
  997. </Col>
  998. <Col xs={24} md={8}>
  999. <Form.Item label="关联报警(示例)" name="alarms">
  1000. <Select
  1001. mode="multiple"
  1002. placeholder="选择报警记录"
  1003. options={seedAlarms.slice(0, 10).map((a) => ({
  1004. label: `${a.device}-${a.metric}(${dayjs(a.time).format("MM-DD HH:mm")})`,
  1005. value: a.id,
  1006. }))}
  1007. />
  1008. </Form.Item>
  1009. </Col>
  1010. <Col span={24}>
  1011. <Form.Item label="预警描述" name="description" rules={[{ required: true, message: "请输入描述" }]}>
  1012. <Input.TextArea rows={4} placeholder="结合设备报警曲线、位置及周边要素分析,描述具体内容..." />
  1013. </Form.Item>
  1014. </Col>
  1015. <Col span={24}>
  1016. <Form.Item
  1017. label="预警报告上传"
  1018. valuePropName="fileList"
  1019. getValueFromEvent={normFile}
  1020. name="attachments"
  1021. >
  1022. <Upload.Dragger beforeUpload={() => false} multiple>
  1023. <p className="ant-upload-drag-icon">
  1024. <FileText />
  1025. </p>
  1026. <p className="ant-upload-text">点击或拖拽文件到此处上传</p>
  1027. <p className="ant-upload-hint">支持多文件</p>
  1028. </Upload.Dragger>
  1029. </Form.Item>
  1030. </Col>
  1031. </Row>
  1032. <Space>
  1033. <Button type="primary" htmlType="submit">
  1034. 发布预警
  1035. </Button>
  1036. <Button htmlType="reset">重置</Button>
  1037. </Space>
  1038. </Form>
  1039. </Card>
  1040. <Row gutter={16}>
  1041. <Col xs={24} md={12}>
  1042. <Card title="报警信息关联(统一接口示例)" extra={<Tag>仅演示</Tag>}>
  1043. <Table<Alarm>
  1044. size="small"
  1045. rowKey="id"
  1046. dataSource={seedAlarms.slice(0, 8)}
  1047. pagination={false}
  1048. columns={[
  1049. { title: "设备", dataIndex: "device" },
  1050. { title: "指标", dataIndex: "metric" },
  1051. { title: "值/阈值", render: (_, a) => `${a.value}/${a.threshold}` },
  1052. { title: "时间", dataIndex: "time", render: (t) => dayjs(t).format("MM-DD HH:mm") },
  1053. ]}
  1054. />
  1055. </Card>
  1056. </Col>
  1057. <Col xs={24} md={12}>
  1058. <Card title="发布记录(最近)">
  1059. <Table<WarningItem>
  1060. size="small"
  1061. rowKey="id"
  1062. pagination={false}
  1063. dataSource={[...warnings].filter((w) => isToday(w.createdAt)).slice(0, 6)}
  1064. columns={[
  1065. { title: "名称", dataIndex: "name" },
  1066. { title: "编码", dataIndex: "code" },
  1067. { title: "等级", dataIndex: "level", render: (l: Level) => <Tag color={levelColor(l)}>{l}</Tag> },
  1068. { title: "状态", dataIndex: "status", render: (s: WarningStatus) => statusTag(s) },
  1069. ]}
  1070. />
  1071. </Card>
  1072. </Col>
  1073. </Row>
  1074. <Modal
  1075. title="快速发布预警"
  1076. open={publishOpen}
  1077. onCancel={() => setPublishOpen(false)}
  1078. footer={null}
  1079. destroyOnHidden
  1080. >
  1081. <Form
  1082. form={publishForm}
  1083. layout="vertical"
  1084. onFinish={handlePublish}
  1085. initialValues={{ type: "综合", level: "黄色", assignee: currentUser }}
  1086. >
  1087. <Form.Item label="预警名称" name="name" rules={[{ required: true }]}>
  1088. <Input />
  1089. </Form.Item>
  1090. <Form.Item label="类型" name="type" rules={[{ required: true }]}>
  1091. <Select options={TYPES.map((t) => ({ label: t, value: t }))} />
  1092. </Form.Item>
  1093. <Form.Item label="等级" name="level" rules={[{ required: true }]}>
  1094. <Select options={LEVELS.map((l) => ({ label: l, value: l }))} />
  1095. </Form.Item>
  1096. <Form.Item label="责任人" name="assignee" rules={[{ required: true }]}>
  1097. <Select options={USERS.map((u) => ({ label: u, value: u }))} />
  1098. </Form.Item>
  1099. <Form.Item label="描述" name="description" rules={[{ required: true }]}>
  1100. <Input.TextArea rows={3} />
  1101. </Form.Item>
  1102. <Space>
  1103. <Button htmlType="submit" type="primary">
  1104. 发布
  1105. </Button>
  1106. <Button onClick={() => setPublishOpen(false)}>取消</Button>
  1107. </Space>
  1108. </Form>
  1109. </Modal>
  1110. </Space>
  1111. )
  1112. }
  1113. function Handling() {
  1114. return (
  1115. <Space direction="vertical" size={16} className="w-full">
  1116. <Card
  1117. title={
  1118. <Space>
  1119. <SquareGanttChart />
  1120. <span>预警处置</span>
  1121. </Space>
  1122. }
  1123. >
  1124. <Table<WarningItem>
  1125. size="small"
  1126. rowKey="id"
  1127. dataSource={warnings.filter((w) =>
  1128. ["published", "in_process", "supervising", "upgraded", "returned"].includes(w.status),
  1129. )}
  1130. columns={warnColumns}
  1131. pagination={{ pageSize: 8 }}
  1132. scroll={{ x: 1000 }}
  1133. />
  1134. </Card>
  1135. <UpgradeModal />
  1136. <SuperviseModal />
  1137. <LeaderModal />
  1138. <ReturnModal />
  1139. </Space>
  1140. )
  1141. }
  1142. function Todos() {
  1143. const list = todoFilter.status === "todo" ? mineTodo : mineDone
  1144. const data = list.filter((w) => {
  1145. if (!todoFilter.q) return true
  1146. const q = todoFilter.q.trim()
  1147. if (!q) return true
  1148. return w.name.includes(q) || w.code.includes(q)
  1149. })
  1150. return (
  1151. <Space direction="vertical" size={16} className="w-full">
  1152. <Card
  1153. title={
  1154. <Space>
  1155. <UserRoundCheck />
  1156. <span>我的预警</span>
  1157. </Space>
  1158. }
  1159. extra={
  1160. <Space>
  1161. <Segmented
  1162. value={todoFilter.status}
  1163. onChange={(v) => setTodoFilter((p) => ({ ...p, status: v as any }))}
  1164. options={[
  1165. { label: "我的待办", value: "todo" },
  1166. { label: "我的已办", value: "done" },
  1167. ]}
  1168. />
  1169. <Input.Search
  1170. allowClear
  1171. placeholder="按名称/编号筛选"
  1172. onSearch={(q) => setTodoFilter((p) => ({ ...p, q }))}
  1173. />
  1174. </Space>
  1175. }
  1176. >
  1177. <Table<WarningItem>
  1178. size="small"
  1179. rowKey="id"
  1180. dataSource={data}
  1181. columns={warnColumns}
  1182. pagination={{ pageSize: 8 }}
  1183. scroll={{ x: 1000 }}
  1184. />
  1185. </Card>
  1186. </Space>
  1187. )
  1188. }
  1189. function Management() {
  1190. function SearchButton() {
  1191. return (
  1192. <Space>
  1193. <ChartBar size={16} />
  1194. <span>查询</span>
  1195. </Space>
  1196. )
  1197. }
  1198. return (
  1199. <Space direction="vertical" size={16} className="w-full">
  1200. <Card
  1201. title={
  1202. <Space>
  1203. <Settings />
  1204. <span>预警信息管理</span>
  1205. </Space>
  1206. }
  1207. >
  1208. <Space wrap className="w-full">
  1209. <Select
  1210. allowClear
  1211. placeholder="类型"
  1212. options={TYPES.map((t) => ({ label: t, value: t }))}
  1213. onChange={(type) => setMgmtFilters((p) => ({ ...p, type: type as WarnType | undefined }))}
  1214. style={{ width: 140 }}
  1215. />
  1216. <Select
  1217. allowClear
  1218. placeholder="等级"
  1219. options={LEVELS.map((l) => ({ label: l, value: l }))}
  1220. onChange={(level) => setMgmtFilters((p) => ({ ...p, level: level as Level | undefined }))}
  1221. style={{ width: 140 }}
  1222. />
  1223. <Select
  1224. allowClear
  1225. placeholder="行业"
  1226. options={INDUSTRIES.map((i) => ({ label: i, value: i }))}
  1227. onChange={(industry) => setMgmtFilters((p) => ({ ...p, industry: industry as Industry | undefined }))}
  1228. style={{ width: 140 }}
  1229. />
  1230. <Select
  1231. allowClear
  1232. placeholder="状态"
  1233. options={[
  1234. "draft",
  1235. "published",
  1236. "in_process",
  1237. "supervising",
  1238. "returned",
  1239. "upgraded",
  1240. "resolved",
  1241. "released",
  1242. ].map((s) => ({ label: s, value: s }))}
  1243. onChange={(status) => setMgmtFilters((p) => ({ ...p, status: status as WarningStatus | undefined }))}
  1244. style={{ width: 160 }}
  1245. />
  1246. <DatePicker.RangePicker
  1247. onChange={(v) => setMgmtFilters((p) => ({ ...p, date: (v as any) || undefined }))}
  1248. />
  1249. <Input.Search
  1250. allowClear
  1251. placeholder="名称/编码/位置/描述"
  1252. onSearch={(q) => setMgmtFilters((p) => ({ ...p, q }))}
  1253. style={{ width: 280 }}
  1254. enterButton={<SearchButton />}
  1255. />
  1256. <Radio.Group
  1257. value={todayOnly}
  1258. onChange={(e) => setTodayOnly(e.target.value)}
  1259. optionType="button"
  1260. options={[
  1261. { label: "今日", value: true },
  1262. { label: "全部", value: false },
  1263. ]}
  1264. />
  1265. </Space>
  1266. <Divider />
  1267. <Table<WarningItem>
  1268. size="small"
  1269. rowKey="id"
  1270. dataSource={filteredMgmtList}
  1271. columns={warnColumns}
  1272. pagination={{ pageSize: 10 }}
  1273. scroll={{ x: 1000 }}
  1274. />
  1275. </Card>
  1276. </Space>
  1277. )
  1278. }
  1279. function Statistics() {
  1280. const statusAgg = useMemo(() => {
  1281. const agg = new Map<WarningStatus, number>()
  1282. warnings.forEach((w) => agg.set(w.status, (agg.get(w.status) || 0) + 1))
  1283. return Array.from(agg.entries()).map(([name, value]) => ({ name, value }))
  1284. }, [warnings])
  1285. const statusPie = {
  1286. tooltip: { trigger: "item" },
  1287. series: [{ type: "pie", radius: ["35%", "60%"], data: statusAgg }],
  1288. color: ["#64748b", "#60a5fa", "#34d399", "#f59e0b", "#ef4444", "#a78bfa", "#22c55e", "#93c5fd"],
  1289. }
  1290. const todayNonDraftResolvedReleased = warnings.filter((w) => isToday(w.createdAt) && !["draft"].includes(w.status))
  1291. return (
  1292. <Space direction="vertical" size={16} className="w-full">
  1293. <Row gutter={16}>
  1294. <Col xs={24} md={8}>
  1295. <Card title="处置状态分析">
  1296. <EChart option={statusPie} style={{ height: 260 }} opts={{ notMerge: true, lazyUpdate: true }} />
  1297. </Card>
  1298. </Col>
  1299. <Col xs={24} md={8}>
  1300. <Card title="今日预警统计(非草稿)">
  1301. <Table<WarningItem>
  1302. size="small"
  1303. rowKey="id"
  1304. pagination={{ pageSize: 5 }}
  1305. dataSource={todayNonDraftResolvedReleased}
  1306. columns={[
  1307. { title: "名称", dataIndex: "name" },
  1308. { title: "等级", dataIndex: "level", render: (l: Level) => <Tag color={levelColor(l)}>{l}</Tag> },
  1309. { title: "状态", dataIndex: "status", render: (s: WarningStatus) => statusTag(s) },
  1310. ]}
  1311. />
  1312. </Card>
  1313. </Col>
  1314. <Col xs={24} md={8}>
  1315. <Card title="接警效率 TOP(按类型)">
  1316. <EChart
  1317. option={efficiencyTopOption}
  1318. style={{ height: 260 }}
  1319. opts={{ notMerge: true, lazyUpdate: true }}
  1320. />
  1321. </Card>
  1322. </Col>
  1323. </Row>
  1324. <Row gutter={16}>
  1325. <Col xs={24} md={14}>
  1326. <Card
  1327. title={
  1328. <Space>
  1329. <LineChart />
  1330. <span>预警发展趋势(14日)</span>
  1331. </Space>
  1332. }
  1333. >
  1334. <EChart option={trendOption} style={{ height: 320 }} opts={{ notMerge: true, lazyUpdate: true }} />
  1335. </Card>
  1336. </Col>
  1337. <Col xs={24} md={10}>
  1338. <Card title="行业处置分析">
  1339. <EChart
  1340. option={{
  1341. tooltip: { trigger: "axis" },
  1342. legend: { data: ["处置中", "已处置", "已解除"] },
  1343. xAxis: { type: "category", data: INDUSTRIES },
  1344. yAxis: { type: "value" },
  1345. series: [
  1346. {
  1347. name: "处置中",
  1348. type: "bar",
  1349. stack: "total",
  1350. data: INDUSTRIES.map(
  1351. (i) =>
  1352. warnings.filter(
  1353. (w) =>
  1354. w.industry === i &&
  1355. ["in_process", "supervising", "upgraded", "returned"].includes(w.status),
  1356. ).length,
  1357. ),
  1358. },
  1359. {
  1360. name: "已处置",
  1361. type: "bar",
  1362. stack: "total",
  1363. data: INDUSTRIES.map(
  1364. (i) => warnings.filter((w) => w.industry === i && w.status === "resolved").length,
  1365. ),
  1366. itemStyle: { color: "#22c55e" },
  1367. },
  1368. {
  1369. name: "已解除",
  1370. type: "bar",
  1371. stack: "total",
  1372. data: INDUSTRIES.map(
  1373. (i) => warnings.filter((w) => w.industry === i && w.status === "released").length,
  1374. ),
  1375. itemStyle: { color: "#10b981" },
  1376. },
  1377. ],
  1378. }}
  1379. style={{ height: 320 }}
  1380. opts={{ notMerge: true, lazyUpdate: true }}
  1381. />
  1382. </Card>
  1383. </Col>
  1384. </Row>
  1385. </Space>
  1386. )
  1387. }
  1388. return (
  1389. <main className="min-h-screen w-full bg-slate-50">
  1390. <div className="mx-auto max-w-[1400px] px-4 py-6">
  1391. <Space direction="vertical" size={16} className="w-full">
  1392. <Space align="center" className="w-full justify-between">
  1393. <Space>
  1394. <Siren className="text-orange-500" />
  1395. <h1 className="text-2xl font-semibold">生命线预警联动处置平台</h1>
  1396. </Space>
  1397. <Space>
  1398. <AntdBadge status="processing" text={`当前用户:${currentUser}`} />
  1399. </Space>
  1400. </Space>
  1401. <Tabs
  1402. activeKey={activeTab}
  1403. onChange={(k) => {
  1404. setActiveTab(k as string)
  1405. // 切换 Tab 时主动触发一次窗口 resize,配合组件内的 ResizeObserver,确保图表正确自适应
  1406. if (typeof window !== "undefined") {
  1407. setTimeout(() => window.dispatchEvent(new Event("resize")), 0)
  1408. }
  1409. }}
  1410. items={[
  1411. {
  1412. key: "overview",
  1413. label: (
  1414. <Space>
  1415. <Timeline size={16} />
  1416. <span>预警总览</span>
  1417. </Space>
  1418. ),
  1419. children: <Overview />,
  1420. },
  1421. {
  1422. key: "publish",
  1423. label: (
  1424. <Space>
  1425. <Siren size={16} />
  1426. <span>预警信息发布</span>
  1427. </Space>
  1428. ),
  1429. children: <Publish />,
  1430. },
  1431. {
  1432. key: "todos",
  1433. label: (
  1434. <Space>
  1435. <UserRoundCheck size={16} />
  1436. <span>待办预警</span>
  1437. </Space>
  1438. ),
  1439. children: <Todos />,
  1440. },
  1441. {
  1442. key: "handling",
  1443. label: (
  1444. <Space>
  1445. <SquareGanttChart size={16} />
  1446. <span>预警处置</span>
  1447. </Space>
  1448. ),
  1449. children: <Handling />,
  1450. },
  1451. {
  1452. key: "management",
  1453. label: (
  1454. <Space>
  1455. <Settings size={16} />
  1456. <span>预警信息管理</span>
  1457. </Space>
  1458. ),
  1459. children: <Management />,
  1460. },
  1461. {
  1462. key: "matters",
  1463. label: (
  1464. <Space>
  1465. <FileText size={16} />
  1466. <span>预警清单管理</span>
  1467. </Space>
  1468. ),
  1469. children: (
  1470. <MatterManagement
  1471. matters={matters}
  1472. setMatters={setMatters}
  1473. reasons={reasons}
  1474. setReasons={setReasons}
  1475. />
  1476. ),
  1477. },
  1478. {
  1479. key: "stats",
  1480. label: (
  1481. <Space>
  1482. <ChartBar size={16} />
  1483. <span>预警信息统计</span>
  1484. </Space>
  1485. ),
  1486. children: <Statistics />,
  1487. },
  1488. ]}
  1489. />
  1490. <DetailDrawer />
  1491. </Space>
  1492. </div>
  1493. </main>
  1494. )
  1495. }