| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441 |
- import { useCallback, useEffect, useRef, useState } from "react";
- import { useConfirm } from "../../shared/ui/ConfirmDialog";
- import { Edit2, Info, Plus, RefreshCw, Trash2, X } from "lucide-react";
- import type { PageResult } from "../../shared/api/types";
- import { ipEntryApi, type IpEntryApi } from "./ipEntryApi";
- import type {
- IpEntryCreateRequest,
- IpEntrySummary,
- IpEntryUpdateRequest,
- } from "./ipEntryTypes";
- import {
- B,
- DANGER,
- N,
- PBG,
- W,
- type ApiResultState,
- describeError,
- initialResult,
- LabeledInput,
- ModalFooter,
- ModalShell,
- smallButton,
- } from "./directoryUi";
- const EMPTY_IP_PAGE: PageResult<IpEntrySummary> = {
- items: [],
- page: 1,
- pageSize: 20,
- total: 0,
- totalPages: 0,
- };
- interface IpEntryForm {
- ipValue: string;
- remark: string;
- }
- interface IpEntryEditState {
- mode: "create" | "edit";
- target: IpEntrySummary | null;
- form: IpEntryForm;
- rowVersion: number;
- }
- function buildIpEntryForm(
- mode: "create" | "edit",
- target: IpEntrySummary | null,
- ): IpEntryForm {
- if (mode === "edit" && target) {
- return { ipValue: target.ipValue, remark: target.remark };
- }
- return { ipValue: "", remark: "" };
- }
- /** IP地址池维护:全局唯一IP池(精确IP或CIDR网段),供机构维护中为部门分配。 */
- export function IpEntryMaintenancePanel({
- api = ipEntryApi,
- }: {
- api?: IpEntryApi;
- }) {
- const { confirm: confirmDialog, dialog: confirmDialogNode } = useConfirm();
- const [keyword, setKeyword] = useState("");
- const [page, setPage] = useState(1);
- const pageSize = 20;
- const [retry, setRetry] = useState(0);
- const [data, setData] = useState<PageResult<IpEntrySummary>>(EMPTY_IP_PAGE);
- const [listStatus, setListStatus] = useState<"loading" | "success" | "error">(
- "loading",
- );
- const [listError, setListError] = useState<string | null>(null);
- const [editState, setEditState] = useState<IpEntryEditState | null>(null);
- const [mutation, setMutation] = useState<ApiResultState>(initialResult);
- // 列表:搜索防抖 + 分页(与 RoleMaintenancePanel 相同的竞态防护模式)
- const listVersion = useRef(0);
- useEffect(() => {
- const controller = new AbortController();
- const current = ++listVersion.current;
- const timer = window.setTimeout(async () => {
- setListStatus("loading");
- setListError(null);
- try {
- const result = await api.getIpEntries(
- {
- keyword: keyword.trim() || undefined,
- page,
- pageSize,
- },
- controller.signal,
- );
- if (controller.signal.aborted || current !== listVersion.current) {
- return;
- }
- setData(result);
- setListStatus("success");
- } catch (loadError) {
- if (controller.signal.aborted || current !== listVersion.current) {
- return;
- }
- setListError(describeError(loadError));
- setListStatus("error");
- }
- }, keyword.trim() ? 300 : 0);
- return () => {
- window.clearTimeout(timer);
- controller.abort();
- };
- }, [api, keyword, page, retry]);
- const refresh = useCallback(() => setRetry((v) => v + 1), []);
- const openCreate = () => {
- setEditState({
- mode: "create",
- target: null,
- form: buildIpEntryForm("create", null),
- rowVersion: 0,
- });
- setMutation(initialResult);
- };
- const openEdit = (entry: IpEntrySummary) => {
- setEditState({
- mode: "edit",
- target: entry,
- form: buildIpEntryForm("edit", entry),
- rowVersion: entry.rowVersion,
- });
- setMutation(initialResult);
- };
- const closeEditor = () => {
- setEditState(null);
- setMutation(initialResult);
- };
- const handleSubmit = async () => {
- if (!editState) return;
- const form = editState.form;
- if (!form.ipValue.trim()) {
- setMutation({ submitting: false, error: "IP地址或网段不能为空" });
- return;
- }
- setMutation({ submitting: true, error: null });
- try {
- if (editState.mode === "create") {
- const payload: IpEntryCreateRequest = {
- ipValue: form.ipValue.trim(),
- remark: form.remark.trim() || null,
- };
- await api.create(payload);
- } else if (editState.target) {
- const payload: IpEntryUpdateRequest = {
- ipValue: form.ipValue.trim(),
- remark: form.remark.trim() || null,
- rowVersion: editState.rowVersion,
- };
- await api.update(editState.target.id, payload);
- }
- closeEditor();
- refresh();
- } catch (error) {
- setMutation({ submitting: false, error: describeError(error) });
- }
- };
- const handleDelete = async (entry: IpEntrySummary) => {
- if (!(await confirmDialog({ message: `确认删除IP「${entry.ipValue}」?`, danger: true }))) return;
- setMutation({ submitting: true, error: null });
- try {
- await api.delete(entry.id, entry.rowVersion);
- setMutation(initialResult);
- refresh();
- } catch (error) {
- setMutation({ submitting: false, error: describeError(error) });
- }
- };
- return (
- <div className="w-full h-full flex flex-col overflow-hidden">
- {confirmDialogNode}
- <div
- className="flex-shrink-0 flex items-center justify-between gap-3 px-6 py-3 border-b"
- style={{ background: W, borderColor: "rgba(var(--dms-bright-rgb),0.15)" }}
- >
- <div className="flex items-center gap-2 flex-1 max-w-md">
- <input
- value={keyword}
- onChange={(e) => {
- setKeyword(e.target.value);
- setPage(1);
- }}
- placeholder="按IP或备注筛选"
- className="flex-1 px-3 py-1.5 text-base border rounded-sm outline-none"
- style={{ borderColor: "rgba(var(--dms-bright-rgb),0.4)", color: N }}
- />
- {keyword && (
- <button
- type="button"
- onClick={() => setKeyword("")}
- className="text-base font-bold opacity-60 hover:opacity-100"
- style={{ color: N }}
- >
- <X size={14} />
- </button>
- )}
- </div>
- <div className="flex items-center gap-1.5">
- {smallButton(false, {
- label: "刷新",
- icon: <RefreshCw size={12} />,
- onClick: refresh,
- })}
- {smallButton(true, {
- label: "新建IP",
- icon: <Plus size={12} />,
- onClick: openCreate,
- })}
- </div>
- </div>
- <div
- className="flex-shrink-0 flex items-center gap-1.5 px-6 py-2 text-base font-bold border-b"
- style={{
- color: N,
- background: "rgba(var(--dms-bright-rgb),0.05)",
- borderColor: "rgba(var(--dms-bright-rgb),0.15)",
- }}
- >
- <Info size={12} />
- 全局IP池:维护精确IP或CIDR网段;在「机构管理」中为部门分配后,该部门用户仅可从分配的IP登录(ADMIN不受限制)。
- </div>
- <div
- className="flex-1 min-h-0 overflow-auto px-6 py-4"
- style={{ background: PBG }}
- >
- {listStatus === "loading" && (
- <div
- className="text-base font-bold text-center py-8"
- style={{ color: N }}
- >
- 正在加载IP条目…
- </div>
- )}
- {listStatus === "error" && (
- <div
- className="text-base font-bold text-center py-8"
- style={{ color: DANGER }}
- >
- {listError ?? "加载失败"}
- </div>
- )}
- {listStatus === "success" && data.items.length === 0 && (
- <div
- className="text-base font-bold text-center py-8"
- style={{ color: N }}
- >
- 暂无IP条目,点击右上角"新建IP"开始维护。
- </div>
- )}
- {data.items.length > 0 && (
- <table
- className="w-full text-base"
- style={{ background: W, borderCollapse: "collapse" }}
- >
- <thead>
- <tr style={{ background: PBG }}>
- {["IP地址/网段", "备注", "操作"].map((h) => (
- <th
- key={h}
- className="text-left px-3 py-2 text-base font-black border"
- style={{
- color: N,
- borderColor: "rgba(var(--dms-bright-rgb),0.2)",
- }}
- >
- {h}
- </th>
- ))}
- </tr>
- </thead>
- <tbody>
- {data.items.map((entry) => (
- <tr
- key={entry.id}
- className="border-t transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)]"
- style={{ borderColor: "rgba(var(--dms-bright-rgb),0.12)" }}
- >
- <td className="px-3 py-2 font-black" style={{ color: N }}>
- {entry.ipValue}
- </td>
- <td className="px-3 py-2" style={{ color: N }}>
- {entry.remark || "—"}
- </td>
- <td className="px-3 py-2">
- <div className="flex items-center gap-1.5">
- {smallButton(false, {
- label: "编辑",
- icon: <Edit2 size={12} />,
- onClick: () => openEdit(entry),
- })}
- {smallButton(false, {
- label: "删除",
- icon: <Trash2 size={12} />,
- onClick: () => handleDelete(entry),
- danger: true,
- })}
- </div>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- )}
- </div>
- <div
- className="flex-shrink-0 flex items-center justify-between px-6 py-2 border-t text-base font-bold"
- style={{
- color: N,
- background: W,
- borderColor: "rgba(var(--dms-bright-rgb),0.15)",
- }}
- >
- <span>
- 共 {data.total} 条 · 第 {data.page} / {Math.max(data.totalPages, 1)}{" "}
- 页
- </span>
- <div className="flex items-center gap-1.5">
- <button
- type="button"
- onClick={() => setPage((p) => Math.max(1, p - 1))}
- disabled={data.page <= 1}
- className="px-2.5 py-1 text-base font-bold rounded-sm disabled:opacity-40"
- style={{ border: `1px solid ${B}`, color: N, background: W }}
- >
- 上一页
- </button>
- <button
- type="button"
- onClick={() =>
- setPage((p) => Math.min(Math.max(data.totalPages, 1), p + 1))
- }
- disabled={data.page >= data.totalPages}
- className="px-2.5 py-1 text-base font-bold rounded-sm disabled:opacity-40"
- style={{ border: `1px solid ${B}`, color: N, background: W }}
- >
- 下一页
- </button>
- </div>
- </div>
- {mutation.error && (
- <div
- className="flex-shrink-0 px-6 py-2 text-base font-bold border-t"
- style={{
- color: DANGER,
- background: "rgba(192,57,43,0.05)",
- borderColor: "rgba(192,57,43,0.2)",
- }}
- >
- {mutation.error}
- </div>
- )}
- {editState && (
- <IpEntryEditorModal
- mode={editState.mode}
- target={editState.target}
- form={editState.form}
- onChange={(patch) =>
- setEditState((current) =>
- current
- ? { ...current, form: { ...current.form, ...patch } }
- : current,
- )
- }
- onClose={closeEditor}
- onSubmit={handleSubmit}
- submitting={mutation.submitting}
- />
- )}
- </div>
- );
- }
- function IpEntryEditorModal({
- mode,
- target,
- form,
- onChange,
- onClose,
- onSubmit,
- submitting,
- }: {
- mode: "create" | "edit";
- target: IpEntrySummary | null;
- form: IpEntryForm;
- onChange: (patch: Partial<IpEntryForm>) => void;
- onClose: () => void;
- onSubmit: () => void;
- submitting: boolean;
- }) {
- const title =
- mode === "create" ? "新建IP条目" : `编辑IP条目:${target?.ipValue ?? ""}`;
- return (
- <ModalShell title={title} onClose={onClose}>
- <div className="space-y-3">
- <LabeledInput
- label="IP地址/网段"
- value={form.ipValue}
- onChange={(v) => onChange({ ipValue: v })}
- placeholder="如:192.168.1.5 或 10.0.0.0/24"
- />
- <div
- className="text-base font-bold px-2 py-1.5 rounded-sm"
- style={{ color: N, background: "rgba(var(--dms-bright-rgb),0.05)" }}
- >
- 支持精确IP(IPv4/IPv6)或无主机位的CIDR网段;输入会被自动规范化,
- 网段不允许携带主机位(如 10.0.0.1/24 将被拒绝)。
- </div>
- <LabeledInput
- label="备注"
- value={form.remark}
- onChange={(v) => onChange({ remark: v })}
- placeholder="选填,如:总部办公网段"
- />
- </div>
- <ModalFooter
- onClose={onClose}
- onSubmit={onSubmit}
- submitting={submitting}
- submitLabel={mode === "create" ? "创建" : "保存"}
- />
- </ModalShell>
- );
- }
|