| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584 |
- import {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
- } from "react";
- import {
- Building2,
- ChevronDown,
- ChevronRight,
- Edit2,
- Key,
- Lock,
- Network,
- Plus,
- RefreshCw,
- Shield,
- Trash2,
- Users,
- X,
- } from "lucide-react";
- import {
- EnabledStatus,
- RoleCode,
- SecurityLevel,
- type EnabledStatus as EnabledStatusCode,
- type RoleCode as RoleCodeValue,
- type SecurityLevel as SecurityLevelValue,
- } from "../../shared/api/enums";
- import { useConfirm } from "../../shared/ui/ConfirmDialog";
- import { useDictionaries } from "../dictionaries/dictionaryContext";
- import type { UserSummary } from "../auth/authTypes";
- import {
- flattenOrganizations,
- useOrganizationTree,
- useUserDirectory,
- } from "./directoryState";
- import {
- organizationApi,
- type OrganizationApi,
- } from "./organizationApi";
- import { userApi, type UserApi } from "./userApi";
- import { roleApi, type RoleApi } from "./roleApi";
- import type { RoleSummary } from "./roleTypes";
- import { RoleMaintenancePanel } from "./RoleMaintenancePanel";
- import { IpEntryMaintenancePanel } from "./IpEntryMaintenancePanel";
- import { OrgIpAssignmentModal } from "./OrgIpAssignmentModal";
- import type {
- OrganizationCreateRequest,
- OrganizationNode,
- OrganizationUpdateRequest,
- UserCreateRequest,
- UserQuery,
- UserResetPasswordRequest,
- UserUpdateRequest,
- } from "./directoryTypes";
- import {
- B,
- DANGER,
- MAX_TREE_DEPTH,
- N,
- PBG,
- W,
- type ApiResultState,
- describeError,
- initialResult,
- LabeledInput,
- LabeledNumberInput,
- type OptionEntry,
- LabeledSelect,
- ModalFooter,
- ModalShell,
- roleOptions,
- securityLevelOptions,
- smallButton,
- statusOptions,
- } from "./directoryUi";
- type DirectoryTab =
- | "organizations"
- | "users"
- | "roles"
- | "ipEntries";
- // ===== 主页面 =====
- export function DirectoryPage() {
- const [tab, setTab] = useState<DirectoryTab>("organizations");
- const dictionaries = useDictionaries();
- const tabLabel = (key: DirectoryTab) =>
- key === "organizations"
- ? dictionaries.label("allowedModules", "ORGANIZATION_USER_MANAGEMENT") ||
- "机构维护"
- : key === "users"
- ? "用户维护"
- : key === "roles"
- ? "角色管理"
- : "IP地址列表";
- return (
- <div
- className="w-full h-full flex flex-col overflow-hidden"
- style={{ background: PBG }}
- >
- <div
- className="flex-shrink-0 flex items-center gap-1 px-6 py-2 border-b"
- style={{ background: W, borderColor: "rgba(49,145,235,0.2)" }}
- >
- <button
- type="button"
- onClick={() => setTab("organizations")}
- className="flex items-center gap-1.5 px-3 py-1.5 text-base font-black rounded-sm transition-colors"
- style={{
- color: tab === "organizations" ? W : N,
- background: tab === "organizations" ? B : "transparent",
- }}
- >
- <Building2 size={14} />
- {tabLabel("organizations")}
- </button>
- <button
- type="button"
- onClick={() => setTab("users")}
- className="flex items-center gap-1.5 px-3 py-1.5 text-base font-black rounded-sm transition-colors"
- style={{
- color: tab === "users" ? W : N,
- background: tab === "users" ? B : "transparent",
- }}
- >
- <Users size={14} />
- {tabLabel("users")}
- </button>
- <button
- type="button"
- onClick={() => setTab("roles")}
- className="flex items-center gap-1.5 px-3 py-1.5 text-base font-black rounded-sm transition-colors"
- style={{
- color: tab === "roles" ? W : N,
- background: tab === "roles" ? B : "transparent",
- }}
- >
- <Shield size={14} />
- {tabLabel("roles")}
- </button>
- <button
- type="button"
- onClick={() => setTab("ipEntries")}
- className="flex items-center gap-1.5 px-3 py-1.5 text-base font-black rounded-sm transition-colors"
- style={{
- color: tab === "ipEntries" ? W : N,
- background: tab === "ipEntries" ? B : "transparent",
- }}
- >
- <Network size={14} />
- {tabLabel("ipEntries")}
- </button>
- </div>
- <div className="flex-1 min-h-0 overflow-hidden">
- {tab === "organizations" ? (
- <OrganizationMaintenancePanel />
- ) : tab === "users" ? (
- <UserMaintenancePanel />
- ) : tab === "roles" ? (
- <RoleMaintenancePanel />
- ) : (
- <IpEntryMaintenancePanel />
- )}
- </div>
- </div>
- );
- }
- // ===== 机构维护面板 =====
- interface OrganizationForm {
- orgCode: string;
- orgName: string;
- parentId: string | null;
- sortNo: number;
- status: EnabledStatusCode;
- }
- interface EditState {
- mode: "create" | "edit" | null;
- target: OrganizationNode | null;
- form: OrganizationForm | null;
- rowVersion: number;
- }
- function buildInitialForm(
- mode: "create" | "edit",
- target: OrganizationNode | null,
- defaultParentId: string | null,
- ): OrganizationForm {
- if (mode === "edit" && target) {
- return {
- orgCode: target.orgCode,
- orgName: target.orgName,
- parentId: target.parentId,
- sortNo: target.sortNo,
- status: target.status,
- };
- }
- return {
- orgCode: "",
- orgName: "",
- parentId: defaultParentId,
- sortNo: 0,
- status: EnabledStatus.ENABLED,
- };
- }
- function OrganizationMaintenancePanel({
- api = organizationApi,
- }: {
- api?: OrganizationApi;
- }) {
- const dictionaries = useDictionaries();
- const { confirm: confirmDialog, dialog: confirmDialogNode } = useConfirm();
- const [keyword, setKeyword] = useState("");
- const [retryVersion, setRetryVersion] = useState(0);
- const { tree, status, error } = useOrganizationTree(
- keyword,
- retryVersion,
- api,
- );
- const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
- const [selectedId, setSelectedId] = useState<string | null>(null);
- const [ipAssignTarget, setIpAssignTarget] = useState<OrganizationNode | null>(
- null,
- );
- const [editState, setEditState] = useState<EditState>({
- mode: null,
- target: null,
- form: null,
- rowVersion: 0,
- });
- const [mutation, setMutation] = useState<ApiResultState>(initialResult);
- // 初次加载自动展开所有节点(用 useRef 避免依赖 expanded.size 反复触发)
- const initialExpandDone = useRef(false);
- useEffect(() => {
- if (
- !initialExpandDone.current
- && status === "success"
- && tree.length > 0
- ) {
- initialExpandDone.current = true;
- setExpanded(new Set(flattenOrganizations(tree).map((n) => n.id)));
- }
- }, [status, tree]);
- const flatNodes = useMemo(() => flattenOrganizations(tree), [tree]);
- const nodeMap = useMemo(() => {
- const map = new Map<string, OrganizationNode>();
- flatNodes.forEach((node) => map.set(node.id, node));
- return map;
- }, [flatNodes]);
- const selectedNode = selectedId ? nodeMap.get(selectedId) ?? null : null;
- const refresh = useCallback(() => {
- setRetryVersion((v) => v + 1);
- }, []);
- const toggleExpand = (id: string) => {
- setExpanded((current) => {
- const next = new Set(current);
- if (next.has(id)) next.delete(id);
- else next.add(id);
- return next;
- });
- };
- const openCreate = (parent: OrganizationNode | null) => {
- setEditState({
- mode: "create",
- target: null,
- form: buildInitialForm("create", null, parent?.id ?? null),
- rowVersion: 0,
- });
- setMutation(initialResult);
- };
- const openEdit = (node: OrganizationNode) => {
- setEditState({
- mode: "edit",
- target: node,
- form: buildInitialForm("edit", node, null),
- rowVersion: node.rowVersion,
- });
- setMutation(initialResult);
- };
- const closeEditor = () => {
- setEditState({
- mode: null,
- target: null,
- form: null,
- rowVersion: 0,
- });
- setMutation(initialResult);
- };
- const handleSubmit = async () => {
- if (!editState.form || !editState.mode) return;
- const form = editState.form;
- if (!form.orgCode.trim() || !form.orgName.trim()) {
- setMutation({ submitting: false, error: "编码与名称均不能为空" });
- return;
- }
- setMutation({ submitting: true, error: null });
- try {
- if (editState.mode === "create") {
- const payload: OrganizationCreateRequest = {
- orgCode: form.orgCode.trim(),
- orgName: form.orgName.trim(),
- parentId: form.parentId,
- sortNo: form.sortNo,
- status: form.status,
- };
- const created = await api.create(payload);
- setExpanded((current) => {
- const next = new Set(current);
- if (form.parentId) next.add(form.parentId);
- next.add(created.id);
- return next;
- });
- setSelectedId(created.id);
- } else if (editState.target) {
- const payload: OrganizationUpdateRequest = {
- orgCode: form.orgCode.trim(),
- orgName: form.orgName.trim(),
- parentId: form.parentId,
- sortNo: form.sortNo,
- status: form.status,
- rowVersion: editState.rowVersion,
- };
- await api.update(editState.target.id, payload);
- }
- closeEditor();
- refresh();
- } catch (error) {
- setMutation({ submitting: false, error: describeError(error) });
- }
- };
- const handleDelete = async (node: OrganizationNode) => {
- if (!(await confirmDialog({ message: `确认删除机构「${node.orgName}」?`, danger: true }))) return;
- setMutation({ submitting: true, error: null });
- try {
- await api.delete(node.id, node.rowVersion);
- if (selectedId === node.id) setSelectedId(null);
- setMutation(initialResult);
- refresh();
- } catch (error) {
- setMutation({ submitting: false, error: describeError(error) });
- }
- };
- return (
- <div className="w-full h-full flex flex-col overflow-hidden">
- <div
- className="flex-shrink-0 flex items-center justify-between gap-3 px-6 py-3 border-b"
- style={{ background: W, borderColor: "rgba(49,145,235,0.15)" }}
- >
- <div className="flex items-center gap-2 flex-1 max-w-md">
- <input
- value={keyword}
- onChange={(e) => setKeyword(e.target.value)}
- placeholder="按机构名称或编码筛选"
- className="flex-1 px-3 py-1.5 text-base border rounded-sm outline-none"
- style={{ borderColor: "rgba(49,145,235,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: "新建顶层机构",
- icon: <Plus size={12} />,
- onClick: () => openCreate(null),
- })}
- </div>
- </div>
- <div
- className="flex-1 min-h-0 overflow-auto px-6 py-4"
- style={{ background: PBG }}
- >
- {status === "loading" && (
- <div
- className="text-base font-bold text-center py-8"
- style={{ color: N }}
- >
- 正在加载机构树…
- </div>
- )}
- {status === "error" && (
- <div
- className="text-base font-bold text-center py-8"
- style={{ color: DANGER }}
- >
- {error?.message ?? "加载失败"}
- </div>
- )}
- {status !== "loading" && status !== "error" && tree.length === 0 && (
- <div
- className="text-base font-bold text-center py-8"
- style={{ color: N }}
- >
- 暂无机构数据,点击右上角"新建顶层机构"开始维护。
- </div>
- )}
- {tree.length > 0 && (
- <div
- className="inline-block min-w-full rounded-sm border"
- style={{ background: W, borderColor: "rgba(49,145,235,0.2)" }}
- >
- {tree.map((node) => (
- <OrganizationTreeRow
- key={node.id}
- node={node}
- depth={0}
- expanded={expanded}
- onToggle={toggleExpand}
- selectedId={selectedId}
- onSelect={setSelectedId}
- onAddChild={(parent) => openCreate(parent)}
- onAssignIp={setIpAssignTarget}
- onEdit={openEdit}
- onDelete={handleDelete}
- />
- ))}
- </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>
- )}
- {ipAssignTarget && (
- <OrgIpAssignmentModal
- org={ipAssignTarget}
- onClose={() => setIpAssignTarget(null)}
- onSaved={() => {
- setIpAssignTarget(null);
- refresh();
- }}
- />
- )}
- {editState.form && editState.mode && (
- <OrganizationEditorModal
- mode={editState.mode}
- target={editState.target}
- form={editState.form}
- allNodes={flatNodes}
- onChange={(patch) =>
- setEditState((current) =>
- current.form
- ? {
- ...current,
- form: { ...current.form, ...patch },
- }
- : current,
- )
- }
- onClose={closeEditor}
- onSubmit={handleSubmit}
- submitting={mutation.submitting}
- statusLabel={(code) => dictionaries.label("enabledStatuses", code)}
- />
- )}
- {confirmDialogNode}
- </div>
- );
- }
- function OrganizationTreeRow({
- node,
- depth,
- expanded,
- onToggle,
- selectedId,
- onSelect,
- onAddChild,
- onAssignIp,
- onEdit,
- onDelete,
- }: {
- node: OrganizationNode;
- depth: number;
- expanded: ReadonlySet<string>;
- onToggle: (id: string) => void;
- selectedId: string | null;
- onSelect: (id: string) => void;
- onAddChild: (parent: OrganizationNode) => void;
- onAssignIp: (node: OrganizationNode) => void;
- onEdit: (node: OrganizationNode) => void;
- onDelete: (node: OrganizationNode) => void;
- }) {
- const dictionaries = useDictionaries();
- // 防御性:避免极端深度时栈溢出(后端 _descendants 已做循环检测,此处为前端二次保护)
- if (depth >= MAX_TREE_DEPTH) return null;
- const isOpen = expanded.has(node.id);
- const isSelected = selectedId === node.id;
- const hasChildren = node.children.length > 0;
- return (
- <>
- <div
- className={`flex items-center gap-2 px-3 py-2 cursor-pointer border-b last:border-b-0 transition-colors hover:bg-[rgba(49,145,235,0.18)] ${isSelected ? "bg-[rgba(49,145,235,0.08)]" : "bg-white"}`}
- style={{
- paddingLeft: 12 + depth * 18,
- borderColor: "rgba(49,145,235,0.1)",
- }}
- onClick={() => onSelect(node.id)}
- >
- {hasChildren ? (
- <button
- type="button"
- onClick={(e) => {
- e.stopPropagation();
- onToggle(node.id);
- }}
- className="opacity-70 hover:opacity-100"
- style={{ color: N }}
- >
- {isOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
- </button>
- ) : (
- <span className="inline-block w-[13px]" />
- )}
- <span className="text-base font-black" style={{ color: N }}>
- {node.orgName}
- </span>
- <span
- className="text-base font-bold opacity-60"
- style={{ color: N }}
- >
- ({node.orgCode})
- </span>
- <span
- className="text-base font-bold px-1.5 py-0.5 rounded-sm"
- style={{
- color: node.status === EnabledStatus.ENABLED ? N : DANGER,
- background:
- node.status === EnabledStatus.ENABLED
- ? "rgba(49,145,235,0.1)"
- : "rgba(192,57,43,0.08)",
- }}
- >
- {dictionaries.label("enabledStatuses", node.status)}
- </span>
- <span
- className="text-base font-bold opacity-50 flex-1 truncate ml-2"
- style={{ color: N }}
- >
- {node.orgPath}
- </span>
- <div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
- {smallButton(false, {
- label: "新增下级",
- icon: <Plus size={12} />,
- onClick: () => onAddChild(node),
- })}
- {smallButton(false, {
- label: "分配IP",
- icon: <Network size={12} />,
- onClick: () => onAssignIp(node),
- })}
- {smallButton(false, {
- label: "编辑",
- icon: <Edit2 size={12} />,
- onClick: () => onEdit(node),
- })}
- {smallButton(false, {
- label: "删除",
- icon: <Trash2 size={12} />,
- onClick: () => onDelete(node),
- danger: true,
- })}
- </div>
- </div>
- {isOpen &&
- node.children.map((child) => (
- <OrganizationTreeRow
- key={child.id}
- node={child}
- depth={depth + 1}
- expanded={expanded}
- onToggle={onToggle}
- selectedId={selectedId}
- onSelect={onSelect}
- onAddChild={onAddChild}
- onAssignIp={onAssignIp}
- onEdit={onEdit}
- onDelete={onDelete}
- />
- ))}
- </>
- );
- }
- function OrganizationEditorModal({
- mode,
- target,
- form,
- allNodes,
- onChange,
- onClose,
- onSubmit,
- submitting,
- statusLabel,
- }: {
- mode: "create" | "edit";
- target: OrganizationNode | null;
- form: OrganizationForm;
- allNodes: OrganizationNode[];
- onChange: (patch: Partial<OrganizationForm>) => void;
- onClose: () => void;
- onSubmit: () => void;
- submitting: boolean;
- statusLabel: (code: EnabledStatusCode) => string;
- }) {
- const dictionaries = useDictionaries();
- const title = mode === "create" ? "新建机构" : `编辑机构:${target?.orgName ?? ""}`;
- const isEdit = mode === "edit";
- const parentOptions = useMemo(() => {
- const result: OrganizationNode[] = [];
- const blockedIds = new Set<string>();
- if (mode === "edit" && target) {
- blockedIds.add(target.id);
- const collectDescendants = (root: OrganizationNode) => {
- root.children.forEach((child) => {
- blockedIds.add(child.id);
- collectDescendants(child);
- });
- };
- collectDescendants(target);
- }
- allNodes.forEach((node) => {
- if (!blockedIds.has(node.id)) result.push(node);
- });
- return result;
- }, [allNodes, mode, target]);
- const parentSelectOptions = useMemo<OptionEntry<string>[]>(
- () =>
- parentOptions.map((node) => ({
- value: node.id,
- label: `${node.orgPath} (${node.orgCode})`,
- })),
- [parentOptions],
- );
- const statusSelectOptions = useMemo<OptionEntry<EnabledStatusCode>[]>(
- () => statusOptions().map((code) => ({ value: code, label: statusLabel(code) })),
- [statusLabel],
- );
- return (
- <ModalShell title={title} onClose={onClose}>
- <div className="space-y-3">
- <LabeledInput
- label="机构编码"
- value={form.orgCode}
- onChange={(v) => onChange({ orgCode: v })}
- placeholder="如:ORG_HQ"
- />
- <LabeledInput
- label="机构名称"
- value={form.orgName}
- onChange={(v) => onChange({ orgName: v })}
- />
- <LabeledSelect<string>
- label="上级机构"
- value={form.parentId ?? ""}
- onChange={(v) => onChange({ parentId: v || null })}
- options={[
- { value: "", label: "— 顶层机构 —" },
- ...parentSelectOptions,
- ]}
- />
- <LabeledNumberInput
- label="排序号"
- value={form.sortNo}
- onChange={(v) => onChange({ sortNo: v })}
- />
- <LabeledSelect<EnabledStatusCode>
- label="状态"
- value={form.status}
- onChange={(v) => onChange({ status: v })}
- options={statusSelectOptions}
- />
- </div>
- <ModalFooter
- onClose={onClose}
- onSubmit={onSubmit}
- submitting={submitting}
- submitLabel={mode === "create" ? "创建" : "保存"}
- secondaryTooltip={dictionaries.label(
- "auditActions",
- mode === "create" ? "CREATE_ORGANIZATION" : "EDIT_ORGANIZATION",
- )}
- />
- </ModalShell>
- );
- }
- // ===== 用户维护面板 =====
- interface UserForm {
- username: string;
- realName: string;
- initialPassword: string;
- organizationId: string | null;
- roleCode: RoleCodeValue;
- roleId: string | null;
- securityLevel: SecurityLevelValue;
- status: EnabledStatusCode;
- }
- interface UserEditState {
- mode: "create" | "edit" | null;
- target: UserSummary | null;
- form: UserForm | null;
- rowVersion: number;
- }
- interface ResetState {
- target: UserSummary | null;
- newPassword: string;
- }
- function buildInitialUserForm(
- mode: "create" | "edit",
- target: UserSummary | null,
- defaultOrgId: string | null,
- ): UserForm {
- if (mode === "edit" && target) {
- return {
- username: target.username,
- realName: target.realName,
- initialPassword: "",
- organizationId: target.organizationId,
- roleCode: target.roleCode,
- roleId: target.roleId,
- securityLevel: target.securityLevel,
- status: target.status,
- };
- }
- return {
- username: "",
- realName: "",
- initialPassword: "",
- organizationId: defaultOrgId,
- roleCode: RoleCode.USER,
- roleId: null,
- securityLevel: SecurityLevel.INTERNAL,
- status: EnabledStatus.ENABLED,
- };
- }
- function UserMaintenancePanel({
- userApiImpl = userApi,
- orgApiImpl = organizationApi,
- roleApiImpl = roleApi,
- }: {
- userApiImpl?: UserApi;
- orgApiImpl?: OrganizationApi;
- roleApiImpl?: RoleApi;
- }) {
- const dictionaries = useDictionaries();
- const { confirm: confirmDialog, dialog: confirmDialogNode } = useConfirm();
- const [orgKeyword, setOrgKeyword] = useState("");
- const [orgRetry, setOrgRetry] = useState(0);
- // 功能角色下拉数据(仅 roleCode=USER 时可选)
- const [roles, setRoles] = useState<RoleSummary[]>([]);
- useEffect(() => {
- let cancelled = false;
- roleApiImpl
- .getRoles({ pageSize: 100 })
- .then((page) => {
- if (!cancelled) setRoles(page.items);
- })
- .catch(() => {
- // 角色加载失败不阻塞用户维护,下拉为空即可
- });
- return () => {
- cancelled = true;
- };
- }, [roleApiImpl]);
- const { tree } = useOrganizationTree(orgKeyword, orgRetry, orgApiImpl);
- const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
- const [includeDescendants, setIncludeDescendants] = useState(false);
- const [userKeyword, setUserKeyword] = useState("");
- const [userStatus, setUserStatus] = useState<EnabledStatusCode | "">("");
- const [page, setPage] = useState(1);
- const pageSize = 20;
- const [userRetry, setUserRetry] = useState(0);
- const userQuery: UserQuery = useMemo(
- () => ({
- organizationId: selectedOrgId ?? undefined,
- includeDescendants,
- keyword: userKeyword.trim() || undefined,
- status: userStatus || undefined,
- page,
- pageSize,
- }),
- [selectedOrgId, includeDescendants, userKeyword, userStatus, page],
- );
- const { data, status, error } = useUserDirectory(
- userQuery,
- userRetry,
- userApiImpl,
- );
- const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
- const [userEdit, setUserEdit] = useState<UserEditState>({
- mode: null,
- target: null,
- form: null,
- rowVersion: 0,
- });
- const [resetState, setResetState] = useState<ResetState | null>(null);
- const [mutation, setMutation] = useState<ApiResultState>(initialResult);
- // 初次加载自动展开用户筛选机构树(同上 useRef 模式)
- const initialUserExpandDone = useRef(false);
- useEffect(() => {
- if (!initialUserExpandDone.current && tree.length > 0) {
- initialUserExpandDone.current = true;
- setExpanded(new Set(flattenOrganizations(tree).map((n) => n.id)));
- }
- }, [tree]);
- const refreshUsers = useCallback(() => setUserRetry((v) => v + 1), []);
- const refreshTree = useCallback(() => setOrgRetry((v) => v + 1), []);
- const openCreateUser = () => {
- setUserEdit({
- mode: "create",
- target: null,
- form: buildInitialUserForm("create", null, selectedOrgId),
- rowVersion: 0,
- });
- setMutation(initialResult);
- };
- const openEditUser = (user: UserSummary) => {
- setUserEdit({
- mode: "edit",
- target: user,
- form: buildInitialUserForm("edit", user, null),
- rowVersion: user.rowVersion,
- });
- setMutation(initialResult);
- };
- const closeUserEditor = () => {
- setUserEdit({
- mode: null,
- target: null,
- form: null,
- rowVersion: 0,
- });
- setMutation(initialResult);
- };
- const handleSubmitUser = async () => {
- if (!userEdit.form || !userEdit.mode) return;
- const form = userEdit.form;
- if (!form.username.trim() || !form.realName.trim()) {
- setMutation({ submitting: false, error: "用户名与姓名不能为空" });
- return;
- }
- if (userEdit.mode === "create" && form.initialPassword.length < 8) {
- setMutation({ submitting: false, error: "初始密码至少8位" });
- return;
- }
- setMutation({ submitting: true, error: null });
- try {
- if (userEdit.mode === "create") {
- const payload: UserCreateRequest = {
- username: form.username.trim(),
- realName: form.realName.trim(),
- initialPassword: form.initialPassword,
- organizationId: form.organizationId,
- roleCode: form.roleCode,
- roleId: form.roleCode === RoleCode.USER ? form.roleId : null,
- securityLevel: form.securityLevel,
- status: form.status,
- };
- await userApiImpl.create(payload);
- } else if (userEdit.target) {
- const payload: UserUpdateRequest = {
- realName: form.realName.trim(),
- organizationId: form.organizationId,
- roleCode: form.roleCode,
- roleId: form.roleCode === RoleCode.USER ? form.roleId : null,
- securityLevel: form.securityLevel,
- status: form.status,
- rowVersion: userEdit.rowVersion,
- };
- await userApiImpl.update(userEdit.target.id, payload);
- }
- closeUserEditor();
- refreshUsers();
- } catch (error) {
- setMutation({ submitting: false, error: describeError(error) });
- }
- };
- const handleDeleteUser = async (user: UserSummary) => {
- if (!(await confirmDialog({ message: `确认删除用户「${user.realName}」?`, danger: true }))) return;
- setMutation({ submitting: true, error: null });
- try {
- await userApiImpl.delete(user.id, user.rowVersion);
- setMutation(initialResult);
- refreshUsers();
- } catch (error) {
- setMutation({ submitting: false, error: describeError(error) });
- }
- };
- const handleResetPassword = async () => {
- if (!resetState) return;
- if (resetState.newPassword.length < 8) {
- setMutation({ submitting: false, error: "新密码至少8位" });
- return;
- }
- setMutation({ submitting: true, error: null });
- try {
- const payload: UserResetPasswordRequest = {
- newPassword: resetState.newPassword,
- rowVersion: resetState.target?.rowVersion ?? 0,
- };
- await userApiImpl.resetPassword(resetState.target!.id, payload);
- setResetState(null);
- setMutation(initialResult);
- refreshUsers();
- } catch (error) {
- setMutation({ submitting: false, error: describeError(error) });
- }
- };
- return (
- <div className="w-full h-full flex overflow-hidden">
- <div
- className="flex-shrink-0 border-r flex flex-col"
- style={{
- width: 320,
- background: W,
- borderColor: "rgba(49,145,235,0.15)",
- }}
- >
- <div
- className="flex-shrink-0 px-4 py-2 border-b text-base font-black tracking-wide"
- style={{
- color: N,
- borderColor: "rgba(49,145,235,0.15)",
- background: PBG,
- }}
- >
- 机构筛选
- </div>
- <div className="flex-shrink-0 px-3 py-2 border-b" style={{ borderColor: "rgba(49,145,235,0.1)" }}>
- <input
- value={orgKeyword}
- onChange={(e) => setOrgKeyword(e.target.value)}
- placeholder="按机构名称或编码筛选"
- className="w-full px-2.5 py-1.5 text-base border rounded-sm outline-none"
- style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
- />
- </div>
- <div className="flex-1 min-h-0 overflow-auto py-1">
- <button
- type="button"
- onClick={() => setSelectedOrgId(null)}
- className="w-full text-left px-3 py-1.5 text-base font-bold transition-colors"
- style={{
- color: selectedOrgId === null ? W : N,
- background: selectedOrgId === null ? B : "transparent",
- }}
- >
- 全部机构
- </button>
- {tree.map((node) => (
- <UserOrgTreeRow
- key={node.id}
- node={node}
- depth={0}
- expanded={expanded}
- onToggle={(id) =>
- setExpanded((current) => {
- const next = new Set(current);
- if (next.has(id)) next.delete(id);
- else next.add(id);
- return next;
- })
- }
- selectedId={selectedOrgId}
- onSelect={setSelectedOrgId}
- />
- ))}
- </div>
- <div
- className="flex-shrink-0 px-3 py-2 border-t flex items-center justify-between"
- style={{ borderColor: "rgba(49,145,235,0.15)" }}
- >
- <label
- className="flex items-center gap-2 text-base font-bold cursor-pointer"
- style={{ color: N }}
- >
- <input
- type="checkbox"
- checked={includeDescendants}
- onChange={(e) => setIncludeDescendants(e.target.checked)}
- />
- 包含下级机构
- </label>
- {smallButton(false, {
- label: "刷新",
- icon: <RefreshCw size={12} />,
- onClick: refreshTree,
- })}
- </div>
- </div>
- <div className="flex-1 min-w-0 flex flex-col overflow-hidden">
- <div
- className="flex-shrink-0 flex items-center justify-between gap-3 px-6 py-3 border-b"
- style={{ background: W, borderColor: "rgba(49,145,235,0.15)" }}
- >
- <div className="flex items-center gap-2 flex-1 max-w-md">
- <input
- value={userKeyword}
- onChange={(e) => {
- setUserKeyword(e.target.value);
- setPage(1);
- }}
- placeholder="按用户名或姓名筛选"
- className="flex-1 px-3 py-1.5 text-base border rounded-sm outline-none"
- style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
- />
- {userKeyword && (
- <button
- type="button"
- onClick={() => setUserKeyword("")}
- className="text-base font-bold opacity-60 hover:opacity-100"
- style={{ color: N }}
- >
- <X size={14} />
- </button>
- )}
- <select
- value={userStatus}
- onChange={(e) => {
- setUserStatus(
- (e.target.value || "") as EnabledStatusCode | "",
- );
- setPage(1);
- }}
- className="px-2 py-1.5 text-base border rounded-sm outline-none"
- style={{ borderColor: "rgba(49,145,235,0.4)", color: N }}
- >
- <option value="">全部状态</option>
- {statusOptions().map((code) => (
- <option key={code} value={code}>
- {dictionaries.label("enabledStatuses", code)}
- </option>
- ))}
- </select>
- </div>
- <div className="flex items-center gap-1.5">
- {smallButton(false, {
- label: "刷新",
- icon: <RefreshCw size={12} />,
- onClick: refreshUsers,
- })}
- {smallButton(true, {
- label: "新建用户",
- icon: <Plus size={12} />,
- onClick: openCreateUser,
- })}
- </div>
- </div>
- <div
- className="flex-1 min-h-0 overflow-auto px-6 py-4"
- style={{ background: PBG }}
- >
- {status === "loading" && (
- <div
- className="text-base font-bold text-center py-8"
- style={{ color: N }}
- >
- 正在加载用户列表…
- </div>
- )}
- {status === "error" && (
- <div
- className="text-base font-bold text-center py-8"
- style={{ color: DANGER }}
- >
- {error?.message ?? "加载失败"}
- </div>
- )}
- {status !== "loading" && status !== "error" && data.items.length === 0 && (
- <div
- className="text-base font-bold text-center py-8"
- style={{ color: N }}
- >
- 暂无用户,请点击右上角"新建用户"。
- </div>
- )}
- {data.items.length > 0 && (
- <table
- className="w-full text-base"
- style={{ background: W, borderCollapse: "collapse" }}
- >
- <thead>
- <tr style={{ background: PBG }}>
- {[
- "用户名",
- "姓名",
- "所属机构",
- "角色",
- "密级",
- "状态",
- "最近登录",
- "操作",
- ].map((h) => (
- <th
- key={h}
- className="text-left px-3 py-2 text-base font-black border"
- style={{
- color: N,
- borderColor: "rgba(49,145,235,0.2)",
- }}
- >
- {h}
- </th>
- ))}
- </tr>
- </thead>
- <tbody>
- {data.items.map((user) => (
- <tr
- key={user.id}
- className="border-t transition-colors hover:bg-[rgba(49,145,235,0.18)]"
- style={{ borderColor: "rgba(49,145,235,0.12)" }}
- >
- <td className="px-3 py-2 font-bold" style={{ color: N }}>
- {user.username}
- </td>
- <td className="px-3 py-2 text-base font-bold" style={{ color: N }}>
- {user.realName}
- </td>
- <td className="px-3 py-2 text-base font-bold" style={{ color: N }}>
- {user.organizationName || "—"}
- </td>
- <td className="px-3 py-2 text-base font-bold" style={{ color: N }}>
- {user.roleCode === RoleCode.USER && user.roleName
- ? user.roleName
- : dictionaries.label("roleCodes", user.roleCode)}
- </td>
- <td className="px-3 py-2 text-base font-bold" style={{ color: N }}>
- {dictionaries.label("securityLevels", user.securityLevel)}
- </td>
- <td className="px-3 py-2 text-base font-bold" style={{ color: N }}>
- {dictionaries.label("enabledStatuses", user.status)}
- </td>
- <td className="px-3 py-2 text-base" style={{ color: N }}>
- {user.lastLoginAt ?? "—"}
- </td>
- <td className="px-3 py-2">
- <div className="flex items-center gap-1.5">
- {smallButton(false, {
- label: "编辑",
- icon: <Edit2 size={12} />,
- onClick: () => openEditUser(user),
- })}
- {smallButton(false, {
- label: "重置密码",
- icon: <Key size={12} />,
- onClick: () =>
- setResetState({
- target: user,
- newPassword: "",
- }),
- })}
- {smallButton(false, {
- label: "删除",
- icon: <Trash2 size={12} />,
- onClick: () => handleDeleteUser(user),
- 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(49,145,235,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>
- </div>
- {mutation.error && (
- <div
- className="flex-shrink-0 px-6 py-2 text-base font-bold"
- style={{ color: DANGER, background: "rgba(192,57,43,0.05)" }}
- >
- {mutation.error}
- </div>
- )}
- {userEdit.form && userEdit.mode && (
- <UserEditorModal
- mode={userEdit.mode}
- target={userEdit.target}
- form={userEdit.form}
- organizations={flattenOrganizations(tree)}
- roles={roles}
- onChange={(patch) =>
- setUserEdit((current) =>
- current.form
- ? { ...current, form: { ...current.form, ...patch } }
- : current,
- )
- }
- onClose={closeUserEditor}
- onSubmit={handleSubmitUser}
- submitting={mutation.submitting}
- />
- )}
- {resetState && resetState.target && (
- <ResetPasswordModal
- target={resetState.target}
- value={resetState.newPassword}
- onChange={(v) =>
- setResetState((current) =>
- current ? { ...current, newPassword: v } : current,
- )
- }
- onClose={() => {
- setResetState(null);
- setMutation(initialResult);
- }}
- onSubmit={handleResetPassword}
- submitting={mutation.submitting}
- />
- )}
- {confirmDialogNode}
- </div>
- );
- }
- function UserOrgTreeRow({
- node,
- depth,
- expanded,
- onToggle,
- selectedId,
- onSelect,
- }: {
- node: OrganizationNode;
- depth: number;
- expanded: ReadonlySet<string>;
- onToggle: (id: string) => void;
- selectedId: string | null;
- onSelect: (id: string) => void;
- }) {
- // 防御性:避免极端深度时栈溢出(后端 _descendants 已做循环检测,此处为前端二次保护)
- if (depth >= MAX_TREE_DEPTH) return null;
- const isOpen = expanded.has(node.id);
- const isSelected = selectedId === node.id;
- const hasChildren = node.children.length > 0;
- return (
- <>
- <div
- className={`flex items-center gap-1.5 px-3 py-1.5 cursor-pointer rounded-sm transition-colors hover:bg-[rgba(49,145,235,0.18)] ${isSelected ? "bg-[rgba(49,145,235,0.08)]" : ""}`}
- style={{ paddingLeft: 12 + depth * 14 }}
- onClick={() => onSelect(node.id)}
- >
- {hasChildren ? (
- <button
- type="button"
- onClick={(e) => {
- e.stopPropagation();
- onToggle(node.id);
- }}
- className="opacity-70 hover:opacity-100"
- style={{ color: N }}
- >
- {isOpen ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
- </button>
- ) : (
- <span className="inline-block w-[11px]" />
- )}
- <span className="text-base font-bold truncate flex-1" style={{ color: N }}>
- {node.orgName}
- </span>
- {isSelected && (
- <span
- className="text-base font-black px-1.5 py-0.5 rounded-sm"
- style={{ background: B, color: W }}
- >
- 已选
- </span>
- )}
- </div>
- {isOpen &&
- node.children.map((child) => (
- <UserOrgTreeRow
- key={child.id}
- node={child}
- depth={depth + 1}
- expanded={expanded}
- onToggle={onToggle}
- selectedId={selectedId}
- onSelect={onSelect}
- />
- ))}
- </>
- );
- }
- function UserEditorModal({
- mode,
- target,
- form,
- organizations,
- roles,
- onChange,
- onClose,
- onSubmit,
- submitting,
- }: {
- mode: "create" | "edit";
- target: UserSummary | null;
- form: UserForm;
- organizations: OrganizationNode[];
- roles: RoleSummary[];
- onChange: (patch: Partial<UserForm>) => void;
- onClose: () => void;
- onSubmit: () => void;
- submitting: boolean;
- }) {
- const dictionaries = useDictionaries();
- const title = mode === "create" ? "新建用户" : `编辑用户:${target?.realName ?? ""}`;
- const isEdit = mode === "edit";
- const orgSelectOptions = useMemo<OptionEntry<string>[]>(
- () =>
- organizations.map((node) => ({
- value: node.id,
- label: `${node.orgPath} (${node.orgCode})`,
- })),
- [organizations],
- );
- const roleSelectOptions = useMemo<OptionEntry<RoleCodeValue>[]>(
- () =>
- roleOptions().map((code) => ({
- value: code,
- label: dictionaries.label("roleCodes", code),
- })),
- [dictionaries],
- );
- const customRoleOptions = useMemo<OptionEntry<string>[]>(
- () =>
- roles.map((role) => ({
- value: role.id,
- label:
- role.status === EnabledStatus.ENABLED
- ? role.roleName
- : `${role.roleName}(已停用)`,
- })),
- [roles],
- );
- const securitySelectOptions = useMemo<OptionEntry<SecurityLevelValue>[]>(
- () =>
- securityLevelOptions().map((code) => ({
- value: code,
- label: dictionaries.label("securityLevels", code),
- })),
- [dictionaries],
- );
- const statusSelectOptions = useMemo<OptionEntry<EnabledStatusCode>[]>(
- () =>
- statusOptions().map((code) => ({
- value: code,
- label: dictionaries.label("enabledStatuses", code),
- })),
- [dictionaries],
- );
- return (
- <ModalShell title={title} onClose={onClose}>
- <div className="space-y-3">
- <LabeledInput
- label="用户名"
- value={form.username}
- onChange={(v) => onChange({ username: v })}
- disabled={isEdit}
- placeholder="登录用户名"
- />
- <LabeledInput
- label="姓名"
- value={form.realName}
- onChange={(v) => onChange({ realName: v })}
- />
- {mode === "create" && (
- <LabeledInput
- label="初始密码(至少8位)"
- value={form.initialPassword}
- onChange={(v) => onChange({ initialPassword: v })}
- type="password"
- autoComplete="new-password"
- />
- )}
- <LabeledSelect<string>
- label="所属机构"
- value={form.organizationId ?? ""}
- onChange={(v) => onChange({ organizationId: v || null })}
- options={[
- { value: "", label: "— 未指定 —" },
- ...orgSelectOptions,
- ]}
- />
- <LabeledSelect<RoleCodeValue>
- label="用户类型"
- value={form.roleCode}
- onChange={(v) =>
- onChange(
- v === RoleCode.USER
- ? { roleCode: v }
- : { roleCode: v, roleId: null },
- )
- }
- options={roleSelectOptions}
- />
- {form.roleCode === RoleCode.USER && (
- <LabeledSelect<string>
- label="角色"
- value={form.roleId ?? ""}
- onChange={(v) => onChange({ roleId: v || null })}
- options={[
- { value: "", label: "— 未指定(跟随默认角色) —" },
- ...customRoleOptions,
- ]}
- />
- )}
- <LabeledSelect<SecurityLevelValue>
- label="密级"
- value={form.securityLevel}
- onChange={(v) => onChange({ securityLevel: v })}
- options={securitySelectOptions}
- />
- <LabeledSelect<EnabledStatusCode>
- label="状态"
- value={form.status}
- onChange={(v) => onChange({ status: v })}
- options={statusSelectOptions}
- />
- </div>
- <ModalFooter
- onClose={onClose}
- onSubmit={onSubmit}
- submitting={submitting}
- submitLabel={mode === "create" ? "创建" : "保存"}
- />
- </ModalShell>
- );
- }
- function ResetPasswordModal({
- target,
- value,
- onChange,
- onClose,
- onSubmit,
- submitting,
- }: {
- target: UserSummary;
- value: string;
- onChange: (v: string) => void;
- onClose: () => void;
- onSubmit: () => void;
- submitting: boolean;
- }) {
- return (
- <ModalShell
- title={`重置密码:${target.realName}(${target.username})`}
- onClose={onClose}
- >
- <div className="space-y-3">
- <div
- className="text-base font-bold flex items-center gap-1 p-2 rounded-sm"
- style={{ color: DANGER, background: "rgba(192,57,43,0.05)" }}
- >
- <Lock size={12} />
- 新密码生效后该用户原有登录凭证立即失效。
- </div>
- <LabeledInput
- label="新密码(至少8位)"
- value={value}
- onChange={onChange}
- type="password"
- autoComplete="new-password"
- />
- </div>
- <ModalFooter
- onClose={onClose}
- onSubmit={onSubmit}
- submitting={submitting}
- submitLabel="重置密码"
- danger
- />
- </ModalShell>
- );
- }
|