IpEntryMaintenancePanel.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. import { useCallback, useEffect, useRef, useState } from "react";
  2. import { useConfirm } from "../../shared/ui/ConfirmDialog";
  3. import { Edit2, Info, Plus, RefreshCw, Trash2, X } from "lucide-react";
  4. import type { PageResult } from "../../shared/api/types";
  5. import { ipEntryApi, type IpEntryApi } from "./ipEntryApi";
  6. import type {
  7. IpEntryCreateRequest,
  8. IpEntrySummary,
  9. IpEntryUpdateRequest,
  10. } from "./ipEntryTypes";
  11. import {
  12. B,
  13. DANGER,
  14. N,
  15. PBG,
  16. W,
  17. type ApiResultState,
  18. describeError,
  19. initialResult,
  20. LabeledInput,
  21. ModalFooter,
  22. ModalShell,
  23. smallButton,
  24. } from "./directoryUi";
  25. const EMPTY_IP_PAGE: PageResult<IpEntrySummary> = {
  26. items: [],
  27. page: 1,
  28. pageSize: 20,
  29. total: 0,
  30. totalPages: 0,
  31. };
  32. interface IpEntryForm {
  33. ipValue: string;
  34. remark: string;
  35. }
  36. interface IpEntryEditState {
  37. mode: "create" | "edit";
  38. target: IpEntrySummary | null;
  39. form: IpEntryForm;
  40. rowVersion: number;
  41. }
  42. function buildIpEntryForm(
  43. mode: "create" | "edit",
  44. target: IpEntrySummary | null,
  45. ): IpEntryForm {
  46. if (mode === "edit" && target) {
  47. return { ipValue: target.ipValue, remark: target.remark };
  48. }
  49. return { ipValue: "", remark: "" };
  50. }
  51. /** IP地址池维护:全局唯一IP池(精确IP或CIDR网段),供机构维护中为部门分配。 */
  52. export function IpEntryMaintenancePanel({
  53. api = ipEntryApi,
  54. }: {
  55. api?: IpEntryApi;
  56. }) {
  57. const { confirm: confirmDialog, dialog: confirmDialogNode } = useConfirm();
  58. const [keyword, setKeyword] = useState("");
  59. const [page, setPage] = useState(1);
  60. const pageSize = 20;
  61. const [retry, setRetry] = useState(0);
  62. const [data, setData] = useState<PageResult<IpEntrySummary>>(EMPTY_IP_PAGE);
  63. const [listStatus, setListStatus] = useState<"loading" | "success" | "error">(
  64. "loading",
  65. );
  66. const [listError, setListError] = useState<string | null>(null);
  67. const [editState, setEditState] = useState<IpEntryEditState | null>(null);
  68. const [mutation, setMutation] = useState<ApiResultState>(initialResult);
  69. // 列表:搜索防抖 + 分页(与 RoleMaintenancePanel 相同的竞态防护模式)
  70. const listVersion = useRef(0);
  71. useEffect(() => {
  72. const controller = new AbortController();
  73. const current = ++listVersion.current;
  74. const timer = window.setTimeout(async () => {
  75. setListStatus("loading");
  76. setListError(null);
  77. try {
  78. const result = await api.getIpEntries(
  79. {
  80. keyword: keyword.trim() || undefined,
  81. page,
  82. pageSize,
  83. },
  84. controller.signal,
  85. );
  86. if (controller.signal.aborted || current !== listVersion.current) {
  87. return;
  88. }
  89. setData(result);
  90. setListStatus("success");
  91. } catch (loadError) {
  92. if (controller.signal.aborted || current !== listVersion.current) {
  93. return;
  94. }
  95. setListError(describeError(loadError));
  96. setListStatus("error");
  97. }
  98. }, keyword.trim() ? 300 : 0);
  99. return () => {
  100. window.clearTimeout(timer);
  101. controller.abort();
  102. };
  103. }, [api, keyword, page, retry]);
  104. const refresh = useCallback(() => setRetry((v) => v + 1), []);
  105. const openCreate = () => {
  106. setEditState({
  107. mode: "create",
  108. target: null,
  109. form: buildIpEntryForm("create", null),
  110. rowVersion: 0,
  111. });
  112. setMutation(initialResult);
  113. };
  114. const openEdit = (entry: IpEntrySummary) => {
  115. setEditState({
  116. mode: "edit",
  117. target: entry,
  118. form: buildIpEntryForm("edit", entry),
  119. rowVersion: entry.rowVersion,
  120. });
  121. setMutation(initialResult);
  122. };
  123. const closeEditor = () => {
  124. setEditState(null);
  125. setMutation(initialResult);
  126. };
  127. const handleSubmit = async () => {
  128. if (!editState) return;
  129. const form = editState.form;
  130. if (!form.ipValue.trim()) {
  131. setMutation({ submitting: false, error: "IP地址或网段不能为空" });
  132. return;
  133. }
  134. setMutation({ submitting: true, error: null });
  135. try {
  136. if (editState.mode === "create") {
  137. const payload: IpEntryCreateRequest = {
  138. ipValue: form.ipValue.trim(),
  139. remark: form.remark.trim() || null,
  140. };
  141. await api.create(payload);
  142. } else if (editState.target) {
  143. const payload: IpEntryUpdateRequest = {
  144. ipValue: form.ipValue.trim(),
  145. remark: form.remark.trim() || null,
  146. rowVersion: editState.rowVersion,
  147. };
  148. await api.update(editState.target.id, payload);
  149. }
  150. closeEditor();
  151. refresh();
  152. } catch (error) {
  153. setMutation({ submitting: false, error: describeError(error) });
  154. }
  155. };
  156. const handleDelete = async (entry: IpEntrySummary) => {
  157. if (!(await confirmDialog({ message: `确认删除IP「${entry.ipValue}」?`, danger: true }))) return;
  158. setMutation({ submitting: true, error: null });
  159. try {
  160. await api.delete(entry.id, entry.rowVersion);
  161. setMutation(initialResult);
  162. refresh();
  163. } catch (error) {
  164. setMutation({ submitting: false, error: describeError(error) });
  165. }
  166. };
  167. return (
  168. <div className="w-full h-full flex flex-col overflow-hidden">
  169. {confirmDialogNode}
  170. <div
  171. className="flex-shrink-0 flex items-center justify-between gap-3 px-6 py-3 border-b"
  172. style={{ background: W, borderColor: "rgba(var(--dms-bright-rgb),0.15)" }}
  173. >
  174. <div className="flex items-center gap-2 flex-1 max-w-md">
  175. <input
  176. value={keyword}
  177. onChange={(e) => {
  178. setKeyword(e.target.value);
  179. setPage(1);
  180. }}
  181. placeholder="按IP或备注筛选"
  182. className="flex-1 px-3 py-1.5 text-base border rounded-sm outline-none"
  183. style={{ borderColor: "rgba(var(--dms-bright-rgb),0.4)", color: N }}
  184. />
  185. {keyword && (
  186. <button
  187. type="button"
  188. onClick={() => setKeyword("")}
  189. className="text-base font-bold opacity-60 hover:opacity-100"
  190. style={{ color: N }}
  191. >
  192. <X size={14} />
  193. </button>
  194. )}
  195. </div>
  196. <div className="flex items-center gap-1.5">
  197. {smallButton(false, {
  198. label: "刷新",
  199. icon: <RefreshCw size={12} />,
  200. onClick: refresh,
  201. })}
  202. {smallButton(true, {
  203. label: "新建IP",
  204. icon: <Plus size={12} />,
  205. onClick: openCreate,
  206. })}
  207. </div>
  208. </div>
  209. <div
  210. className="flex-shrink-0 flex items-center gap-1.5 px-6 py-2 text-base font-bold border-b"
  211. style={{
  212. color: N,
  213. background: "rgba(var(--dms-bright-rgb),0.05)",
  214. borderColor: "rgba(var(--dms-bright-rgb),0.15)",
  215. }}
  216. >
  217. <Info size={12} />
  218. 全局IP池:维护精确IP或CIDR网段;在「机构管理」中为部门分配后,该部门用户仅可从分配的IP登录(ADMIN不受限制)。
  219. </div>
  220. <div
  221. className="flex-1 min-h-0 overflow-auto px-6 py-4"
  222. style={{ background: PBG }}
  223. >
  224. {listStatus === "loading" && (
  225. <div
  226. className="text-base font-bold text-center py-8"
  227. style={{ color: N }}
  228. >
  229. 正在加载IP条目…
  230. </div>
  231. )}
  232. {listStatus === "error" && (
  233. <div
  234. className="text-base font-bold text-center py-8"
  235. style={{ color: DANGER }}
  236. >
  237. {listError ?? "加载失败"}
  238. </div>
  239. )}
  240. {listStatus === "success" && data.items.length === 0 && (
  241. <div
  242. className="text-base font-bold text-center py-8"
  243. style={{ color: N }}
  244. >
  245. 暂无IP条目,点击右上角"新建IP"开始维护。
  246. </div>
  247. )}
  248. {data.items.length > 0 && (
  249. <table
  250. className="w-full text-base"
  251. style={{ background: W, borderCollapse: "collapse" }}
  252. >
  253. <thead>
  254. <tr style={{ background: PBG }}>
  255. {["IP地址/网段", "备注", "操作"].map((h) => (
  256. <th
  257. key={h}
  258. className="text-left px-3 py-2 text-base font-black border"
  259. style={{
  260. color: N,
  261. borderColor: "rgba(var(--dms-bright-rgb),0.2)",
  262. }}
  263. >
  264. {h}
  265. </th>
  266. ))}
  267. </tr>
  268. </thead>
  269. <tbody>
  270. {data.items.map((entry) => (
  271. <tr
  272. key={entry.id}
  273. className="border-t transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)]"
  274. style={{ borderColor: "rgba(var(--dms-bright-rgb),0.12)" }}
  275. >
  276. <td className="px-3 py-2 font-black" style={{ color: N }}>
  277. {entry.ipValue}
  278. </td>
  279. <td className="px-3 py-2" style={{ color: N }}>
  280. {entry.remark || "—"}
  281. </td>
  282. <td className="px-3 py-2">
  283. <div className="flex items-center gap-1.5">
  284. {smallButton(false, {
  285. label: "编辑",
  286. icon: <Edit2 size={12} />,
  287. onClick: () => openEdit(entry),
  288. })}
  289. {smallButton(false, {
  290. label: "删除",
  291. icon: <Trash2 size={12} />,
  292. onClick: () => handleDelete(entry),
  293. danger: true,
  294. })}
  295. </div>
  296. </td>
  297. </tr>
  298. ))}
  299. </tbody>
  300. </table>
  301. )}
  302. </div>
  303. <div
  304. className="flex-shrink-0 flex items-center justify-between px-6 py-2 border-t text-base font-bold"
  305. style={{
  306. color: N,
  307. background: W,
  308. borderColor: "rgba(var(--dms-bright-rgb),0.15)",
  309. }}
  310. >
  311. <span>
  312. 共 {data.total} 条 · 第 {data.page} / {Math.max(data.totalPages, 1)}{" "}
  313. 页
  314. </span>
  315. <div className="flex items-center gap-1.5">
  316. <button
  317. type="button"
  318. onClick={() => setPage((p) => Math.max(1, p - 1))}
  319. disabled={data.page <= 1}
  320. className="px-2.5 py-1 text-base font-bold rounded-sm disabled:opacity-40"
  321. style={{ border: `1px solid ${B}`, color: N, background: W }}
  322. >
  323. 上一页
  324. </button>
  325. <button
  326. type="button"
  327. onClick={() =>
  328. setPage((p) => Math.min(Math.max(data.totalPages, 1), p + 1))
  329. }
  330. disabled={data.page >= data.totalPages}
  331. className="px-2.5 py-1 text-base font-bold rounded-sm disabled:opacity-40"
  332. style={{ border: `1px solid ${B}`, color: N, background: W }}
  333. >
  334. 下一页
  335. </button>
  336. </div>
  337. </div>
  338. {mutation.error && (
  339. <div
  340. className="flex-shrink-0 px-6 py-2 text-base font-bold border-t"
  341. style={{
  342. color: DANGER,
  343. background: "rgba(192,57,43,0.05)",
  344. borderColor: "rgba(192,57,43,0.2)",
  345. }}
  346. >
  347. {mutation.error}
  348. </div>
  349. )}
  350. {editState && (
  351. <IpEntryEditorModal
  352. mode={editState.mode}
  353. target={editState.target}
  354. form={editState.form}
  355. onChange={(patch) =>
  356. setEditState((current) =>
  357. current
  358. ? { ...current, form: { ...current.form, ...patch } }
  359. : current,
  360. )
  361. }
  362. onClose={closeEditor}
  363. onSubmit={handleSubmit}
  364. submitting={mutation.submitting}
  365. />
  366. )}
  367. </div>
  368. );
  369. }
  370. function IpEntryEditorModal({
  371. mode,
  372. target,
  373. form,
  374. onChange,
  375. onClose,
  376. onSubmit,
  377. submitting,
  378. }: {
  379. mode: "create" | "edit";
  380. target: IpEntrySummary | null;
  381. form: IpEntryForm;
  382. onChange: (patch: Partial<IpEntryForm>) => void;
  383. onClose: () => void;
  384. onSubmit: () => void;
  385. submitting: boolean;
  386. }) {
  387. const title =
  388. mode === "create" ? "新建IP条目" : `编辑IP条目:${target?.ipValue ?? ""}`;
  389. return (
  390. <ModalShell title={title} onClose={onClose}>
  391. <div className="space-y-3">
  392. <LabeledInput
  393. label="IP地址/网段"
  394. value={form.ipValue}
  395. onChange={(v) => onChange({ ipValue: v })}
  396. placeholder="如:192.168.1.5 或 10.0.0.0/24"
  397. />
  398. <div
  399. className="text-base font-bold px-2 py-1.5 rounded-sm"
  400. style={{ color: N, background: "rgba(var(--dms-bright-rgb),0.05)" }}
  401. >
  402. 支持精确IP(IPv4/IPv6)或无主机位的CIDR网段;输入会被自动规范化,
  403. 网段不允许携带主机位(如 10.0.0.1/24 将被拒绝)。
  404. </div>
  405. <LabeledInput
  406. label="备注"
  407. value={form.remark}
  408. onChange={(v) => onChange({ remark: v })}
  409. placeholder="选填,如:总部办公网段"
  410. />
  411. </div>
  412. <ModalFooter
  413. onClose={onClose}
  414. onSubmit={onSubmit}
  415. submitting={submitting}
  416. submitLabel={mode === "create" ? "创建" : "保存"}
  417. />
  418. </ModalShell>
  419. );
  420. }